Skip to content
Merged
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 .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.14
3.14.8
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
WORKDIR /app
# Pinned, not `:latest` — a floating tag is invisible to Dependabot's docker ecosystem.
COPY --from=ghcr.io/astral-sh/uv:0.12.3 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.12.22 /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
Expand Down
139 changes: 135 additions & 4 deletions mise.lock

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

18 changes: 10 additions & 8 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
# Canonical task vocabulary shared by git hooks (lefthook) and CI (GitHub Actions).

[env]
UV_LOCKED = "1"
_.source = ".env"

[settings.task]
run_auto_install = false

[tools]
actionlint = "latest"
dprint = "latest"
git-cliff = "latest"
gitleaks = "latest"
hadolint = "latest"
trivy = "latest"
uv = "latest"
zizmor = "latest"
python = "3.14.8"
"aqua:rhysd/actionlint" = "1.7.12"
"aqua:dprint/dprint" = "0.58.0"
"aqua:orhun/git-cliff" = "2.14.2"
"aqua:gitleaks/gitleaks" = "8.30.1"
"aqua:hadolint/hadolint" = "2.15.1"
"aqua:aquasecurity/trivy" = "0.75.0"
"aqua:astral-sh/uv" = "0.12.22"
"aqua:zizmorcore/zizmor" = "1.30.1"

# ALL

