diff --git a/ctl/pyproject.toml b/ctl/pyproject.toml index 295099e1..0c205815 100644 --- a/ctl/pyproject.toml +++ b/ctl/pyproject.toml @@ -13,6 +13,7 @@ requires-python = ">=3.11" license = { text = "Apache-2.0" } dependencies = [ "mas-runtime", + "mas-library-standard", "click>=8.0", "pyyaml>=6.0", "python-dotenv>=1.0", @@ -44,5 +45,13 @@ packages = ["src/mas"] # are excluded from the published wheel. exclude = ["src/mas/ctl/testing/**"] +# Package a copy of the canonical runtime/lab manifest schemas inside mas.ctl so +# overlay merging and manifest validation still work when installed as a wheel +# (e.g. via 'uv tool install'), where there is no repo checkout to read +# docs/schemas/ from. See mas.ctl.validate.schemas.schema_root(). +[tool.hatch.build.targets.wheel.force-include] +"../docs/schemas" = "src/mas/ctl/_schemas" + [tool.uv.sources] mas-runtime = { workspace = true } +mas-library-standard = { workspace = true } diff --git a/ctl/src/mas/ctl/overlay/merge.py b/ctl/src/mas/ctl/overlay/merge.py index a23c7421..80f8d7bd 100644 --- a/ctl/src/mas/ctl/overlay/merge.py +++ b/ctl/src/mas/ctl/overlay/merge.py @@ -7,11 +7,12 @@ from functools import lru_cache import logging from copy import deepcopy -from pathlib import Path from typing import Any import yaml +from mas.ctl.validate.schemas import schema_root + logger = logging.getLogger(__name__) @@ -21,10 +22,10 @@ # single edit in a single file (docs/schemas/runtime/.schema.yaml), # not a second hand-maintained overlay-*-patch fragment kept in sync by hand. _OVERLAY_PATCH_SCHEMA_FILES: dict[str, str] = { - "Agent": "docs/schemas/runtime/agent.schema.yaml", - "MAS": "docs/schemas/runtime/mas.schema.yaml", - "Flavour": "docs/schemas/runtime/flavour.schema.yaml", - "Infra": "docs/schemas/runtime/infra.schema.yaml", + "Agent": "runtime/agent.schema.yaml", + "MAS": "runtime/mas.schema.yaml", + "Flavour": "runtime/flavour.schema.yaml", + "Infra": "runtime/infra.schema.yaml", } # Where to start reading "properties" from, per schema file, relative to that @@ -38,14 +39,9 @@ } -def _repo_root() -> Path: - # .../mas-lab/ctl/src/mas/ctl/overlay/merge.py -> parents[5] == repo root - return Path(__file__).resolve().parents[5] - - @lru_cache(maxsize=8) def _load_yaml_schema(rel_path: str) -> dict[str, Any]: - schema_path = _repo_root() / rel_path + schema_path = schema_root() / rel_path data = yaml.safe_load(schema_path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} diff --git a/ctl/src/mas/ctl/validate/schemas.py b/ctl/src/mas/ctl/validate/schemas.py index 68b1b437..5b83528a 100644 --- a/ctl/src/mas/ctl/validate/schemas.py +++ b/ctl/src/mas/ctl/validate/schemas.py @@ -1,16 +1,37 @@ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates # SPDX-License-Identifier: Apache-2.0 -"""Manifest JSON Schema loader — schemas live under docs/schemas/.""" +"""Manifest JSON Schema loader — schemas live under docs/schemas/. + +Two layouts are supported so this works both in a source checkout (dev / +editable install, where ``docs/schemas`` sits a few parents above this file) +and when installed as a built wheel (e.g. via ``uv tool install``, where +there is no repo root — the schemas are instead packaged inside ``mas.ctl`` +itself via ``force-include`` and resolved through ``importlib.resources``). +""" from __future__ import annotations from functools import lru_cache +from importlib import resources from pathlib import Path from typing import Any import yaml -_SCHEMA_ROOT = Path(__file__).resolve().parents[5] / "docs" / "schemas" + +def _repo_checkout_schema_root() -> Path | None: + # .../mas-lab/ctl/src/mas/ctl/validate/schemas.py -> parents[5] == repo root + candidate = Path(__file__).resolve().parents[5] / "docs" / "schemas" + return candidate if candidate.is_dir() else None + + +def _packaged_schema_root() -> Path: + # Falls back to the copy of docs/schemas packaged inside mas.ctl (see + # [tool.hatch.build.targets.wheel.force-include] in ctl/pyproject.toml). + return Path(str(resources.files("mas.ctl") / "_schemas")) + + +_SCHEMA_ROOT = _repo_checkout_schema_root() or _packaged_schema_root() _KIND_MAP: dict[str, str] = { "agent": "runtime/agent.schema.yaml", diff --git a/lab/components/core/pyproject.toml b/lab/components/core/pyproject.toml index 51be7961..1ca27582 100644 --- a/lab/components/core/pyproject.toml +++ b/lab/components/core/pyproject.toml @@ -19,5 +19,12 @@ dependencies = [ [tool.hatch.build.targets.wheel] packages = ["src/mas"] +# Package a copy of the lab manifest schemas inside mas.lab.schemas so +# mas-lab-core still works when installed as a wheel (e.g. via +# 'uv tool install'), where there is no repo checkout to read +# docs/schemas/lab from. See mas.lab.schemas.paths.lab_schema_dir(). +[tool.hatch.build.targets.wheel.force-include] +"../../../docs/schemas/lab" = "src/mas/lab/schemas/_schemas" + [project.entry-points."mas.lab.controller.plugins"] local-inproc = "mas.lab.controllers.local_inproc:LocalInprocController" diff --git a/lab/components/core/src/mas/lab/schemas/paths.py b/lab/components/core/src/mas/lab/schemas/paths.py index 4cda07eb..ec9935f1 100644 --- a/lab/components/core/src/mas/lab/schemas/paths.py +++ b/lab/components/core/src/mas/lab/schemas/paths.py @@ -3,6 +3,7 @@ """Resolve manifest schema files from installed package dependencies.""" from __future__ import annotations +from importlib import resources from pathlib import Path @@ -13,14 +14,25 @@ def runtime_schema_dir() -> Path: return _SCHEMA_ROOT / "runtime" -def _repo_root() -> Path: - """Repository root (contains docs/schemas/).""" - return Path(__file__).resolve().parents[7] +def _repo_checkout_root() -> Path | None: + """Repository root (contains docs/schemas/), if running from a checkout.""" + candidate = Path(__file__).resolve().parents[7] + return candidate if (candidate / "docs" / "schemas").is_dir() else None + + +def _packaged_lab_schema_dir() -> Path: + # Falls back to the copy of docs/schemas/lab packaged inside + # mas.lab.schemas (see [tool.hatch.build.targets.wheel.force-include] in + # lab/components/core/pyproject.toml). + return Path(str(resources.files("mas.lab.schemas") / "_schemas")) def lab_schema_dir() -> Path: """YAML/JSON schemas for mas-lab manifests (experiment, pipeline, dataset, lab-config).""" - return _repo_root() / "docs" / "schemas" / "lab" + repo_root = _repo_checkout_root() + if repo_root is not None: + return repo_root / "docs" / "schemas" / "lab" + return _packaged_lab_schema_dir() def lab_artefact_schema_dir() -> Path: diff --git a/lab/pyproject.toml b/lab/pyproject.toml index 47280fd9..a33b3498 100644 --- a/lab/pyproject.toml +++ b/lab/pyproject.toml @@ -45,6 +45,14 @@ Issues = "https://github.com/outshift-open/mas-lab/issues" [project.scripts] mas-lab = "mas.lab.cli:app" +mas-ctl = "mas.ctl.cli.main:main" [tool.hatch.build.targets.wheel] packages = ["src/mas"] + +# Package a copy of the top-level examples/ tree inside mas.lab so +# 'mas-lab init' still works when installed as a wheel (e.g. via +# 'uv tool install'), where there is no repo checkout to read examples/ +# from. See mas.lab.cli.commands.init._templates_root(). +[tool.hatch.build.targets.wheel.force-include] +"../examples" = "src/mas/lab/_examples" diff --git a/lab/src/mas/lab/cli/commands/init.py b/lab/src/mas/lab/cli/commands/init.py index c8a69562..721e8c2e 100644 --- a/lab/src/mas/lab/cli/commands/init.py +++ b/lab/src/mas/lab/cli/commands/init.py @@ -5,6 +5,7 @@ from __future__ import annotations import re +from importlib import resources from pathlib import Path from typing import Any @@ -21,9 +22,20 @@ _DEFAULT_TARGET_MODEL = "gpt-4o-mini" -def _templates_root() -> Path: +def _repo_checkout_examples_root() -> Path | None: # lab/src/mas/lab/cli/commands/init.py -> repo root/examples - return Path(__file__).resolve().parents[6] / "examples" + candidate = Path(__file__).resolve().parents[6] / "examples" + return candidate if candidate.is_dir() else None + + +def _packaged_examples_root() -> Path: + # Falls back to the copy of examples/ packaged inside mas.lab (see + # [tool.hatch.build.targets.wheel.force-include] in lab/pyproject.toml). + return Path(str(resources.files("mas.lab") / "_examples")) + + +def _templates_root() -> Path: + return _repo_checkout_examples_root() or _packaged_examples_root() def _load_template(rel_path: str) -> str: diff --git a/library-standard/pyproject.toml b/library-standard/pyproject.toml index d30b6a5f..34a1672e 100644 --- a/library-standard/pyproject.toml +++ b/library-standard/pyproject.toml @@ -35,5 +35,8 @@ include = [ [tool.hatch.build.targets.wheel] packages = ["src/mas"] +[tool.hatch.build.targets.wheel.force-include] +"library.yaml" = "src/mas/library/standard/library.yaml" + [tool.uv.sources] mas-runtime = { workspace = true } diff --git a/uv.lock b/uv.lock index 5558df96..acee5e9a 100644 --- a/uv.lock +++ b/uv.lock @@ -3614,6 +3614,7 @@ dependencies = [ { name = "click" }, { name = "httpx" }, { name = "jsonschema" }, + { name = "mas-library-standard" }, { name = "mas-runtime" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -3635,6 +3636,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "jsonschema", specifier = ">=4.0" }, + { name = "mas-library-standard", editable = "library-standard" }, { name = "mas-runtime", editable = "runtime" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "python-dotenv", specifier = ">=1.0" }, @@ -5012,7 +5014,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -6438,7 +6440,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -6523,7 +6525,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -6574,8 +6576,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -6806,13 +6808,13 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'emscripten'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'emscripten'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, - { name = "packaging", marker = "sys_platform == 'emscripten'" }, - { name = "pandas", marker = "sys_platform == 'emscripten'" }, - { name = "patsy", marker = "sys_platform == 'emscripten'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'emscripten'" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/3b/963a015dd8ea17e10c7b0e2f14d7c4daec903baf60a017e756b57953a4bf/statsmodels-0.14.4.tar.gz", hash = "sha256:5d69e0f39060dc72c067f9bb6e8033b6dccdb0bae101d76a7ef0bcc94e898b67", size = 20354802, upload-time = "2024-10-03T16:15:36.273Z" } @@ -6833,13 +6835,13 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'emscripten'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform != 'emscripten'" }, - { name = "packaging", marker = "sys_platform != 'emscripten'" }, - { name = "pandas", marker = "sys_platform != 'emscripten'" }, - { name = "patsy", marker = "sys_platform != 'emscripten'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'emscripten'" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform != 'emscripten'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } wheels = [