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.
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. User-provided local wheel and source archive requirements use the same staging boundary: Cog copies only the referenced artifacts into `.cog/build/` before the requirements install, preserving the later `COPY . /src` 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 also reference local Python package artifacts, such as wheels and source archives:

`requirements.txt`:

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

Local artifact paths are resolved relative to the requirements file, and the referenced files must be inside your project directory. Cog stages these artifacts before installing requirements, so this is the supported way to install a local package artifact during `cog build`.

Local package directories, `name @ file:...` requirements, local `--find-links` directories, recursive local artifact includes, and inline hashes or options on local artifact lines are not supported.

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 code is _not_ available to commands in `run`. This is so we can build your image efficiently when running locally. To install a local wheel or source archive, list it in your `python_requirements` file instead of running `pip install ./artifact.zip` from `run`.

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

Expand Down
34 changes: 34 additions & 0 deletions integration-tests/tests/local_python_requirement_artifact.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Local package artifacts listed in requirements.txt are available during build.

mkdir dist
exec python3 -c 'import zipfile; z = zipfile.ZipFile("dist/local_pkg-0.1.0.zip", "w"); z.write("setup.py"); z.write("local_pkg/__init__.py"); z.close()'

cog build -t $TEST_IMAGE
cog predict $TEST_IMAGE
stdout 'hello from local package'

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

-- requirements.txt --
./dist/local_pkg-0.1.0.zip

-- setup.py --
from setuptools import setup

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

-- local_pkg/__init__.py --
MESSAGE = "hello from local package"

-- predict.py --
from cog import BasePredictor
from local_pkg import MESSAGE


class Predictor(BasePredictor):
def predict(self) -> str:
return MESSAGE
95 changes: 95 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package config

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
Expand Down Expand Up @@ -60,6 +63,14 @@ type Build struct {
SDKVersion string `json:"sdk_version,omitempty" yaml:"sdk_version,omitempty"`

pythonRequirementsContent []string
localPackageArtifacts []LocalPackageArtifact
}

type LocalPackageArtifact struct {
Requirement string
SourcePath string
StagedDir string
Filename string
}

type Concurrency struct {
Expand Down Expand Up @@ -277,6 +288,9 @@ func (c *Config) Complete(projectDir string) error {
return fmt.Errorf("failed to open python_requirements file: %w", err)
}
c.Build.pythonRequirementsContent = reqs
if err := c.loadLocalPackageArtifacts(projectDir, requirementsFilePath); err != nil {
return err
}
} else if len(c.Build.PythonPackages) > 0 {
// Backwards compatibility: if using deprecated python_packages, populate requirements content
c.Build.pythonRequirementsContent = c.Build.PythonPackages
Expand All @@ -297,6 +311,80 @@ func (c *Config) Complete(projectDir string) error {
return nil
}

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)
seen := map[string]LocalPackageArtifact{}
artifacts := []LocalPackageArtifact{}
for _, line := range c.Build.pythonRequirementsContent {
artifactPath, ok, err := requirements.ParseLocalArtifactRequirement(line)
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)
}
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)
}

if artifact, ok := seen[canonicalPath]; ok {
artifact.Requirement = line
artifacts = append(artifacts, artifact)
continue
}

relPath, err := filepath.Rel(projectRoot, canonicalPath)
if err != nil {
return fmt.Errorf("failed to resolve local Python package artifact %q relative to project directory: %w", artifactPath, err)
}
hash := sha256.Sum256([]byte(filepath.ToSlash(relPath)))
artifact := LocalPackageArtifact{
Requirement: line,
SourcePath: canonicalPath,
StagedDir: hex.EncodeToString(hash[:])[:16],
Filename: filepath.Base(absPath),
}
seen[canonicalPath] = artifact
artifacts = append(artifacts, artifact)
}
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 @@ -546,6 +634,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
146 changes: 146 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,152 @@ flask>0.4

}

func TestPythonRequirementsLocalPackageArtifacts(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(path.Join(tmpDir, "requirements", "dist"), 0o755))
wheelPath := path.Join(tmpDir, "requirements", "dist", "local_pkg-0.1.0-py3-none-any.whl")
archivePath := path.Join(tmpDir, "requirements", "mylibpackage.zip")
require.NoError(t, os.WriteFile(wheelPath, []byte("wheel"), 0o644))
require.NoError(t, os.WriteFile(archivePath, []byte("zip"), 0o644))
require.NoError(t, os.WriteFile(path.Join(tmpDir, "requirements", "requirements.txt"), []byte(`torch==1.13.1
./dist/local_pkg-0.1.0-py3-none-any.whl
mylibpackage.zip`), 0o644))

config := &Config{
Build: &Build{
GPU: true,
PythonVersion: "3.10",
PythonRequirements: "requirements/requirements.txt",
},
}
require.NoError(t, config.Complete(tmpDir))

artifacts := config.LocalPackageArtifacts()
canonicalWheelPath, err := filepath.EvalSymlinks(wheelPath)
require.NoError(t, err)
canonicalArchivePath, err := filepath.EvalSymlinks(archivePath)
require.NoError(t, err)
require.Len(t, artifacts, 2)
require.Equal(t, "./dist/local_pkg-0.1.0-py3-none-any.whl", artifacts[0].Requirement)
require.Equal(t, canonicalWheelPath, artifacts[0].SourcePath)
require.Equal(t, "local_pkg-0.1.0-py3-none-any.whl", artifacts[0].Filename)
require.Len(t, artifacts[0].StagedDir, 16)
require.Equal(t, "mylibpackage.zip", artifacts[1].Requirement)
require.Equal(t, canonicalArchivePath, artifacts[1].SourcePath)

