Skip to content

Commit

Permalink
Require min python 3.9 (#1409)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Feil <[email protected]>
  • Loading branch information
marius-baseten and michaelfeil authored Feb 21, 2025
1 parent 0970b7d commit 9cff3a7
Show file tree
Hide file tree
Showing 11 changed files with 929 additions and 1,012 deletions.
11 changes: 1 addition & 10 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,5 @@ repos:
entry: poetry run mypy
language: python
types: [python]
exclude: ^examples/|^truss/test.+/|model.py$|^truss-chains/.*|^smoketests/.*
pass_filenames: true
- id: mypy
name: mypy-local (3.9)
entry: poetry run mypy
language: python
types: [python]
files: ^truss-chains/.*|^smoketests/.*
args:
- --python-version=3.9
exclude: ^examples/|^truss/test.+/|model.py$
pass_filenames: true
1,769 changes: 836 additions & 933 deletions poetry.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ truss-docker-build-setup = "truss.contexts.docker_build_setup:docker_build_setup
[tool.poetry.dependencies]
# "base" dependencies.
# "When using chains, 3.9 will be required at runtime, but other truss functionality works with 3.8.
python = ">=3.8,<3.13"
python = ">=3.9,<3.13"
huggingface_hub = ">=0.25.0"
pydantic = ">=1.10.0" # We cannot upgrade to v2, due to customer constraints.
PyYAML = ">=6.0"
Expand Down Expand Up @@ -178,7 +178,7 @@ requires = ["poetry-core>=1.2.1"]

[tool.mypy]
ignore_missing_imports = true
python_version = "3.8"
python_version = "3.9"
plugins = ["pydantic.mypy"]

[tool.pytest.ini_options]
Expand All @@ -189,8 +189,8 @@ markers = [
addopts = "--ignore=smoketests"

[tool.ruff]
src = ["truss", "truss-chains", "truss-utils"]
target-version = "py38"
src = ["truss", "truss-chains"]
target-version = "py39"
line-length = 88
lint.extend-select = [
"I", # isort
Expand Down
8 changes: 0 additions & 8 deletions truss-chains/truss_chains/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
import sys

if (sys.version_info.major, sys.version_info.minor) <= (3, 8):
raise RuntimeError(
"Python version 3.8 or older is not supported for `Truss-Chains`. Please"
"upgrade to Python 3.9 or newer. You can still use other Truss functionality."
)
del sys
import pydantic

pydantic_major_version = int(pydantic.VERSION.split(".")[0])
Expand Down
10 changes: 6 additions & 4 deletions truss-chains/truss_chains/deployment/deployment_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,9 @@ def __init__(

def _patch(self) -> None:
exception_raised = None
with log_utils.LogInterceptor() as log_interceptor, self._console.status(
" Live Patching Model.\n", spinner="arrow3"
with (
log_utils.LogInterceptor() as log_interceptor,
self._console.status(" Live Patching Model.\n", spinner="arrow3"),
):
try:
gen_truss_path = code_gen.gen_truss_model_from_source(self._source)
Expand Down Expand Up @@ -717,8 +718,9 @@ def _code_gen_and_patch_thread(
def _patch(self, executor: concurrent.futures.Executor) -> None:
exception_raised = None
stack_trace = ""
with log_utils.LogInterceptor() as log_interceptor, self._console.status(
" Live Patching Chain.\n", spinner="arrow3"
with (
log_utils.LogInterceptor() as log_interceptor,
self._console.status(" Live Patching Chain.\n", spinner="arrow3"),
):
# Handle import errors gracefully (e.g. if user saved file, but there
# are syntax errors, undefined symbols etc.).
Expand Down
5 changes: 2 additions & 3 deletions truss/base/truss_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,16 +871,15 @@ def obj_to_dict(obj, verbose: bool = False):
return d


# TODO(marius): consolidate this with config/validation:
def _infer_python_version() -> str:
return f"py{sys.version_info.major}{sys.version_info.minor}"


def map_local_to_supported_python_version() -> str:
return map_to_supported_python_version(_infer_python_version())
return _map_to_supported_python_version(_infer_python_version())


def map_to_supported_python_version(python_version: str) -> str:
def _map_to_supported_python_version(python_version: str) -> str:
"""Map python version to truss supported python version.
Currently, it maps any versions greater than 3.11 to 3.11.
Expand Down
7 changes: 4 additions & 3 deletions truss/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,10 @@ def push_chain(
num_failed = 0
# Logging inferences with live display (even when using richHandler)
# -> capture logs and print later.
with LogInterceptor() as log_interceptor, rich.live.Live(
table, console=console, refresh_per_second=4
) as live:
with (
LogInterceptor() as log_interceptor,
rich.live.Live(table, console=console, refresh_per_second=4) as live,
):
while True:
table, statuses = _create_chains_table(service)
live.update(table)
Expand Down
28 changes: 16 additions & 12 deletions truss/templates/server/model_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,9 +724,10 @@ async def _execute_model_endpoint(
"""
fn_span = self._tracer.start_span(f"call-{descriptor.method_name}")
# TODO(nikhil): Make it easier to start a section with detached context.
with tracing.section_as_event(
fn_span, descriptor.method_name
), tracing.detach_context() as detached_ctx:
with (
tracing.section_as_event(fn_span, descriptor.method_name),
tracing.detach_context() as detached_ctx,
):
result = await self._execute_user_model_fn(inputs, request, descriptor)

if inspect.isgenerator(result) or inspect.isasyncgen(result):
Expand Down Expand Up @@ -811,9 +812,10 @@ async def predict(
if self.model_descriptor.preprocess:
with self._tracer.start_as_current_span("call-pre") as span_pre:
# TODO(nikhil): Make it easier to start a section with detached context.
with tracing.section_as_event(
span_pre, "preprocess"
), tracing.detach_context():
with (
tracing.section_as_event(span_pre, "preprocess"),
tracing.detach_context(),
):
preprocess_result = await self.preprocess(inputs, request)
else:
preprocess_result = inputs
Expand All @@ -823,9 +825,10 @@ async def predict(
self._predict_semaphore, span_predict
) as get_defer_fn:
# TODO(nikhil): Make it easier to start a section with detached context.
with tracing.section_as_event(
span_predict, "predict"
), tracing.detach_context() as detached_ctx:
with (
tracing.section_as_event(span_predict, "predict"),
tracing.detach_context() as detached_ctx,
):
# To prevent span pollution, we need to make sure spans created by user
# code don't inherit context from our spans (which happens even if
# different tracer instances are used).
Expand Down Expand Up @@ -884,9 +887,10 @@ async def predict(
if self.model_descriptor.postprocess:
with self._tracer.start_as_current_span("call-post") as span_post:
# TODO(nikhil): Make it easier to start a section with detached context.
with tracing.section_as_event(
span_post, "postprocess"
), tracing.detach_context():
with (
tracing.section_as_event(span_post, "postprocess"),
tracing.detach_context(),
):
postprocess_result = await self.postprocess(predict_result, request)
return postprocess_result
else:
Expand Down
6 changes: 4 additions & 2 deletions truss/tests/templates/server/test_model_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,10 @@ async def mock_predict(return_value, request: Request):
@pytest.mark.anyio
async def test_open_ai_completion_endpoints(open_ai_container_fs, helpers):
app_path = open_ai_container_fs / "app"
with _clear_model_load_modules(), helpers.sys_paths(app_path), _change_directory(
app_path
with (
_clear_model_load_modules(),
helpers.sys_paths(app_path),
_change_directory(app_path),
):
model_wrapper_module = importlib.import_module("model_wrapper")
model_wrapper_class = getattr(model_wrapper_module, "ModelWrapper")
Expand Down
36 changes: 36 additions & 0 deletions truss/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ModelRepo,
Resources,
TrussConfig,
_map_to_supported_python_version,
)
from truss.truss_handle.truss_handle import TrussHandle

Expand Down Expand Up @@ -570,3 +571,38 @@ def test_validate_quant_format_and_accelerator_for_trt_llm_builder(
config.resources.accelerator.accelerator = accelerator
with expectation:
TrussConfig.from_dict(config.to_dict())


@pytest.mark.parametrize(
"python_version, expected_python_version",
[
("py38", "py38"),
("py39", "py39"),
("py310", "py310"),
("py311", "py311"),
("py312", "py311"),
],
)
def test_map_to_supported_python_version(python_version, expected_python_version):
out_python_version = _map_to_supported_python_version(python_version)
assert out_python_version == expected_python_version


def test_not_supported_python_minor_versions():
with pytest.raises(
ValueError,
match="Mapping python version 3.6 to 3.8, "
"the lowest version that Truss currently supports.",
):
_map_to_supported_python_version("py36")
with pytest.raises(
ValueError,
match="Mapping python version 3.7 to 3.8, "
"the lowest version that Truss currently supports.",
):
_map_to_supported_python_version("py37")


def test_not_supported_python_major_versions():
with pytest.raises(NotImplementedError, match="Only python version 3 is supported"):
_map_to_supported_python_version("py211")
53 changes: 20 additions & 33 deletions truss/tests/test_model_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from requests.exceptions import RequestException
from websockets.exceptions import ConnectionClosed

from truss.base.truss_config import map_to_supported_python_version
from truss.local.local_config_handler import LocalConfigHandler
from truss.tests.helpers import create_truss
from truss.tests.test_testing_utilities_for_other_tests import ensure_kill_all
Expand Down Expand Up @@ -105,39 +104,26 @@ def _temp_truss(model_src: str, config_src: str = "") -> Iterator[TrussHandle]:
# Test Cases ###########################################################################


@pytest.mark.integration
@pytest.mark.parametrize(
"python_version, expected_python_version",
[
("py38", "py38"),
("py39", "py39"),
("py310", "py310"),
("py311", "py311"),
("py312", "py311"),
],
"config_python_version, inspected_python_version",
[("py38", "3.8"), ("py39", "3.9"), ("py310", "3.10"), ("py311", "3.11")],
)
def test_map_to_supported_python_version(python_version, expected_python_version):
out_python_version = map_to_supported_python_version(python_version)
assert out_python_version == expected_python_version


def test_not_supported_python_minor_versions():
with pytest.raises(
ValueError,
match="Mapping python version 3.6 to 3.8, "
"the lowest version that Truss currently supports.",
):
map_to_supported_python_version("py36")
with pytest.raises(
ValueError,
match="Mapping python version 3.7 to 3.8, "
"the lowest version that Truss currently supports.",
):
map_to_supported_python_version("py37")
def test_predict_python_versions(config_python_version, inspected_python_version):
model = """
import sys
class Model:
def predict(self, data):
version = sys.version_info
return f"{version.major}.{version.minor}"
"""

config = f"python_version: {config_python_version}"

def test_not_supported_python_major_versions():
with pytest.raises(NotImplementedError, match="Only python version 3 is supported"):
map_to_supported_python_version("py211")
with ensure_kill_all(), _temp_truss(model, config) as tr:
_ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)

Check failure on line 124 in truss/tests/test_model_inference.py

View workflow job for this annotation

GitHub Actions / JUnit Test Report

test_model_inference.test_predict_python_versions[py38-3.8]

requests.exceptions.ConnectionError: HTTPConnectionPool(host='localhost', port=8090): Max retries exceeded with url: /v1/models/model (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x7f2e4159eb80>: Failed to establish a new connection: [Errno 111] Connection refused'))
Raw output
url = 'http://localhost:8090/v1/models/model'
container = python_on_whales.Container(id='5180a46bc1bb', name='exciting_hypatia')
wait_for_server_ready = True, model_server_stop_retry_override = None

    def wait_for_truss(
        url: str,
        container: str,
        wait_for_server_ready: bool = True,
        model_server_stop_retry_override=None,
    ) -> None:
        from python_on_whales.exceptions import NoSuchContainer
    
        try:
            _wait_for_docker_build(container)
            if wait_for_server_ready:
                if model_server_stop_retry_override is not None:
                    _wait_for_model_server(url, stop=model_server_stop_retry_override)
                else:
>                   _wait_for_model_server(url)

truss/truss_handle/truss_handle.py:1093: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

url = 'http://localhost:8090/v1/models/model'
stop = <tenacity.stop.stop_after_delay object at 0x7f2e46160820>

    def _wait_for_model_server(url: str, stop=stop_after_delay(120)) -> Response:  # type: ignore[return]
>       for attempt in Retrying(
            stop=stop,
            wait=wait_fixed(2),
            retry=(
                retry_if_result(lambda response: response.status_code in [502, 503])
                | retry_if_exception_type(exceptions.ConnectionError)
            ),
        ):

truss/truss_handle/truss_handle.py:1066: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Retrying object at 0x7f2e4159eb20 (stop=<tenacity.stop.stop_after_delay object at 0x7f2e46160820>, wait=<tenacity.wai...0x7f2e44323fd0>, before=<function before_nothing at 0x7f2e47c824c0>, after=<function after_nothing at 0x7f2e47c82700>)>

    def __iter__(self) -> t.Generator[AttemptManager, None, None]:
        self.begin()
    
        retry_state = RetryCallState(self, fn=None, args=(), kwargs={})
        while True:
>           do = self.iter(retry_state=retry_state)

.venv/lib/python3.9/site-packages/tenacity/__init__.py:443: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Retrying object at 0x7f2e4159eb20 (stop=<tenacity.stop.stop_after_delay object at 0x7f2e46160820>, wait=<tenacity.wai...0x7f2e44323fd0>, before=<function before_nothing at 0x7f2e47c824c0>, after=<function after_nothing at 0x7f2e47c82700>)>
retry_state = <RetryCallState 139836641636160: attempt #61; slept for 120.0; last result: failed (ConnectionError HTTPConnectionPool...ion.HTTPConnection object at 0x7f2e4159eb80>: Failed to establish a new connection: [Errno 111] Connection refused')))>

    def iter(self, retry_state: "RetryCallState") -> t.Union[DoAttempt, DoSleep, t.Any]:  # noqa
        self._begin_iter(retry_state)
        result = None
        for action in self.iter_state.actions:
>           result = action(retry_state)

.venv/lib/python3.9/site-packages/tenacity/__init__.py:376: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

rs = <RetryCallState 139836641636160: attempt #61; slept for 120.0; last result: failed (ConnectionError HTTPConnectionPool...ion.HTTPConnection object at 0x7f2e4159eb80>: Failed to establish a new connection: [Errno 111] Connection refused')))>

    def exc_check(rs: "RetryCallState") -> None:
        fut = t.cast(Future, rs.outcome)
        retry_exc = self.retry_error_cls(fut)
        if self.reraise:
            raise retry_exc.reraise()
>       raise retry_exc from fut.exception()
E       tenacity.RetryError: RetryError[<Future at 0x7f2e4159ec40 state=finished raised ConnectionError>]

.venv/lib/python3.9/site-packages/tenacity/__init__.py:419: RetryError

During handling of the above exception, another exception occurred:

config_python_version = 'py38', inspected_python_version = '3.8'

    @pytest.mark.integration
    @pytest.mark.parametrize(
        "config_python_version, inspected_python_version",
        [("py38", "3.8"), ("py39", "3.9"), ("py310", "3.10"), ("py311", "3.11")],
    )
    def test_predict_python_versions(config_python_version, inspected_python_version):
        model = """
        import sys
        class Model:
            def predict(self, data):
                version = sys.version_info
                return f"{version.major}.{version.minor}"
        """
    
        config = f"python_version: {config_python_version}"
    
        with ensure_kill_all(), _temp_truss(model, config) as tr:
>           _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)

truss/tests/test_model_inference.py:124: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
truss/truss_handle/decorators.py:7: in wrapper
    return func(*args, **kwargs)
truss/truss_handle/truss_handle.py:349: in docker_run
    raise err
truss/truss_handle/truss_handle.py:339: in docker_run
    wait_for_truss(
truss/truss_handle/truss_handle.py:1097: in wait_for_truss
    retry_err.reraise()
.venv/lib/python3.9/site-packages/tenacity/__init__.py:185: in reraise
    raise self.last_attempt.result()
/opt/hostedtoolcache/Python/3.9.21/x64/lib/python3.9/concurrent/futures/_base.py:439: in result
    return self.__get_result()
/opt/hostedtoolcache/Python/3.9.21/x64/lib/python3.9/concurrent/futures/_base.py:391: in __get_result
    raise self._exception
truss/truss_handle/truss_handle.py:1075: in _wait_for_model_server
    response = requests.get(url)
.venv/lib/python3.9/site-packages/requests/api.py:73: in get
    return request("get", url, params=params, **kwargs)
.venv/lib/python3.9/site-packages/requests/api.py:59: in request
    return session.request(method=method, url=url, **kwargs)
.venv/lib/python3.9/site-packages/requests/sessions.py:589: in request
    resp = self.send(prep, **send_kwargs)
.venv/lib/python3.9/site-packages/requests/sessions.py:703: in send
    r = adapter.send(request, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <requests.adapters.HTTPAdapter object at 0x7f2e4159ec10>
request = <PreparedRequest [GET]>, stream = False
timeout = Timeout(connect=None, read=None, total=None), verify = True
cert = None, proxies = OrderedDict()

    def send(
        self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None
    ):
        """Sends PreparedRequest object. Returns Response object.
    
        :param request: The :class:`PreparedRequest <PreparedRequest>` being sent.
        :param stream: (optional) Whether to stream the request content.
        :param timeout: (optional) How long to wait for the server to send
            data before giving up, as a float, or a :ref:`(connect timeout,
            read timeout) <timeouts>` tuple.
        :type timeout: float or tuple or urllib3 Timeout object
        :param verify: (optional) Either a boolean, in which case it controls whether
            we verify the server's TLS certificate, or a string, in which case it
            must be a path to a CA bundle to use
        :param cert: (optional) Any user-provided SSL certificate to be trusted.
        :param proxies: (optional) The proxies dictionary to apply to the request.
        :rtype: requests.Response
        """
    
        try:
            conn = self.get_connection_with_tls_context(
                request, verify, proxies=proxies, cert=cert
            )
        except LocationValueError as e:
            raise InvalidURL(e, request=request)
    
        self.cert_verify(conn, request.url, verify, cert)
        url = self.request_url(request, proxies)
        self.add_headers(
            request,
            stream=stream,
            timeout=timeout,
            verify=verify,
            cert=cert,
            proxies=proxies,
        )
    
        chunked = not (request.body is None or "Content-Length" in request.headers)
    
        if isinstance(timeout, tuple):
            try:
                connect, read = timeout
                timeout = TimeoutSauce(connect=connect, read=read)
            except ValueError:
                raise ValueError(
                    f"Invalid timeout {timeout}. Pass a (connect, read) timeout tuple, "
                    f"or a single float to set both timeouts to the same value."
                )
        elif isinstance(timeout, TimeoutSauce):
            pass
        else:
            timeout = TimeoutSauce(connect=timeout, read=timeout)
    
        try:
            resp = conn.urlopen(
                method=request.method,
                url=url,
                body=request.body,
                headers=request.headers,
                redirect=False,
                assert_same_host=False,
                preload_content=False,
                decode_content=False,
                retries=self.max_retries,
                timeout=timeout,
                chunked=chunked,
            )
    
        except (ProtocolError, OSError) as err:
            raise ConnectionError(err, request=request)
    
        except MaxRetryError as e:
            if isinstance(e.reason, ConnectTimeoutError):
                # TODO: Remove this in 3.0.0: see #2811
                if not isinstance(e.reason, NewConnectionError):
                    raise ConnectTimeout(e, request=request)
    
            if isinstance(e.reason, ResponseError):
                raise RetryError(e, request=request)
    
            if isinstance(e.reason, _ProxyError):
                raise ProxyError(e, request=request)
    
            if isinstance(e.reason, _SSLError):
                # This branch is for urllib3 v1.22 and later.
                raise SSLError(e, request=request)
    
>           raise ConnectionError(e, request=request)
E           requests.exceptions.ConnectionError: HTTPConnectionPool(host='localhost', port=8090): Max retries exceeded with url: /v1/models/model (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x7f2e4159eb80>: Failed to establish a new connection: [Errno 111] Connection refused'))

.venv/lib/python3.9/site-packages/requests/adapters.py:700: ConnectionError
response = requests.post(PREDICT_URL, json={})
assert inspected_python_version == response.json()


@pytest.mark.integration
Expand Down Expand Up @@ -418,9 +404,10 @@ def predict(self, request):
assert response.json() == "secret_value"

# Case where the secret is not specified in the config
with ensure_kill_all(), _temp_truss(
inspect.getsource(Model), config_with_no_secret
) as tr:
with (
ensure_kill_all(),
_temp_truss(inspect.getsource(Model), config_with_no_secret) as tr,
):
LocalConfigHandler.set_secret("secret", "secret_value")
container = tr.docker_run(
local_port=8090, detach=True, wait_for_server_ready=True
Expand Down

0 comments on commit 9cff3a7

Please sign in to comment.