diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml index 973ab3a..7ad8502 100644 --- a/.forgejo/workflows/docs.yml +++ b/.forgejo/workflows/docs.yml @@ -7,6 +7,7 @@ on: - 'docs/**' - 'great_ai/**' - 'mkdocs.yaml' + - 'pyproject.toml' - '.forgejo/workflows/docs.yml' workflow_dispatch: @@ -29,11 +30,11 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Set up Python - run: uv python install 3.10 + run: uv python install 3.13 - name: Install dependencies run: | - uv venv --python 3.10 + uv venv --python 3.13 uv pip install '.[dev]' - name: Build documentation diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 8042684..26b4bdb 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Set up Python - run: uv python install 3.10 + run: uv python install 3.13 # Forgejo cannot use PyPI trusted publishing (OIDC), so authenticate with # an API token, the same way the sibling repos publish their packages. diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index a76c206..85b56c2 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -24,14 +24,14 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Set up Python - run: uv python install 3.10 + run: uv python install 3.13 - name: Install dependencies # --seed gives the venv its own pip, which scripts/check-python.sh shells # out to for its linters; '.[dev]' provides the rest (mypy resolves the # package's own imports against the installed tree). run: | - uv venv --seed --python 3.10 + uv venv --seed --python 3.13 uv pip install '.[dev]' - name: Check code and formatting @@ -45,12 +45,7 @@ jobs: strategy: fail-fast: false matrix: - # pyproject declares >= 3.7, but uv's standalone CPython and the current - # dependency floors (scikit-learn/numpy now require >= 3.9) no longer - # build on the older interpreters, so we test the current supported set. - # The windows leg of the old GitHub matrix is dropped: this Forgejo - # instance only has a Linux `docker` runner. - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/Dockerfile b/Dockerfile index 359dcd6..34ca706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.4 -FROM python:3.10.4-slim-bullseye +FROM python:3.13-slim-trixie LABEL org.opencontainers.image.title="GreatAI package wrapper image" LABEL org.opencontainers.image.vendor="ScoutinScience B.V." diff --git a/README.md b/README.md index eba2aaf..b798927 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ However, [research indicates](https://great-ai.scoutinscience.com) that professi ## Find `great-ai` on [PyPI](https://pypi.org/project/great-ai/) +Requires Python 3.12 or later, including Python 3.13. + ```sh pip install great-ai ``` @@ -78,8 +80,8 @@ Contributions are welcome. ```sh python3 -m venv --copies .env source .env/bin/activate -pip install --upgrade flit pip -flit install --symlink +pip install --upgrade pip +pip install --editable '.[dev]' ``` ### Develop diff --git a/docs/how-to-guides/install.md b/docs/how-to-guides/install.md index fc00e4d..9504d65 100644 --- a/docs/how-to-guides/install.md +++ b/docs/how-to-guides/install.md @@ -5,18 +5,17 @@ Provided you already have [Python3](https://www.python.org/downloads/){ target=_ ```sh pip install great-ai ``` -> Python 3.7 or later is required. +> Python 3.12 or later is required. Python 3.13 is supported. This will work on all major operating systems. ## Google Colab -In order to use GreatAI in [Google Colab](https://colab.research.google.com){ target=_blank }, you need to downgrade `pyyaml` to a Colab compatible version. [See related StackOverflow question](https://stackoverflow.com/questions/69564817/typeerror-load-missing-1-required-positional-argument-loader-in-google-col){ target=_blank }. +To use GreatAI in [Google Colab](https://colab.research.google.com){ target=_blank }, select a runtime with Python 3.12 or later and install the package: ```sh -pip install great-ai pyyaml==5.4.1 +pip install --upgrade great-ai ``` -> This will make GreatAI work in Colab. ## Command-line tools @@ -50,4 +49,4 @@ You can also install the latest (usually unreleased) version from GitHub. ```sh pip install --upgrade git+https://github.com/schmelczer/great-ai.git ``` -> Python 3.7 or later is required. +> Python 3.12 or later is required. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index b9055d6..f0c45ff 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -50,7 +50,7 @@ After training and evaluating a model, it is exported using [great_ai.save_model save_model(model, key='my-domain-predictor') ``` - For more info, checkout [the configuration how-to page](/how-to-guides/configure-service). + For more info, check out [the configuration how-to page](/how-to-guides/configure-service). ### [Deployment notebook](deploy.ipynb) diff --git a/great_ai/deploy/routes/dashboard/get_description.py b/great_ai/deploy/routes/dashboard/get_description.py index 8982f24..74d82c8 100644 --- a/great_ai/deploy/routes/dashboard/get_description.py +++ b/great_ai/deploy/routes/dashboard/get_description.py @@ -16,8 +16,7 @@ def get_description( style={"color": accent_color}, ), dcc.Markdown( - strip_lines( - f""" + strip_lines(f""" > View the live data of your deployment here. ## Using the API @@ -27,8 +26,7 @@ def get_description( ## Details {function_docs} - """ - ), + """), className="description", ), ] diff --git a/great_ai/utilities/config_file/config_file.py b/great_ai/utilities/config_file/config_file.py index 1116833..33e14b7 100644 --- a/great_ai/utilities/config_file/config_file.py +++ b/great_ai/utilities/config_file/config_file.py @@ -94,11 +94,9 @@ class ConfigFile(Mapping[str, str]): try: value = next(v for v in values if v) except StopIteration: - raise ParseError( - f"""Cannot parse config file ({ + raise ParseError(f"""Cannot parse config file ({ self._path.absolute() - }), error at key `{key}`""" - ) + }), error at key `{key}`""") already_exists = key in self._key_values if already_exists and not value.startswith( diff --git a/great_ai/utilities/parallel_map/manage_communication.py b/great_ai/utilities/parallel_map/manage_communication.py index 4d53d2b..8476e4c 100644 --- a/great_ai/utilities/parallel_map/manage_communication.py +++ b/great_ai/utilities/parallel_map/manage_communication.py @@ -39,11 +39,9 @@ def manage_communication( is_iteration_over = True except Exception as e: if ignore_exceptions: - logger.error( - f"""Exception {e} encountered in input, traceback:\n{ + logger.error(f"""Exception {e} encountered in input, traceback:\n{ traceback.format_exc() - }""" - ) + }""") else: raise diff --git a/pyproject.toml b/pyproject.toml index a02460d..dbe07b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["flit_core >=3.2,<4"] +requires = ["flit_core >= 4.1.0, < 5"] build-backend = "flit_core.buildapi" [tool.flit.module] @@ -14,6 +14,8 @@ license = { file = "LICENSE" } classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", "Development Status :: 3 - Alpha", @@ -21,51 +23,51 @@ classifiers = [ "Natural Language :: English", ] keywords = ["SE4ML", "MLOps", "AI engineering", "general", "robust", "end-to-end", "automated", "trustworthy", "ai", "deployment"] -requires-python = ">= 3.10" +requires-python = ">= 3.12" dependencies = [ - "scikit-learn >= 1.3, < 2", - "matplotlib >= 3.7, < 4", - "numpy >= 1.24, < 3", - "nbconvert >= 7.0, < 8", - "ipython >= 8.0, < 10", - "unidecode >= 1.3.0, < 2", - "syntok >= 1.4.0, < 2", - "langcodes[data] >= 3.3.0, < 4", + "scikit-learn >= 1.9.1, < 2", + "matplotlib >= 3.11.2, < 4", + "numpy >= 2.5.4, < 3", + "nbconvert >= 7.17.2, < 8", + "ipython >= 9.17.1, < 10", + "unidecode >= 1.4.0, < 2", + "syntok >= 1.4.4, < 2", + "langcodes[data] >= 3.5.1, < 4", "langdetect >= 1.0.9, < 2", - "tinydb >= 4.7.0, < 5", - "boto3 >= 1.34, < 2", - "plotly >= 5.20, < 7", - "pandas >= 2.0, < 4", - "dash >= 2.16, < 5", - "fastapi >= 0.110, < 1", - "uvicorn[standard] >= 0.27, < 1", - "a2wsgi >= 1.9, < 2", - "watchdog >= 3.0, < 7", - "typeguard >= 4.0, < 5", - "pydantic >= 2.5, < 3", - "pymongo >= 4.6, < 5", - "dill >= 0.3.6, < 1", - "tqdm >= 4.64, < 5", - "httpx >= 0.24, < 1", + "tinydb >= 4.9.0, < 5", + "boto3 >= 1.43.119, < 2", + "plotly >= 7.1.0, < 8", + "pandas >= 3.1.0, < 4", + "dash >= 4.4.1, < 5", + "fastapi >= 0.162.0, < 1", + "uvicorn[standard] >= 0.54.0, < 1", + "a2wsgi >= 1.10.10, < 2", + "watchdog >= 6.0.0, < 7", + "typeguard >= 4.6.0, < 5", + "pydantic >= 2.14.1, < 3", + "pymongo >= 4.19.0, < 5", + "dill >= 0.4.1, < 1", + "tqdm >= 4.70.1, < 5", + "httpx >= 0.28.1, < 1", ] [project.optional-dependencies] dev = [ - "flit >= 3.9, < 4", - "mkdocs >= 1.5, < 2", - "mkdocstrings[python] >= 0.24, < 1", - "mkdocs-material >= 9.5, < 10", - "mkdocs-jupyter >= 0.24, < 1", - "mkdocs-git-revision-date-localized-plugin >= 1.2, < 2", - "autoflake >= 2.0, < 3", - "isort >= 5.12, < 7", - "black[jupyter] >= 25.1, < 26", - "mypy >= 1.8, < 2", - "flake8 >= 7.0, < 8", - "tox >= 4.0, < 5", - "pytest >= 7.4, < 9", - "pytest-cov >= 4.1, < 8", - "pytest-asyncio >= 0.23, < 2", + "flit >= 4.1.0, < 5", + "mkdocs >= 1.6.1, < 2", + "mkdocstrings[python] >= 1.0.8, < 2", + "mkdocs-material >= 9.8.0, < 10", + "mkdocs-jupyter >= 0.27.0, < 1", + "mkdocs-git-revision-date-localized-plugin >= 1.6.2, < 2", + "autoflake >= 2.4.0, < 3", + "isort >= 9.0.2, < 10", + "black[jupyter] >= 26.10.1, < 27", + "mypy >= 2.4.0, < 3", + "flake8 >= 7.4.1, < 8", + "tox >= 4.67.2, < 5", + "pytest >= 9.1.1, < 10", + "pytest-cov >= 7.1.0, < 8", + "pytest-asyncio >= 1.4.1, < 2", ] [tool.pytest.ini_options] diff --git a/tests/test_http_api.py b/tests/test_http_api.py new file mode 100644 index 0000000..45c502f --- /dev/null +++ b/tests/test_http_api.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from great_ai import GreatAI, Trace, configure, context +from great_ai.deploy.routes.dashboard.create_dash_app import update_charts + + +@pytest.mark.parametrize("is_async", [False, True]) +def test_service( + is_async: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(context, "_context", None) + configure(version="1.2.3", disable_se4ml_banner=True) + + def predict_sync(value: int) -> int: + return value + 2 + + async def predict_async(value: int) -> int: + return value + 2 + + service = GreatAI.create(predict_async if is_async else predict_sync) + + with TestClient(service.app) as client: + response = client.post("/predict", json={"value": 3}) + assert response.status_code == 200 + trace = response.json() + assert trace["output"] == 5 + assert trace["logged_values"]["arg:value:value"] == 3 + + trace_url = f"/traces/{trace['trace_id']}" + assert client.get(trace_url).json() == trace + assert client.post("/traces", json={}).json() == [trace] + assert client.post("/predict", json={"value": "invalid"}).status_code == 422 + + feedback_url = f"{trace_url}/feedback/" + assert client.put(feedback_url, json={"feedback": "correct"}).status_code == 202 + assert client.get(feedback_url).json() == "correct" + assert client.delete(feedback_url).status_code == 204 + assert client.get(feedback_url).json() is None + + health = client.get("/health") + assert health.status_code == 200 + assert health.json()["is_healthy"] is True + assert client.get("/version").json()["version"].split("+")[0] == "1.2.3" + assert client.get("/openapi.json").status_code == 200 + assert client.get("/docs").status_code == 200 + assert client.get("/dashboard/").status_code == 200 + assert client.get("/dashboard/_dash-layout").status_code == 200 + assert client.get("/assets/index.css").status_code == 200 + + histogram, coordinates, style = update_charts( + [Trace.model_validate(trace)], accent_color="#123456" + ) + assert len(histogram.figure.data) == 1 + assert len(coordinates.data) == 1 + assert style == {} + + assert client.delete(trace_url).status_code == 204 + assert client.get(trace_url).status_code == 404 diff --git a/tests/utilities/test_config_file.py b/tests/utilities/test_config_file.py index df6a48b..b3476c1 100644 --- a/tests/utilities/test_config_file.py +++ b/tests/utilities/test_config_file.py @@ -14,14 +14,11 @@ def test_simple() -> None: assert c.first_key == "AndrĂ¡s" assert c.second_key == "test 2" assert c.third_key == "test= 2==" - assert ( - c.fourth_key - == """ + assert c.fourth_key == """ this# is multiline """ - ) assert c.whitespace == "hardly matters" with pytest.raises(KeyError): c.this @@ -34,14 +31,11 @@ def test_simple_dict() -> None: assert c["my_hashtag"] == "#great_ai" assert c["second_key"] == "test 2" assert c["third_key"] == "test= 2==" - assert ( - c["fourth_key"] - == """ + assert c["fourth_key"] == """ this# is multiline """ - ) assert c["whitespace"] == "hardly matters" with pytest.raises(KeyError): c["#this"] @@ -55,14 +49,11 @@ def test_string_path() -> None: assert c.second_key == "test 2" assert c.third_key == "test= 2==" - assert ( - c.fourth_key - == """ + assert c.fourth_key == """ this# is multiline """ - ) assert c.whitespace == "hardly matters" diff --git a/tox.ini b/tox.ini index f853c1f..fee545f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py38,py39,py310 +envlist = py312,py313 isolated_build = True [testenv]