requirements, err := config.PythonRequirementsForArch("linux", "amd64", []string{})
require.NoError(t, err)
require.Equal(t, `--extra-index-url https://download.pytorch.org/whl/cu117/
torch==1.13.1
./dist/local_pkg-0.1.0-py3-none-any.whl
mylibpackage.zip`, requirements)
}

func TestPythonRequirementsLocalPackageArtifactDuplicateAliases(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(path.Join(tmpDir, "dist"), 0o755))
wheelPath := path.Join(tmpDir, "dist", "local_pkg-0.1.0-py3-none-any.whl")
require.NoError(t, os.WriteFile(wheelPath, []byte("wheel"), 0o644))
require.NoError(t, os.WriteFile(path.Join(tmpDir, "requirements.txt"), []byte("./dist/local_pkg-0.1.0-py3-none-any.whl\ndist/local_pkg-0.1.0-py3-none-any.whl"), 0o644))

config := &Config{Build: &Build{PythonVersion: "3.10", PythonRequirements: "requirements.txt"}}
require.NoError(t, config.Complete(tmpDir))

artifacts := config.LocalPackageArtifacts()
require.Len(t, artifacts, 2)
require.Equal(t, "./dist/local_pkg-0.1.0-py3-none-any.whl", artifacts[0].Requirement)
require.Equal(t, "dist/local_pkg-0.1.0-py3-none-any.whl", artifacts[1].Requirement)
require.Equal(t, artifacts[0].SourcePath, artifacts[1].SourcePath)
require.Equal(t, artifacts[0].StagedDir, artifacts[1].StagedDir)
require.Equal(t, artifacts[0].Filename, artifacts[1].Filename)
}

func TestPythonRequirementsLocalPackageArtifactUnsupportedLocalOptions(t *testing.T) {
for _, line := range []string{
"--find-links ./wheels",
"--find-links=./wheels",
"-r requirements-local.txt",
"--requirement requirements-local.txt",
} {
t.Run(line, func(t *testing.T) {
projectDir := t.TempDir()
require.NoError(t, os.WriteFile(path.Join(projectDir, "requirements.txt"), []byte(line), 0o644))
config := &Config{Build: &Build{PythonVersion: "3.10", PythonRequirements: "requirements.txt"}}
err := config.Complete(projectDir)
require.Error(t, err)
require.Contains(t, err.Error(), "local requirements option")
})
}
}

func TestPythonRequirementsLocalPackageArtifactValidation(t *testing.T) {
testCases := []struct {
name string
line string
setup func(t *testing.T, projectDir string) string
expectedErr string
}{
{
name: "MissingArtifact",
line: "./missing.zip",
expectedErr: "not found",
},
{
name: "DirectoryArtifact",
line: "./pkg.zip",
setup: func(t *testing.T, projectDir string) string {
require.NoError(t, os.Mkdir(path.Join(projectDir, "pkg.zip"), 0o755))
return ""
},
expectedErr: "regular file",
},
{
name: "OutsideProject",
setup: func(t *testing.T, projectDir string) string {
outsideDir := t.TempDir()
outsidePath := path.Join(outsideDir, "pkg.zip")
require.NoError(t, os.WriteFile(outsidePath, []byte("zip"), 0o644))
return outsidePath
},
expectedErr: "inside the project directory",
},
{
name: "SymlinkOutsideProject",
line: "./pkg.zip",
setup: func(t *testing.T, projectDir string) string {
outsideDir := t.TempDir()
outsidePath := path.Join(outsideDir, "pkg.zip")
require.NoError(t, os.WriteFile(outsidePath, []byte("zip"), 0o644))
require.NoError(t, os.Symlink(outsidePath, path.Join(projectDir, "pkg.zip")))
return ""
},
expectedErr: "inside the project directory",
},
{
name: "InlineHashUnsupported",
line: "./pkg.whl --hash=sha256:abc",
expectedErr: "inline options or hashes",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
projectDir := t.TempDir()
line := tc.line
if tc.setup != nil {
if setupLine := tc.setup(t, projectDir); setupLine != "" {
line = setupLine
}
}
require.NoError(t, os.WriteFile(path.Join(projectDir, "requirements.txt"), []byte(line), 0o644))
config := &Config{Build: &Build{PythonVersion: "3.10", PythonRequirements: "requirements.txt"}}
err := config.Complete(projectDir)
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErr)
})
}
}

func TestValidateAndCompleteCUDAForAllTF(t *testing.T) {
for _, compat := range TFCompatibilityMatrix {
config := &Config{
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/data/config_schema_v1.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"python_requirements": {
"$id": "#/properties/build/properties/python_requirements",
"type": "string",
"description": "A pip requirements file specifying the Python packages to install."
"description": "A pip requirements file specifying the Python packages to install. Local wheel and source archive paths in the requirements file are supported when they point to files inside the project."
},
"system_packages": {
"$id": "#/properties/build/properties/system_packages",
Expand Down
Loading
Loading