Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion architecture/05-build-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Resolution follows a 3-tier priority for each wheel:
| 2. Auto-detect `dist/coglet-*.whl` | Dev builds only |
| 3. Default | Install from PyPI |

Local wheel files are copied into `.cog/build/` and referenced via the `cog_build` named build context, then `COPY --from=cog_build`'d and `pip install`'d in the Dockerfile.
Cog stages local SDK, Coglet, and user-provided wheel or source archive requirements before installing dependencies. The later `COPY . /src` remains a separate source layer.

---

Expand Down
16 changes: 15 additions & 1 deletion docs/llms.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion docs/yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ Your `cog.yaml` file can set either `python_packages` or `python_requirements`,

This follows the standard [requirements.txt](https://pip.pypa.io/en/stable/reference/requirements-file-format/) format.

Requirements files can list a local wheel or source archive:

`requirements.txt`:

```
./dist/mylib-0.1.0-py3-none-any.whl
./vendor/helperlib.zip
./packages/localpkg.tar.gz
```

Cog supports `.whl`, `.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, and `.tar.xz` files. Paths may contain spaces, are resolved relative to the requirements file, and must stay inside the project directory.

Only bare paths are supported. Local directories, local direct references such as `name @ path`, and options, hashes, extras, or markers on a local artifact line are rejected. Remote direct references remain supported. Cog overrides any `cog` or `coglet` distribution installed by a local artifact; use `build.sdk_version` or `COG_SDK_WHEEL` for Cog, and `COGLET_WHEEL` for Coglet.

To install Git-hosted Python packages, add `git` to the `system_packages` list, then use the `git+https://` syntax to specify the package name. For example:

`cog.yaml`:
Expand Down Expand Up @@ -136,7 +150,7 @@ build:
- cd cowsay-3.7.0 && make install
```

Your code is _not_ available to commands in `run`. This is so we can build your image efficiently when running locally.
Your source code is not available to `run` commands. List local wheels and source archives in `python_requirements` instead.

Each command in `run` can be either a string or a dictionary in the following format:

Expand Down
92 changes: 92 additions & 0 deletions integration-tests/tests/local_python_requirement_artifact.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
mkdir dist
exec tar -czf dist/local_pkg-0.1.0.tar.gz setup.py local_pkg
exec python3 make_artifacts.py

# Ensure the imports depend on the artifacts being installed.
rm local_pkg
rm local_zip_pkg
rm local_wheel_pkg
rm setup.py
rm zip_setup.py
rm make_artifacts.py

cog build -t $TEST_IMAGE
cog predict $TEST_IMAGE
stdout 'tar package 0.1.0; zip package 0.2.0; wheel package 0.3.0'

-- cog.yaml --
build:
python_version: "3.12"
python_requirements: requirements.txt
predict: predict.py:Predictor

-- requirements.txt --
./dist/local_pkg-0.1.0.tar.gz
./dist/local zip pkg-0.2.0.zip
./dist/local_wheel_pkg-0.3.0-py3-none-any.whl

-- setup.py --
from setuptools import setup

setup(name="local-pkg", version="0.1.0", packages=["local_pkg"])

-- local_pkg/__init__.py --
MESSAGE = "tar package"

-- zip_setup.py --
from setuptools import setup

setup(name="local-zip-pkg", version="0.2.0", packages=["local_zip_pkg"])

-- local_zip_pkg/__init__.py --
MESSAGE = "zip package"

-- local_wheel_pkg/__init__.py --
MESSAGE = "wheel package"

-- make_artifacts.py --
from zipfile import ZIP_DEFLATED, ZipFile


with ZipFile("dist/local zip pkg-0.2.0.zip", "w", ZIP_DEFLATED) as archive:
archive.write("zip_setup.py", "setup.py")
archive.write("local_zip_pkg/__init__.py", "local_zip_pkg/__init__.py")

dist_info = "local_wheel_pkg-0.3.0.dist-info"
with ZipFile(
"dist/local_wheel_pkg-0.3.0-py3-none-any.whl", "w", ZIP_DEFLATED
) as wheel:
wheel.write("local_wheel_pkg/__init__.py", "local_wheel_pkg/__init__.py")
wheel.writestr(
f"{dist_info}/METADATA",
"Metadata-Version: 2.1\nName: local-wheel-pkg\nVersion: 0.3.0\n",
)
wheel.writestr(
f"{dist_info}/WHEEL",
"Wheel-Version: 1.0\nGenerator: cog-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
)
wheel.writestr(
f"{dist_info}/RECORD",
"local_wheel_pkg/__init__.py,,\n"
f"{dist_info}/METADATA,,\n"
f"{dist_info}/WHEEL,,\n"
f"{dist_info}/RECORD,,\n",
)

-- predict.py --
from importlib.metadata import version

from cog import BasePredictor

from local_pkg import MESSAGE as TAR_MESSAGE
from local_wheel_pkg import MESSAGE as WHEEL_MESSAGE
from local_zip_pkg import MESSAGE as ZIP_MESSAGE


class Predictor(BasePredictor):
def predict(self) -> str:
return (
f"{TAR_MESSAGE} {version('local-pkg')}; "
f"{ZIP_MESSAGE} {version('local-zip-pkg')}; "
f"{WHEEL_MESSAGE} {version('local-wheel-pkg')}"
)
122 changes: 121 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
Expand Down Expand Up @@ -60,6 +61,14 @@ type Build struct {
SDKVersion string `json:"sdk_version,omitempty" yaml:"sdk_version,omitempty"`

pythonRequirementsContent []string
localPackageArtifacts []LocalPackageArtifact
}

// LocalPackageArtifact is a local requirement staged into the Docker build context.
type LocalPackageArtifact struct {
Requirement string // Normalized requirements-file line.
SourcePath string // Canonical source path used for containment checks and reads.
RelativePath string // Project-relative staging path preserving the requirement filename.
}

type Concurrency struct {
Expand Down Expand Up @@ -227,6 +236,9 @@ func (c *Config) cudaFromTF() (tfVersion string, tfCUDA string, tfCuDNN string,

func (c *Config) pythonPackageVersion(name string) (version string, ok bool) {
for _, pkg := range c.Build.pythonRequirementsContent {
if isLocalPackageArtifactRequirement(pkg) {
continue
}
pkgName := requirements.PackageName(pkg)
if pkgName == name {
versions := requirements.Versions(pkg)
Expand Down Expand Up @@ -261,6 +273,9 @@ func splitPythonVersion(version string) (major int, minor int, err error) {
// Use this when building a Config struct directly (not from YAML).
// For configs loaded from YAML, use Load() instead which handles validation and completion.
func (c *Config) Complete(projectDir string) error {
c.Build.pythonRequirementsContent = nil
c.Build.localPackageArtifacts = nil

// Validate mutual exclusion of python_packages and python_requirements
if len(c.Build.PythonPackages) > 0 && c.Build.PythonRequirements != "" {
return fmt.Errorf("only one of python_packages or python_requirements can be set in your cog.yaml, not both")
Expand Down Expand Up @@ -297,6 +312,93 @@ func (c *Config) Complete(projectDir string) error {
return nil
}

// ResolveLocalPackageArtifacts validates local requirements for a generated Dockerfile
// and replaces the artifacts returned by LocalPackageArtifacts.
func (c *Config) ResolveLocalPackageArtifacts(projectDir string) error {
c.Build.localPackageArtifacts = nil
if c.Build.PythonRequirements == "" {
return nil
}

requirementsFilePath := c.Build.PythonRequirements
if !filepath.IsAbs(requirementsFilePath) {
requirementsFilePath = filepath.Join(projectDir, requirementsFilePath)
}
return c.loadLocalPackageArtifacts(projectDir, requirementsFilePath)
}

func (c *Config) loadLocalPackageArtifacts(projectDir string, requirementsFilePath string) error {
projectRoot, err := filepath.Abs(projectDir)
if err != nil {
return fmt.Errorf("failed to resolve project directory: %w", err)
}
projectRoot, err = filepath.EvalSymlinks(projectRoot)
if err != nil {
return fmt.Errorf("failed to resolve project directory symlinks: %w", err)
}

requirementsDir := filepath.Dir(requirementsFilePath)
artifacts := []LocalPackageArtifact{}
for _, line := range c.Build.pythonRequirementsContent {
requirement := strings.TrimSpace(line)
artifactPath, ok, err := requirements.ParseLocalArtifactRequirement(requirement)
if err != nil {
return err
}
if !ok {
continue
}

resolvedPath := artifactPath
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Join(requirementsDir, resolvedPath)
}
absPath, err := filepath.Abs(resolvedPath)
if err != nil {
return fmt.Errorf("failed to resolve local Python package artifact %q: %w", artifactPath, err)
}
resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(absPath))
if err != nil {
return fmt.Errorf("local Python package artifact %q not found: %w", artifactPath, err)
}
stagedPath := filepath.Join(resolvedParent, filepath.Base(absPath))
if !pathWithin(projectRoot, stagedPath) {
return fmt.Errorf("local Python package artifact %q must be inside the project directory", artifactPath)
}
canonicalPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return fmt.Errorf("local Python package artifact %q not found: %w", artifactPath, err)
}
info, err := os.Stat(canonicalPath)
if err != nil {
return fmt.Errorf("failed to inspect local Python package artifact %q: %w", artifactPath, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("local Python package artifact %q must be a regular file", artifactPath)
}
if !pathWithin(projectRoot, canonicalPath) {
return fmt.Errorf("local Python package artifact %q must be inside the project directory", artifactPath)
}

relPath, err := filepath.Rel(projectRoot, stagedPath)
if err != nil {
return fmt.Errorf("failed to resolve local Python package artifact %q relative to project directory: %w", artifactPath, err)
}
artifacts = append(artifacts, LocalPackageArtifact{
Requirement: requirement,
SourcePath: canonicalPath,
RelativePath: relPath,
})
}
c.Build.localPackageArtifacts = artifacts
return nil
}

func pathWithin(root string, target string) bool {
rel, err := filepath.Rel(root, target)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// PythonRequirementsForArch returns a requirements.txt file with all the GPU packages resolved for given OS and architecture.
func (c *Config) PythonRequirementsForArch(goos string, goarch string, includePackages []string) (string, error) {
packages := []string{}
Expand Down Expand Up @@ -327,7 +429,10 @@ func (c *Config) PythonRequirementsForArch(goos string, goarch string, includePa
}
}

packageName := requirements.PackageName(archPkg)
packageName := ""
if !isLocalPackageArtifactRequirement(archPkg) {
packageName = requirements.PackageName(archPkg)
}
if packageName != "" {
foundIdx := -1
for i, includePkg := range includePackageNames {
Expand Down Expand Up @@ -362,9 +467,17 @@ func (c *Config) PythonRequirementsForArch(goos string, goarch string, includePa
return strings.Join(lines, "\n"), nil
}

func isLocalPackageArtifactRequirement(requirement string) bool {
_, ok, err := requirements.ParseLocalArtifactRequirement(requirement)
return err != nil || ok
}

// pythonPackageForArch takes a package==version line and
// returns a package==version and index URL resolved to the correct GPU package for the given OS and architecture
func (c *Config) pythonPackageForArch(pkg, goos, goarch string) (actualPackage string, findLinksList []string, extraIndexURLs []string, err error) {
if isLocalPackageArtifactRequirement(pkg) {
return pkg, []string{}, []string{}, nil
}
name, version, findLinksList, extraIndexURLs, err := requirements.SplitPinnedPythonRequirement(pkg)
if err != nil {
// It's not pinned, so just return the line verbatim
Expand Down Expand Up @@ -546,6 +659,13 @@ func (c *Config) RequirementsFile(projectDir string) string {
return filepath.Join(projectDir, c.Build.PythonRequirements)
}

func (c *Config) LocalPackageArtifacts() []LocalPackageArtifact {
if c.Build == nil {
return nil
}
return slices.Clone(c.Build.localPackageArtifacts)
}

func (c *Config) ParsedEnvironment() map[string]string {
return c.parsedEnvironment
}
Expand Down
Loading
Loading