diff --git a/docs/getting-started.md b/docs/getting-started.md index 33ca01b..b3048a3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -248,15 +248,27 @@ Example project manifest: [project] name = "geometry" version = "0.1.0" +requires-arx = ">=1.0" [environment] kind = "conda" name = "geometry" +[build-system] +dependencies = [ + "arxlang>=1.0", +] + [build] out_dir = "build" ``` +Use `[project].requires-arx` to declare the compatible Arx compiler versions for +a project. The value uses the same version-specifier style as `requires-python`, +for example `">=1.0,<2"`. `[build-system].dependencies` declares installable +packages needed to build the project. If omitted, Arx defaults to `arxlang`; if +`requires-arx` is present, the default build dependency uses that constraint. + Use `__init__.x` as the package root. Arx uses `src/` as the default source root when `[build].src_dir` is omitted. Inside a nested module such as `geometry.shapes.area`, use relative `from` imports for nearby modules and diff --git a/packages/arx/src/arx/schema/arxproject.json b/packages/arx/src/arx/schema/arxproject.json index 76a3c74..3042919 100644 --- a/packages/arx/src/arx/schema/arxproject.json +++ b/packages/arx/src/arx/schema/arxproject.json @@ -72,6 +72,10 @@ "type": "string", "minLength": 1 }, + "requires-arx": { + "type": "string", + "minLength": 1 + }, "edition": { "type": "string", "minLength": 1 @@ -147,15 +151,16 @@ } } }, - "toolchain": { + "build-system": { "type": "object", "additionalProperties": false, "properties": { - "compiler": { - "type": "string" - }, - "linker": { - "type": "string" + "dependencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } } }, diff --git a/packages/arx/src/arx/settings.py b/packages/arx/src/arx/settings.py index f8f0514..7a5af1b 100644 --- a/packages/arx/src/arx/settings.py +++ b/packages/arx/src/arx/settings.py @@ -15,6 +15,9 @@ from typing import Any, cast from jsonschema import ValidationError, validate +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.utils import canonicalize_name if sys.version_info >= (3, 11): import tomllib @@ -28,6 +31,74 @@ _DEPENDENCY_GROUP_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _DEPENDENCY_GROUP_NORMALIZE_PATTERN = re.compile(r"[-_.]+") _DEFAULT_SRC_DIR = "src" +_ARXLANG_DISTRIBUTION_NAME = "arxlang" + + +def _default_build_system_dependency(requires_arx: str | None) -> str: + """ + title: Build the default Arx compiler dependency requirement. + parameters: + requires_arx: + type: str | None + returns: + type: str + """ + if requires_arx is None: + return _ARXLANG_DISTRIBUTION_NAME + return f"{_ARXLANG_DISTRIBUTION_NAME}{requires_arx.strip()}" + + +def _requirement_name(value: str) -> str | None: + """ + title: Parse and return one requirement distribution name. + parameters: + value: + type: str + returns: + type: str | None + """ + try: + return Requirement(value).name + except InvalidRequirement: + return None + + +def _has_arxlang_dependency(dependencies: tuple[str, ...]) -> bool: + """ + title: Return whether dependencies already include ``arxlang``. + parameters: + dependencies: + type: tuple[str, Ellipsis] + returns: + type: bool + """ + arxlang_name = canonicalize_name(_ARXLANG_DISTRIBUTION_NAME) + for dependency in dependencies: + name = _requirement_name(dependency) + if name is None: + continue + if canonicalize_name(name) == arxlang_name: + return True + return False + + +def _normalize_build_system_dependencies( + dependencies: tuple[str, ...], + requires_arx: str | None, +) -> tuple[str, ...]: + """ + title: Ensure build-system dependencies include the Arx compiler package. + parameters: + dependencies: + type: tuple[str, Ellipsis] + requires_arx: + type: str | None + returns: + type: tuple[str, Ellipsis] + """ + if _has_arxlang_dependency(dependencies): + return dependencies + return (_default_build_system_dependency(requires_arx), *dependencies) class ArxProjectError(Exception): @@ -70,6 +141,8 @@ class Project: type: tuple[Author, Ellipsis] dependencies: type: tuple[str, Ellipsis] + requires_arx: + type: str | None """ name: str @@ -79,6 +152,7 @@ class Project: license: str | None = None authors: tuple[Author, ...] = () dependencies: tuple[str, ...] = () + requires_arx: str | None = None @dataclass(frozen=True) @@ -136,18 +210,15 @@ class Build: @dataclass(frozen=True) -class Toolchain: +class BuildSystem: """ - title: Parsed toolchain section of .arxproject.toml. + title: Parsed build-system section of .arxproject.toml. attributes: - compiler: - type: str | None - linker: - type: str | None + dependencies: + type: tuple[str, Ellipsis] """ - compiler: str | None = None - linker: str | None = None + dependencies: tuple[str, ...] = () @dataclass(frozen=True) @@ -212,8 +283,8 @@ class ArxProject: type: Environment | None build: type: Build | None - toolchain: - type: Toolchain | None + build_system: + type: BuildSystem dependency_groups: type: dict[str, tuple[DependencyGroupEntry, Ellipsis]] arxpm: @@ -227,7 +298,7 @@ class ArxProject: project: Project environment: Environment | None = None build: Build | None = None - toolchain: Toolchain | None = None + build_system: BuildSystem = field(default_factory=BuildSystem) dependency_groups: dict[str, tuple[DependencyGroupEntry, ...]] = field( default_factory=dict ) @@ -235,6 +306,20 @@ class ArxProject: tests: Tests | None = None source_path: Path | None = None + def __post_init__(self) -> None: + """ + title: Normalize effective build-system defaults. + """ + dependencies = _normalize_build_system_dependencies( + self.build_system.dependencies, + self.project.requires_arx, + ) + object.__setattr__( + self, + "build_system", + BuildSystem(dependencies=dependencies), + ) + @lru_cache(maxsize=1) def _schema() -> dict[str, Any]: @@ -276,6 +361,7 @@ def _build_project(data: dict[str, Any]) -> Project: return Project( name=data["name"], version=data["version"], + requires_arx=data.get("requires-arx"), edition=data.get("edition"), description=data.get("description"), license=data.get("license"), @@ -341,6 +427,36 @@ def _build_tests(data: dict[str, Any] | None) -> Tests | None: ) +def _build_build_system( + data: dict[str, Any] | None, + project: Project, +) -> BuildSystem: + """ + title: Build the BuildSystem dataclass with effective defaults. + parameters: + data: + type: dict[str, Any] | None + project: + type: Project + returns: + type: BuildSystem + """ + if data is None: + return BuildSystem( + dependencies=( + _default_build_system_dependency(project.requires_arx), + ) + ) + + dependencies = tuple(data.get("dependencies", ())) + return BuildSystem( + dependencies=_normalize_build_system_dependencies( + dependencies, + project.requires_arx, + ) + ) + + def _resolved_src_dir(build: Build | None) -> str: """ title: Resolve the effective source directory for one project. @@ -389,6 +505,22 @@ def _reject_arxpm_sections(data: dict[str, Any]) -> None: ) +def _reject_toolchain_sections(data: dict[str, Any]) -> None: + """ + title: Reject removed ``[toolchain]`` manifest sections. + parameters: + data: + type: dict[str, Any] + """ + if "toolchain" not in data: + return + raise ArxProjectError( + ".arxproject.toml does not support [toolchain] sections. " + "Declare compiler/build requirements in [build-system] using " + 'dependencies = ["arxlang..."].' + ) + + def _validate_dependency(value: str, location: str) -> None: """ title: Validate one dependency entry from ``.arxproject.toml``. @@ -413,10 +545,86 @@ def _validate_project(data: dict[str, Any]) -> None: data: type: dict[str, Any] """ + requires_arx = data.get("requires-arx") + if requires_arx is not None: + _validate_requires_arx(requires_arx) + for index, value in enumerate(data.get("dependencies", ())): _validate_dependency(value, f"project.dependencies[{index}]") +def _validate_build_system_dependency(value: str, location: str) -> None: + """ + title: Validate one installable build-system dependency requirement. + parameters: + value: + type: str + location: + type: str + """ + try: + Requirement(value) + except InvalidRequirement as err: + raise ArxProjectError( + f".arxproject.toml {location} must be a valid dependency " + 'requirement like "arxlang>=1.0,<2".' + ) from err + + +def _validate_build_system( + data: dict[str, Any] | None, + project: dict[str, Any], +) -> None: + """ + title: Validate build-system-only settings after schema validation. + parameters: + data: + type: dict[str, Any] | None + project: + type: dict[str, Any] + """ + raw_dependencies: tuple[str, ...] = () + if data is not None: + raw_dependencies = tuple(cast(list[str], data.get("dependencies", ()))) + + for index, value in enumerate(raw_dependencies): + _validate_build_system_dependency( + value, + f"build-system.dependencies[{index}]", + ) + + if _has_arxlang_dependency(raw_dependencies): + return + + _validate_build_system_dependency( + _default_build_system_dependency( + cast(str | None, project.get("requires-arx")) + ), + "build-system.dependencies default arxlang dependency", + ) + + +def _validate_requires_arx(value: str) -> None: + """ + title: Validate a ``project.requires-arx`` version specifier. + parameters: + value: + type: str + """ + if not value.strip(): + raise ArxProjectError( + ".arxproject.toml project.requires-arx must not be empty." + ) + + try: + SpecifierSet(value) + except InvalidSpecifier as err: + raise ArxProjectError( + ".arxproject.toml project.requires-arx must be a valid " + 'version specifier like ">=1.0,<2".' + ) from err + + def _validate_dependency_group_name(name: str, location: str) -> None: """ title: Validate one dependency-group name. @@ -676,6 +884,7 @@ def _validate_data(data: dict[str, Any]) -> None: type: dict[str, Any] """ _reject_arxpm_sections(data) + _reject_toolchain_sections(data) _reject_legacy_environment_kind(data.get("environment")) try: @@ -686,6 +895,7 @@ def _validate_data(data: dict[str, Any]) -> None: ) from err _validate_project(data["project"]) + _validate_build_system(data.get("build-system"), data["project"]) _validate_dependency_groups(data) _validate_environment(data.get("environment")) @@ -706,18 +916,16 @@ def _build_arx_project( """ environment_data = data.get("environment") build_data = data.get("build") - toolchain_data = data.get("toolchain") + project = _build_project(data["project"]) return ArxProject( - project=_build_project(data["project"]), + project=project, environment=( Environment(**environment_data) if environment_data is not None else None ), build=Build(**build_data) if build_data is not None else None, - toolchain=( - Toolchain(**toolchain_data) if toolchain_data is not None else None - ), + build_system=_build_build_system(data.get("build-system"), project), dependency_groups=_build_dependency_groups( data.get("dependency-groups") ), @@ -760,6 +968,8 @@ def _settings_to_data(settings: ArxProject) -> dict[str, Any]: "name": settings.project.name, "version": settings.project.version, } + if settings.project.requires_arx is not None: + project["requires-arx"] = settings.project.requires_arx if settings.project.edition is not None: project["edition"] = settings.project.edition if settings.project.description is not None: @@ -775,6 +985,15 @@ def _settings_to_data(settings: ArxProject) -> dict[str, Any]: data: dict[str, Any] = {"project": project} + default_build_system_dependencies = _normalize_build_system_dependencies( + (), + settings.project.requires_arx, + ) + if settings.build_system.dependencies != default_build_system_dependencies: + data["build-system"] = { + "dependencies": list(settings.build_system.dependencies) + } + if settings.dependency_groups: dependency_groups: dict[str, list[str | dict[str, str]]] = {} for group_name, entries in settings.dependency_groups.items(): @@ -825,14 +1044,6 @@ def _settings_to_data(settings: ArxProject) -> dict[str, Any]: build["mode"] = settings.build.mode data["build"] = build - if settings.toolchain is not None: - toolchain: dict[str, Any] = {} - if settings.toolchain.compiler is not None: - toolchain["compiler"] = settings.toolchain.compiler - if settings.toolchain.linker is not None: - toolchain["linker"] = settings.toolchain.linker - data["toolchain"] = toolchain - if settings.tests is not None: tests: dict[str, Any] = {} if settings.tests.paths is not None: @@ -921,6 +1132,10 @@ def _append_project(lines: list[str], project: Project) -> None: lines.append("[project]") lines.append(f"name = {_format_toml_string(project.name)}") lines.append(f"version = {_format_toml_string(project.version)}") + if project.requires_arx is not None: + lines.append( + f"requires-arx = {_format_toml_string(project.requires_arx)}" + ) if project.edition is not None: lines.append(f"edition = {_format_toml_string(project.edition)}") if project.description is not None: @@ -1006,25 +1221,29 @@ def _append_build(lines: list[str], build: Build | None) -> None: lines.append(f"mode = {_format_toml_string(build.mode)}") -def _append_toolchain( +def _append_build_system( lines: list[str], - toolchain: Toolchain | None, + build_system: BuildSystem, + project: Project, ) -> None: """ - title: Append the canonical ``[toolchain]`` section when present. + title: Append the canonical ``[build-system]`` section when needed. parameters: lines: type: list[str] - toolchain: - type: Toolchain | None + build_system: + type: BuildSystem + project: + type: Project """ - if toolchain is None: + default_dependencies = _normalize_build_system_dependencies( + (), + project.requires_arx, + ) + if build_system.dependencies == default_dependencies: return - lines.extend(("", "[toolchain]")) - if toolchain.compiler is not None: - lines.append(f"compiler = {_format_toml_string(toolchain.compiler)}") - if toolchain.linker is not None: - lines.append(f"linker = {_format_toml_string(toolchain.linker)}") + lines.extend(("", "[build-system]")) + _append_string_array(lines, "dependencies", build_system.dependencies) def _append_tests(lines: list[str], tests: Tests | None) -> None: @@ -1066,10 +1285,10 @@ def dump_settings(settings: ArxProject) -> str: lines: list[str] = [] _append_project(lines, settings.project) + _append_build_system(lines, settings.build_system, settings.project) _append_dependency_groups(lines, settings.dependency_groups) _append_environment(lines, settings.environment) _append_build(lines, settings.build) - _append_toolchain(lines, settings.toolchain) _append_tests(lines, settings.tests) return "\n".join(lines) + "\n" diff --git a/packages/arx/tests/python/test_settings.py b/packages/arx/tests/python/test_settings.py index 72d1ea5..8acc857 100644 --- a/packages/arx/tests/python/test_settings.py +++ b/packages/arx/tests/python/test_settings.py @@ -15,10 +15,10 @@ ArxProjectError, Author, Build, + BuildSystem, DependencyGroupInclude, Environment, Project, - Toolchain, dump_settings, find_config_file, load_settings, @@ -34,6 +34,7 @@ [project] name = "sciarx" version = "0.1.0" + requires-arx = ">=1.0" edition = "2026" dependencies = [ "http", @@ -53,9 +54,11 @@ out_dir = "build" mode = "lib" - [toolchain] - compiler = "arx" - linker = "clang" + [build-system] + dependencies = [ + "arxlang >=1.0", + "arx-build-helper >=0.2,<1", + ] [tests] paths = ["tests"] @@ -85,6 +88,7 @@ def test_load_settings_from_text_full_example() -> None: assert isinstance(settings, ArxProject) assert settings.project.name == "sciarx" assert settings.project.version == "0.1.0" + assert settings.project.requires_arx == ">=1.0" assert settings.project.edition == "2026" assert settings.project.dependencies == ( "http", @@ -104,9 +108,10 @@ def test_load_settings_from_text_full_example() -> None: assert settings.build.out_dir == "build" assert settings.build.mode == "lib" - assert settings.toolchain is not None - assert settings.toolchain.compiler == "arx" - assert settings.toolchain.linker == "clang" + assert settings.build_system.dependencies == ( + "arxlang >=1.0", + "arx-build-helper >=0.2,<1", + ) assert settings.tests is not None assert settings.tests.paths == ("tests",) @@ -122,10 +127,11 @@ def test_load_settings_from_text_minimal() -> None: settings = load_settings_from_text(_project_toml()) assert settings.project.name == "demo" + assert settings.project.requires_arx is None assert settings.project.dependencies == () assert settings.environment is None assert settings.build is None - assert settings.toolchain is None + assert settings.build_system == BuildSystem(dependencies=("arxlang",)) assert settings.dependency_groups == {} assert settings.arxpm is None assert settings.project.authors == () @@ -141,6 +147,133 @@ def test_load_settings_rejects_removed_build_entry_key() -> None: load_settings_from_text(content) +def test_project_requires_arx_supports_version_specifier() -> None: + """ + title: Parse the optional ``project.requires-arx`` version requirement. + """ + content = _project_toml('requires-arx = ">=1.0,<2"\n') + + settings = load_settings_from_text(content) + + assert settings.project.requires_arx == ">=1.0,<2" + + +@pytest.mark.parametrize("specifier", [" ", "1.0", "=>1.0"]) +def test_project_requires_arx_rejects_invalid_specifier( + specifier: str, +) -> None: + """ + title: Reject invalid ``project.requires-arx`` version requirements. + parameters: + specifier: + type: str + """ + content = _project_toml(f'requires-arx = "{specifier}"\n') + + with pytest.raises(ArxProjectError, match="requires-arx"): + load_settings_from_text(content) + + +def test_build_system_uses_requires_arx_default() -> None: + """ + title: Derive the default build compiler dependency from requires-arx. + """ + content = _project_toml('requires-arx = ">=1.0,<2"\n') + + settings = load_settings_from_text(content) + + assert settings.build_system.dependencies == ("arxlang>=1.0,<2",) + + +def test_build_system_dependencies_auto_include_arxlang() -> None: + """ + title: Build-system dependencies always include the Arx compiler package. + """ + content = _project_toml( + dedent( + """ + requires-arx = ">=1.0,<2" + + [build-system] + dependencies = ["arx-build-helper >=0.2"] + """ + ) + ) + + settings = load_settings_from_text(content) + + assert settings.build_system.dependencies == ( + "arxlang>=1.0,<2", + "arx-build-helper >=0.2", + ) + + +def test_build_system_dependencies_preserve_explicit_arxlang() -> None: + """ + title: Manual arxlang build dependencies are not duplicated. + """ + content = _project_toml( + dedent( + """ + + [build-system] + dependencies = [ + "arx-build-helper >=0.2", + "arxlang >=1.0", + ] + """ + ) + ) + + settings = load_settings_from_text(content) + + assert settings.build_system.dependencies == ( + "arx-build-helper >=0.2", + "arxlang >=1.0", + ) + + +def test_build_system_explicit_arxlang_overrides_requires_arx() -> None: + """ + title: Explicit arxlang build dependency is preserved with requires-arx. + """ + content = _project_toml( + dedent( + """ + requires-arx = ">=1.0,<2" + + [build-system] + dependencies = [ + "arxlang >=0.9", + "arx-build-helper >=0.2", + ] + """ + ) + ) + + settings = load_settings_from_text(content) + + assert settings.build_system.dependencies == ( + "arxlang >=0.9", + "arx-build-helper >=0.2", + ) + + +def test_build_system_dependencies_reject_invalid_requirement() -> None: + """ + title: Reject invalid installable build-system dependency requirements. + """ + content = _project_toml( + '\n[build-system]\ndependencies = ["not a requirement"]\n' + ) + + with pytest.raises( + ArxProjectError, + match=r"build-system\.dependencies\[0\]", + ): + load_settings_from_text(content) + + @pytest.mark.parametrize( "dependency", [ @@ -572,6 +705,19 @@ def test_rejects_legacy_arxpm_dependency_tables() -> None: load_settings_from_text(content) +def test_rejects_removed_toolchain_table() -> None: + """ + title: Top-level ``[toolchain]`` is no longer part of the manifest schema. + """ + content = _project_toml('\n[toolchain]\nlinker = "clang"\n') + + with pytest.raises( + ArxProjectError, + match=r"does not support \[toolchain\]", + ): + load_settings_from_text(content) + + def test_dump_and_write_settings_round_trip(tmp_path: Path) -> None: """ title: Serialize and reload the canonical manifest structure. @@ -584,6 +730,7 @@ def test_dump_and_write_settings_round_trip(tmp_path: Path) -> None: project=Project( name="demo", version="0.1.0", + requires_arx=">=1.0", description="demo project", authors=( Author( @@ -604,7 +751,12 @@ def test_dump_and_write_settings_round_trip(tmp_path: Path) -> None: out_dir="build", mode="app", ), - toolchain=Toolchain(compiler="arx", linker="clang"), + build_system=BuildSystem( + dependencies=( + "arxlang >=1.0", + "arx-build-helper >=0.2,<1", + ) + ), dependency_groups={ "lint": ("ruff", "mypy"), "test": ("pytest", "coverage"), @@ -623,6 +775,10 @@ def test_dump_and_write_settings_round_trip(tmp_path: Path) -> None: rendered = dump_settings(settings) assert "[arxpm" not in rendered + assert 'requires-arx = ">=1.0"' in rendered + assert "[build-system]" in rendered + assert '"arxlang >=1.0",' in rendered + assert '"arx-build-helper >=0.2,<1",' in rendered assert "dependencies = [" in rendered assert "[dependency-groups]" in rendered assert '{ include-group = "lint" },' in rendered @@ -638,7 +794,7 @@ def test_dump_and_write_settings_round_trip(tmp_path: Path) -> None: project=settings.project, environment=settings.environment, build=settings.build, - toolchain=settings.toolchain, + build_system=settings.build_system, dependency_groups=settings.dependency_groups, tests=settings.tests, source_path=path,