Expand Down
13 changes: 2 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ requires-python = ">=3.14"
dependencies = [
"loguru>=0.7.3",
"matplotlib>=3.11.0",
"mlflow>=3.14.0",
"mlflow>=3.16.1",
# numba/numpy/pyarrow/shap are ABI-coupled: keep permissive floors so uv's universal
# (all-platform) resolution stays satisfiable; the lockfile pins the latest tested versions.
"numba>=0.61.0",
"numpy>=2.1.3",
"omegaconf>=2.3.1",
"pandas>=2.3.3", # MLflow 3.x pins pandas<3; pandas 3.0 is held back by MLflow compat
"pandas>=2.3.3",
"pandera>=0.32.1",
"plotly>=6.8.0",
"plyer>=2.1.0",
Expand Down Expand Up @@ -69,15 +69,6 @@ dev = [
]
notebook = ["ipykernel>=6.29.5", "nbformat>=5.10.4"]

# OVERRIDES

[tool.uv]
# MLflow 3.15 still declares `cryptography<50`, but the fix for PYSEC-2026-3552
# (PKCS#7 Bleichenbacher oracle) only ships in 50.0.0. Overriding the stale upper
# bound installs the patched library; the test suite is what proves MLflow still
# works with it. Drop this override once MLflow relaxes the constraint upstream.
override-dependencies = ["cryptography>=50"]

# TOOLS

[tool.ruff]
Expand Down
10 changes: 7 additions & 3 deletions src/bikes/io/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import typing as T

import mlflow
import pandas as pd
import pydantic as pdt
from mlflow.pyfunc import PyFuncModel, PythonModel, PythonModelContext

Expand Down Expand Up @@ -124,20 +125,23 @@ def __init__(self, model: models.Model):
def predict(
self,
context: PythonModelContext, # noqa: ARG002 # required by mlflow PythonModel.predict
model_input: schemas.Inputs,
model_input: pd.DataFrame,
params: dict[str, T.Any] | None = None, # noqa: ARG002 # required by mlflow PythonModel.predict
) -> schemas.Outputs:
"""Generate predictions with a custom model for the given inputs.

Args:
context (mlflow.PythonModelContext): mlflow context.
model_input (schemas.Inputs): inputs for the mlflow model.
model_input (pd.DataFrame): inputs to validate for the mlflow model.
params (dict[str, T.Any] | None): additional parameters.

Returns:
schemas.Outputs: validated outputs of the project model.
"""
return self.model.predict(inputs=model_input)
# MLflow accepts pandas here and validates the logged signature;
# Pandera enforces the project's constraints before the typed model call.
inputs = schemas.InputsSchema.check(data=model_input)
return self.model.predict(inputs=inputs)

@T.override
def save(
Expand Down
25 changes: 22 additions & 3 deletions src/bikes/io/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ class AlertsService(Service):
app_name: str = "Bikes"
timeout: int | None = None

# Plyer's NOTIFYICONDATAW buffers reserve one UTF-16 unit for the terminator.
_MAX_APP_NAME_LENGTH: T.ClassVar[int] = 127
_MAX_TITLE_LENGTH: T.ClassVar[int] = 63
_MAX_MESSAGE_LENGTH: T.ClassVar[int] = 255

@T.override
def start(self) -> None:
pass
Expand All @@ -113,11 +118,16 @@ def notify(self, title: str, message: str) -> None:
message (str): message of the notification.
"""
if self.enable:
notify_title, notify_message, app_name = title, message, self.app_name
if sys.platform == "win32":
notify_title = self._truncate(title, self._MAX_TITLE_LENGTH)
notify_message = self._truncate(message, self._MAX_MESSAGE_LENGTH)
app_name = self._truncate(app_name, self._MAX_APP_NAME_LENGTH)
try:
notification.notify(
title=title,
message=message,
app_name=self.app_name,
title=notify_title,
message=notify_message,
app_name=app_name,
timeout=self.timeout,
)
except NotImplementedError:
Expand All @@ -126,6 +136,15 @@ def notify(self, title: str, message: str) -> None:
else:
self._print(title=title, message=message)

@staticmethod
def _truncate(value: str, max_length: int) -> str:
"""Fit a Windows field in UTF-16 units without splitting a surrogate pair."""
encoded = value.encode("utf-16-le")
if len(encoded) <= max_length * 2:
return value
prefix = encoded[: (max_length - 1) * 2].decode("utf-16-le", errors="ignore")
return f"{prefix}\N{HORIZONTAL ELLIPSIS}"

def _print(self, title: str, message: str) -> None:
"""Print a notification to the system.

Expand Down
34 changes: 34 additions & 0 deletions tests/io/test_registries.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# %% IMPORTS

import runpy
import warnings

import pandas as pd
import pandera.errors as pe
import pytest

from bikes.core import models, schemas
from bikes.io import registries, services
from bikes.utils import signers
Expand Down Expand Up @@ -45,6 +52,28 @@ def test_uri_for_model_alias_or_version() -> None:
# %% SAVERS/LOADERS/REGISTERS


def test_custom_saver_import_without_type_hint_warning() -> None:
# MLflow inspects predict when the adapter class is defined, before logging.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
runpy.run_path(registries.__file__)
assert not any("Type hint used in the model" in str(warning.message) for warning in caught)


def test_custom_saver_adapter_predict(model: models.Model, inputs: schemas.Inputs) -> None:
adapter = registries.CustomSaver.Adapter(model=model)
outputs = adapter.predict(context=None, model_input=pd.DataFrame(inputs))
pd.testing.assert_frame_equal(outputs, model.predict(inputs=inputs))


def test_custom_saver_adapter_rejects_invalid_inputs(model: models.Model, inputs: schemas.Inputs) -> None:
adapter = registries.CustomSaver.Adapter(model=model)
invalid_inputs = pd.DataFrame(inputs).copy()
invalid_inputs["hr"] = 24
with pytest.raises(pe.SchemaError, match="less_than_or_equal_to"):
adapter.predict(context=None, model_input=invalid_inputs)


def test_custom_pipeline(
model: models.Model,
inputs: schemas.Inputs,
Expand Down Expand Up @@ -87,6 +116,11 @@ def test_custom_pipeline(
)
# - output
assert schemas.OutputsSchema.check(outputs) is not None, "Outputs should be valid!"
# The serialized PyFunc must retain Pandera constraints beyond MLflow's column types.
invalid_inputs = inputs.copy()
invalid_inputs.loc[:, "hr"] = 24
with pytest.raises(pe.SchemaError, match="less_than_or_equal_to"):
adapter.predict(inputs=invalid_inputs)


def test_builtin_pipeline(
Expand Down
Loading