Skip to content
25 changes: 25 additions & 0 deletions python/hopsworks_common/core/library_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,28 @@ def _install(
),
environment=self,
)

def _uninstall(self, library_name: str, name: str) -> None:
"""Uninstall a library from the environment.

Parameters:
library_name: Name of the library.
name: Name of the environment.

Raises:
hopsworks.client.exceptions.RestAPIError: If the backend encounters an error when handling the request.
"""
_client = client.get_instance()

path_params = [
"project",
_client._project_id,
"python",
"environments",
name,
"libraries",
library_name,
]

headers = {"content-type": "application/json"}
_client._send_request("DELETE", path_params, headers=headers)
33 changes: 33 additions & 0 deletions python/hopsworks_common/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,39 @@ def install_requirements(self, path: str, await_installation: bool | None = True
if await_installation:
self._environment_engine.await_library_command(self.name, library_name)

@public
@usage.method_logger
def uninstall(
self, library_name: str, await_uninstallation: bool | None = True
) -> None:
"""Uninstall a library from the environment.

```python
import hopsworks

project = hopsworks.login()

env_api = project.get_environment_api()
env = env_api.get_environment("my_custom_environment")

env.uninstall("matplotlib")
```

Parameters:
library_name: Name of the installed library to remove.
await_uninstallation: If `True` the method returns only when the uninstallation finishes.

Raises:
hopsworks.client.exceptions.RestAPIError: If the backend encounters an error when handling the request.
"""
# Wait for any ongoing environment operations
self._environment_engine.await_environment_command(self.name)

self._library_api._uninstall(library_name, self.name)

if await_uninstallation:
self._environment_engine.await_library_command(self.name, library_name)

@public
@usage.method_logger
def delete(self):
Expand Down
31 changes: 31 additions & 0 deletions python/hsml/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,37 @@ def stop(self, await_stopped: int | None = 600):
"""
self._serving_engine.stop(self, await_status=await_stopped)

@public
@usage.method_logger
def restart(
self,
await_stopped: int | None = 600,
await_running: int | None = 600,
fail_if_stopped: bool = False,
Comment thread
aversey marked this conversation as resolved.
Outdated
) -> None:
"""Restart the deployment so it picks up the latest code and environment state.

If the deployment is already stopped, it is started in place by default.
Pass `fail_if_stopped=True` to require that the deployment is currently running.

Parameters:
await_stopped: Awaiting time (seconds) for the deployment to stop.
await_running: Awaiting time (seconds) for the deployment to start again.
fail_if_stopped: Raise instead of starting in place when the deployment is not running.

Raises:
hopsworks.client.exceptions.ModelServingException: If `fail_if_stopped` is `True` and the deployment is not running.
hopsworks.client.exceptions.RestAPIError: In case the backend encounters an issue.
"""
if self.is_stopped():
if fail_if_stopped:
raise ModelServingException(
"Cannot restart a deployment that is not running"
)
else:
self.stop(await_stopped=await_stopped)
self.start(await_running=await_running)

@public
@usage.method_logger
def delete(self, force: bool = False):
Expand Down
206 changes: 206 additions & 0 deletions python/hsml/model_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@
from __future__ import annotations

import os
import re
from typing import TYPE_CHECKING

from hopsworks_apigen import public
from hopsworks_common import usage, util
from hopsworks_common.client.exceptions import RestAPIError
from hopsworks_common.constants import INFERENCE_ENDPOINTS as IE
from hopsworks_common.constants import PREDICTOR_STATE
from hopsworks_common.core import dataset_api as _dataset_api
from hopsworks_common.core import environment_api as _environment_api
from hsml.core import serving_api
from hsml.deployment import Deployment
from hsml.predictor import Predictor
Expand Down Expand Up @@ -382,6 +386,127 @@ def create_endpoint(
scaling_configuration=scaling_configuration,
)

@public
@usage.method_logger
def deploy_agent(
self,
entry: str,
name: str | None = None,
requirements: str | None = None,
Comment thread
aversey marked this conversation as resolved.
environment: str | None = None,
upload_dir: str = "Resources/agents",
description: str | None = None,
resources: PredictorResources | dict | None = None,
inference_logger: InferenceLogger | dict | str | None = None,
inference_batcher: InferenceBatcher | dict | None = None,
api_protocol: str | None = IE.API_PROTOCOL_REST,
scaling_configuration: PredictorScalingConfig | dict | None = None,
) -> Deployment:
"""Deploy a Python script or package as an agent.

The agent is created on first call and updated on subsequent calls.
Each call uploads the latest local code, refreshes the Python environment, and rewrites the deployment's predictor metadata to reflect the arguments passed in — including any unspecified arguments, which fall back to their defaults.
The deployment's running state is left untouched; call `start()` after the first deploy and `restart()` to roll a running agent onto the new code.
Works the same whether invoked from outside or inside a Hopsworks cluster.

Pass either a `.py` script or a directory containing a `pyproject.toml`.
For a script, the file is uploaded and run directly.
For a package, a wheel is built locally with the project's PEP 517 backend, uploaded, and installed; a small runner module invokes the package via `runpy.run_module`.

```python
ms = project.get_model_serving()

agent = ms.deploy_agent(entry="my_agent.py")
agent.start() # or agent.restart()

# iterate: edit code locally, push, then roll the running agent onto it
agent = ms.deploy_agent(entry="my_agent.py")
agent.restart()
```

Parameters:
entry: Local path to a `.py` script or to a directory containing `pyproject.toml`.
name: Name of the deployment, also used as the default Python environment name. Defaults to the basename of `entry` (without the `.py` extension for scripts). Must match `[A-Za-z0-9_-]+`.
requirements: Local path to a `requirements.txt` to install into the environment.
environment: Name of the Python environment to use; defaults to `name`. Created if it does not exist. Must match `[A-Za-z0-9_-]+`.
upload_dir: Directory in the Hopsworks Filesystem under which agent files are placed; the agent gets its own subdirectory `<upload_dir>/<name>`.
description: Description of the deployment.
resources: Resources to be allocated for the predictor.
inference_logger: Inference logger configuration.
inference_batcher: Inference batcher configuration.
api_protocol: API protocol to be enabled in the deployment (i.e., 'REST' or 'GRPC').
Comment thread
aversey marked this conversation as resolved.
scaling_configuration: Scaling configuration for the predictor.

Returns:
The deployment metadata object.

Raises:
ValueError: If `entry` is neither a `.py` file nor a directory with `pyproject.toml`, or if `name`/`environment` contain characters outside `[A-Za-z0-9_-]`.
hopsworks.client.exceptions.RestAPIError: If the backend encounters an error when handling the request.
"""
entry_abs = os.path.abspath(entry)
is_script = os.path.isfile(entry_abs) and entry_abs.endswith(".py")
is_package = os.path.isdir(entry_abs) and os.path.isfile(
os.path.join(entry_abs, "pyproject.toml")
)
if not (is_script or is_package):
raise ValueError(
f"entry must be a .py file or a directory containing pyproject.toml: {entry}"
)

if name is None:
name = os.path.basename(entry_abs)
if is_script:
name = os.path.splitext(name)[0]
_validate_agent_identifier(name, "name")

env_name = environment or name
if environment is not None:
_validate_agent_identifier(environment, "environment")

agent_dir = f"{upload_dir}/{name}"

Comment thread
aversey marked this conversation as resolved.
Comment thread
aversey marked this conversation as resolved.
Outdated
ds_api = _dataset_api.DatasetApi()
env_api = _environment_api.EnvironmentApi()

_ensure_dataset_dir(ds_api, agent_dir)

env = env_api.get_environment(env_name) or env_api.create_environment(env_name)

if is_script:
script_file = ds_api.upload(entry_abs, agent_dir, overwrite=True)
else:
script_file = _build_and_install_package(ds_api, env, entry_abs, agent_dir)

if requirements is not None:
req_remote = ds_api.upload(
os.path.abspath(requirements), agent_dir, overwrite=True
)
env.install_requirements(req_remote)

predictor = Predictor.for_server(
name=name,
script_file=script_file,
description=description,
resources=resources,
inference_logger=inference_logger,
inference_batcher=inference_batcher,
api_protocol=api_protocol,
environment=env_name,
scaling_configuration=scaling_configuration,
)

existing = self.get_deployment(name)
if existing is not None:
# Preserve identity so save() updates the existing record instead of creating a new one.
predictor._id = existing.predictor.id
existing.predictor = predictor
existing.description = description
existing.save()
return existing

return predictor.deploy()

@public
@usage.method_logger
def create_deployment(
Expand Down Expand Up @@ -479,3 +604,84 @@ def project_id(self):

def __repr__(self):
return f"ModelServing(project: {self._project_name!r})"


_AGENT_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_-]+$")


def _validate_agent_identifier(value: str, field: str) -> None:
"""Reject identifiers that would be unsafe to interpolate into a dataset path.

`name` and `environment` flow into both the upload subdirectory and predictor metadata,
so disallowing path separators and traversal segments protects against accidental writes
outside `<upload_dir>/<name>`.
"""
if not _AGENT_IDENTIFIER_RE.fullmatch(value):
raise ValueError(
f"{field} must match {_AGENT_IDENTIFIER_RE.pattern!r}, got {value!r}"
)


def _ensure_dataset_dir(ds_api, path: str) -> None:
"""Create `path` in the Hopsworks Filesystem if missing, creating parents as needed."""
if ds_api.exists(path):
return
parent, _, _ = path.rpartition("/")
if parent:
_ensure_dataset_dir(ds_api, parent)
ds_api.mkdir(path)


def _build_and_install_package(ds_api, env, package_dir: str, agent_dir: str) -> str:
"""Build a wheel from `package_dir`, upload it, install it into `env`, and upload a runner script.

Returns the remote path of the runner script to use as `script_file`.
"""
import tempfile

from build import ProjectBuilder

pkg_name = _read_package_name(package_dir)

with tempfile.TemporaryDirectory() as build_dir:
# `build` returns the wheel filename relative to build_dir on some versions and
# an absolute path on others; os.path.join leaves an absolute result untouched.
wheel_local = os.path.join(
build_dir, ProjectBuilder(package_dir).build("wheel", build_dir)
)
wheel_remote = ds_api.upload(wheel_local, agent_dir, overwrite=True)

# Force a reinstall: pip skips a same-version wheel, so we uninstall first.
# On first deploy the package is not installed yet — that 404 is expected.
try:
env.uninstall(pkg_name)
except RestAPIError as e:
if e.response.status_code != 404:
raise

env.install_wheel(wheel_remote)

runner_local = os.path.join(build_dir, "runner.py")
with open(runner_local, "w") as f:
f.write(
f"import runpy\nrunpy.run_module({pkg_name!r}, run_name='__main__')\n"
)
Comment thread
aversey marked this conversation as resolved.
return ds_api.upload(runner_local, agent_dir, overwrite=True)


def _read_package_name(package_dir: str) -> str:
"""Read `[project].name` from the package's `pyproject.toml`."""
try:
import tomllib
except ImportError:
import tomli as tomllib

with open(os.path.join(package_dir, "pyproject.toml"), "rb") as f:
pyproject = tomllib.load(f)
project = pyproject.get("project") or {}
pkg_name = project.get("name")
if not isinstance(pkg_name, str):
raise ValueError(
f"Cannot read [project].name as a static string from {package_dir}/pyproject.toml"
)
return pkg_name
Comment thread
aversey marked this conversation as resolved.
1 change: 1 addition & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dependencies = [
"protobuf>=4.25.4,<5.0.0", # ^4.25.4
"packaging", # ^21.0
"hopsworks-apigen>=1.0.4,<2.0.0",
"build",
Comment thread
aversey marked this conversation as resolved.
]

[project.scripts]
Expand Down
44 changes: 44 additions & 0 deletions python/tests/core/test_library_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#
# Copyright 2026 Hopsworks AB
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

from hopsworks_common.core import library_api


class TestLibraryApi:
def test_uninstall_sends_delete_to_library_path(self, mocker):
# Arrange
api = library_api.LibraryApi()
mock_client = mocker.MagicMock()
mock_client._project_id = 99
mocker.patch("hopsworks_common.client.get_instance", return_value=mock_client)

# Act
api._uninstall("matplotlib", "myenv")

# Assert
mock_client._send_request.assert_called_once_with(
"DELETE",
[
"project",
99,
"python",
"environments",
"myenv",
"libraries",
"matplotlib",
],
headers={"content-type": "application/json"},
)
Loading
Loading