Skip to content

Commit

Permalink
Vendor Python packages (take 2)
Browse files Browse the repository at this point in the history
This PR vendors all Python packages using the [vendoring](https://pypi.org/project/vendoring/) library.

Vendoring allows users to install packages that rely on versions of libraries that are in conflict with Cog's dependencies (e.g. Pydantic, see replicate#1562, replicate#1384, replicate#1186, replicate#1586, replicate#1336, replicate#785).

Vendored packages are gitignored and are sync'd by the default `make` rule.

Closes replicate#409

Signed-off-by: andreasjansson <[email protected]>
  • Loading branch information
andreasjansson committed Apr 16, 2024
1 parent 72398c7 commit f6deb3a
Show file tree
Hide file tree
Showing 36 changed files with 425 additions and 81 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ pkg/dockerfile/embed/cog.whl
.DS_Store
docs/README.md
docs/CONTRIBUTING.md
python/cog/_vendor/
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,23 @@ To publish a prerelease version, append a [SemVer prerelease identifer](https://
git tag -a v0.1.0-alpha -m "Prerelease v0.1.0"
git push --tags

## Vendoring Python packages

We vendor the few Python libraries we depend on to avoid dependency hell. We use [vendoring](https://pypi.org/project/vendoring/), the same tool used by pip.

The vendored packages are defined in `python/cog/_vendor/vendor.txt`. If you add/change anything in there, the default `make` rule will sync all vendored packages.

If you run into issues you may need to manually add a patch. Patches are stored in `python/tools/vendoring/patches`.

To create a new patch, copy the file you want to patch to `$FILENAME.new`, and edit that file as needed. For example:

cp python/cog/_vendor/curio/__main__.py python/cog/_vendor/curio/__main__.py.new

Then `diff` the new and old version to a patch file in `python/tools/vendoring/patches/$PACKAGE.patch` and re-run vendoring. For example:

diff -u ./python/cog/_vendor/curio/__main__.py ./python/cog/_vendor/curio/__main__.py.new >> python/tools/vendoring/patches/curio.patch
make

## Troubleshooting

### `cog command not found`
Expand Down
19 changes: 12 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@ PYTHON ?= python
PYTEST := $(PYTHON) -m pytest
PYRIGHT := $(PYTHON) -m pyright
RUFF := $(PYTHON) -m ruff
PIP := $(PYTHON) -m pip
VENDORING := $(PYTHON) -m vendoring

default: all

.PHONY: all
all: cog

pkg/dockerfile/embed/cog.whl: python/* python/cog/* python/cog/server/* python/cog/command/*
python/cog/_vendor: python/cog/vendor.txt
$(VENDORING) sync

pkg/dockerfile/embed/cog.whl: python/* python/cog/* python/cog/server/* python/cog/command/* python/cog/_vendor
@echo "Building Python library"
rm -rf dist
$(PYTHON) -m pip install build && $(PYTHON) -m build --wheel
$(PIP) install build && $(PYTHON) -m build --wheel
mkdir -p pkg/dockerfile/embed
cp dist/*.whl $@

Expand Down Expand Up @@ -68,7 +73,7 @@ test-integration: cog
cd test-integration/ && $(MAKE) PATH="$(PWD):$(PATH)" test

.PHONY: test-python
test-python:
test-python: python/cog/_vendor
$(PYTEST) -n auto -vv --cov=python/cog --cov-report term-missing python/tests $(if $(FILTER),-k "$(FILTER)",)

.PHONY: test
Expand Down Expand Up @@ -99,7 +104,7 @@ lint-go:
$(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint run ./...

.PHONY: lint-python
lint-python:
lint-python: python/cog/_vendor
$(RUFF) python/cog
$(RUFF) format --check python
@$(PYTHON) -c 'import sys; sys.exit("Warning: python >=3.10 is needed (not installed) to pass linting (pyright)") if sys.version_info < (3, 10) else None'
Expand All @@ -114,12 +119,12 @@ mod-tidy:

.PHONY: install-python # install dev dependencies
install-python:
$(PYTHON) -m pip install '.[dev]'

$(PIP) install '.[dev]'
$(PIP) install --no-deps --ignore-requires-python vendoring==1.2.0

.PHONY: run-docs-server
run-docs-server:
pip install mkdocs-material
$(PIP) install mkdocs-material
sed 's/docs\///g' README.md > ./docs/README.md
cp CONTRIBUTING.md ./docs/
mkdocs serve
60 changes: 49 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,12 @@ urls."Source" = "https://github.com/replicate/cog"

requires-python = ">=3.7"
dependencies = [
# intentionally loose. perhaps these should be vendored to not collide with user code?
"attrs>=20.1,<24",
"fastapi>=0.75.2,<0.99.0",
"pydantic>=1.9,<2",
"PyYAML",
"requests>=2,<3",
"structlog>=20,<25",
'typing-compat; python_version < "3.8"',
"typing_extensions>=4.4.0",
"uvicorn[standard]>=0.12,<1",
]

optional-dependencies = { "dev" = [
"black",
"build",
"httpx",
'hypothesis<6.80.0; python_version < "3.8"',
'hypothesis; python_version >= "3.8"',
'numpy<1.22.0; python_version < "3.8"',
Expand All @@ -41,6 +31,17 @@ optional-dependencies = { "dev" = [
"pytest-cov",
"responses",
"ruff",

# vendoring's dependencies are listed below. vendoring is pinned to
# python 3.8 and you can't specify --no-deps or
# --ignore-requires-python in pyproject.toml, so unfortunately we
# have to install vendoring in the Makefile.
"click",
"jsonschema",
"packaging",
"requests",
"rich",
"toml",
] }

dynamic = ["version"]
Expand All @@ -54,7 +55,7 @@ strictParameterNoneValue = false
# legacy behavior, fixed in PEP688
disableBytesTypePromotions = true
include = ["python"]
exclude = ["python/tests"]
exclude = ["python/tests", "python/cog/_vendor", "python/build"]
reportMissingParameterType = "error"
reportUnknownLambdaType = "error"
reportUnnecessaryIsInstance = "warning"
Expand Down Expand Up @@ -92,6 +93,7 @@ ignore = [
extend-exclude = [
"python/tests/server/fixtures/*",
"test-integration/test_integration/fixtures/*",
"python/cog/_vendor/*",
]

[tool.ruff.per-file-ignores]
Expand All @@ -111,3 +113,39 @@ extend-exclude = [
"S607", # Starting a process with a partial executable path"
"ANN",
]

[tool.black]
exclude = '(\.eggs|\.git|\.hg|\.mypy|_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist|_vendor)'

[tool.vendoring]
destination = "python/cog/_vendor/"
requirements = "python/cog/vendor.txt"
namespace = "cog._vendor"

protected-files = ["__init__.py", "README.rst", "vendor.txt"]
patches-dir = "python/tools/vendoring/patches"

[tool.vendoring.transformations]
substitute = []
drop = [
# we won't run any dependencies as scripts
"bin/",

# pyyaml falls back to plain python
"_yaml.*.so",

# sniffio ships with tests
"sniffio/_tests",

# h11 ships with tests
"h11/tests",

# pydantic hypothesis plugin, fall back to python
"pydantic/_hypothesis_plugin.*.so",
]

[tool.vendoring.typing-stubs]

[tool.vendoring.license.directories]

[tool.vendoring.license.fallback-urls]
3 changes: 1 addition & 2 deletions python/cog/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from pydantic import BaseModel

from ._vendor.pydantic import BaseModel
from .predictor import BasePredictor
from .types import ConcatenateIterator, File, Input, Path, Secret

Expand Down
2 changes: 1 addition & 1 deletion python/cog/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
from urllib.parse import urlparse

import requests
from ._vendor import requests


def upload_file(fh: io.IOBase, output_file_prefix: str = None) -> str:
Expand Down
3 changes: 1 addition & 2 deletions python/cog/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from types import GeneratorType
from typing import Any, Callable

from pydantic import BaseModel

from ._vendor.pydantic import BaseModel
from .types import Path


Expand Down
4 changes: 2 additions & 2 deletions python/cog/logging.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import logging
import os

import structlog
from structlog.typing import EventDict
from ._vendor import structlog
from ._vendor.structlog.typing import EventDict


def replace_level_with_severity(
Expand Down
15 changes: 7 additions & 8 deletions python/cog/predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,21 @@
)
from unittest.mock import patch

import structlog

import cog.code_xforms as code_xforms

from ._vendor import structlog
from ._vendor.typing_extensions import Annotated

try:
from typing import get_args, get_origin
except ImportError: # Python < 3.8
from typing_compat import get_args, get_origin # type: ignore

import yaml
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
from ._vendor import yaml
from ._vendor.pydantic import BaseModel, Field, create_model
from ._vendor.pydantic.fields import FieldInfo

# Added in Python 3.9. Can be from typing if we drop support for <3.9
from typing_extensions import Annotated

from .errors import ConfigDoesNotExist, PredictorNotSet
from .types import (
File as CogFile,
Expand Down Expand Up @@ -158,7 +157,7 @@ def load_config() -> Dict[str, Any]:
config_path = os.path.abspath("cog.yaml")
try:
with open(config_path) as fh:
config = yaml.safe_load(fh)
config: Dict[str, Any] = yaml.safe_load(fh) # type: ignore
except FileNotFoundError as e:
raise ConfigDoesNotExist(
f"Could not find {config_path}",
Expand Down
2 changes: 1 addition & 1 deletion python/cog/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from enum import Enum
from types import ModuleType

import pydantic
from ._vendor import pydantic

BUNDLED_SCHEMA_PATH = ".cog/schema.py"

Expand Down
2 changes: 1 addition & 1 deletion python/cog/server/eventtypes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any, Dict

from attrs import define, field, validators
from .._vendor.attrs import define, field, validators


# From worker parent process
Expand Down
21 changes: 9 additions & 12 deletions python/cog/server/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,14 @@
if TYPE_CHECKING:
from typing import ParamSpec

import attrs
import structlog
import uvicorn
from fastapi import Body, FastAPI, Header, HTTPException, Path, Response
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from pydantic.error_wrappers import ErrorWrapper

from .. import schema
from .._vendor import attrs, structlog, uvicorn
from .._vendor.fastapi import Body, FastAPI, Header, HTTPException, Path, Response
from .._vendor.fastapi.encoders import jsonable_encoder
from .._vendor.fastapi.exceptions import RequestValidationError
from .._vendor.fastapi.responses import JSONResponse
from .._vendor.pydantic import ValidationError
from .._vendor.pydantic.error_wrappers import ErrorWrapper
from ..errors import PredictorNotSet
from ..files import upload_file
from ..json import upload_files
Expand Down Expand Up @@ -296,7 +293,7 @@ async def predict_idempotent(
"prediction ID must match the ID supplied in the URL"
),
("body", "id"),
)
) # type: ignore
]
)

Expand All @@ -310,7 +307,7 @@ async def predict_idempotent(
return _predict(request=request, respond_async=respond_async)

def _predict(
*, request: PredictionRequest, respond_async: bool = False
*, request: Optional[PredictionRequest], respond_async: bool = False
) -> Response:
# [compat] If no body is supplied, assume that this model can be run
# with empty input. This will throw a ValidationError if that's not
Expand Down
14 changes: 6 additions & 8 deletions python/cog/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@
from multiprocessing.pool import AsyncResult, ThreadPool
from typing import Any, Callable, Optional, Tuple, Union, cast

import requests
import structlog
from attrs import define
from fastapi.encoders import jsonable_encoder
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry # type: ignore

from .. import schema, types
from .._vendor import requests, structlog
from .._vendor.attrs import define
from .._vendor.fastapi.encoders import jsonable_encoder
from .._vendor.requests.adapters import HTTPAdapter
from .._vendor.urllib3.util.retry import Retry # type: ignore
from ..files import put_file_to_signed_endpoint
from ..json import upload_files
from .eventtypes import Done, Heartbeat, Log, PredictionOutput, PredictionOutputType
Expand Down Expand Up @@ -454,7 +452,7 @@ def _make_file_upload_http_client() -> requests.Session:
backoff_factor=0.1,
status_forcelist=[408, 429, 500, 502, 503, 504],
allowed_methods=["PUT"],
),
), # type: ignore
)
session.mount("http://", adapter)
session.mount("https://", adapter)
Expand Down
10 changes: 4 additions & 6 deletions python/cog/server/webhook.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import os
from typing import Any, Callable, Set

import requests
import structlog
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry # type: ignore

from .._vendor import requests, structlog
from .._vendor.requests.adapters import HTTPAdapter
from .._vendor.urllib3.util.retry import Retry # type: ignore
from ..schema import Status, WebhookEvent
from .response_throttler import ResponseThrottler

Expand Down Expand Up @@ -96,7 +94,7 @@ def requests_session_with_retries() -> requests.Session:
backoff_factor=0.1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["POST"],
)
) # type: ignore
)
session.mount("http://", adapter)
session.mount("https://", adapter)
Expand Down
4 changes: 2 additions & 2 deletions python/cog/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import urllib.request
from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union

import requests
from pydantic import Field, SecretStr
from ._vendor import requests
from ._vendor.pydantic import Field, SecretStr

FILENAME_ILLEGAL_CHARS = set("\u0000/")

Expand Down
Loading

0 comments on commit f6deb3a

Please sign in to comment.