diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 12048c4..7037958 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -30,7 +30,7 @@ jobs: pr_sha: ${{ github.event.pull_request.head.sha }} package-tests: - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -79,7 +79,7 @@ jobs: run: makim ${{ matrix.project }}.unittests arx-language-tests: - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -131,7 +131,7 @@ jobs: linter: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 defaults: run: diff --git a/docs/library/modules.md b/docs/library/modules.md index 5038628..7dbaaef 100644 --- a/docs/library/modules.md +++ b/docs/library/modules.md @@ -89,6 +89,66 @@ Use absolute dotted paths for public imports across package boundaries: import circle_area from geometry.shapes.area ``` +## Installed Package Imports + +Arx can also resolve absolute imports from installed Arx source packages +declared in `[project].dependencies` in `.arxproject.toml`. + +For example, if the current project declares an installed dependency that +exposes a package root named `local_lib`, this import resolves from that +installed package when no local project source shadows it: + +```arx +import sum2 from local_lib.stats +``` + +Installed package lookup is scoped to the current Python environment running +`arx`. The compiler reads installed distribution metadata, follows transitive +dependency metadata, and uses packaged `.arxproject.toml` files to find Arx +source roots. It does not install packages, access the network, or change +generated IR/codegen behavior. + +For installed packages with the normal project layout, place `.arxproject.toml` +at the packaged project root and declare both `[build].src_dir` and +`[build].package`: + +```text +site-packages/local_lib_project/ +├── .arxproject.toml +└── src + └── local_lib + ├── __init__.x + └── stats.x +``` + +```toml +[build] +src_dir = "src" +package = "local_lib" +``` + +With that layout, `local_lib.stats` resolves to +`site-packages/local_lib_project/src/local_lib/stats.x`. + +Flat package layouts are also supported for packages that place +`.arxproject.toml` and `.x` files directly in the package directory: + +```text +site-packages/local_lib/ +├── .arxproject.toml +├── __init__.x +└── stats.x +``` + +Resolution precedence is: + +1. local/current project source files +2. installed Arx dependency packages +3. unresolved import error + +The reserved `stdlib` and internal `builtins` namespaces always remain owned by +the compiler and cannot be replaced by installed packages. + ## Bundled `stdlib` Arx ships a first-party standard library namespace called `stdlib`. diff --git a/packages/arx/pyproject.toml b/packages/arx/pyproject.toml index 9bf9a54..63d1cf5 100644 --- a/packages/arx/pyproject.toml +++ b/packages/arx/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "astx == 1.21.0", # semantic-release "pyirx == 1.21.0", # semantic-release "jsonschema (>=4.0.0)", + "packaging >=23", "types-pyyaml (>=6.0.12.20250516)", "tomli >=2.0.0 ; python_version < \"3.11\"" ] diff --git a/packages/arx/src/arx/main.py b/packages/arx/src/arx/main.py index 9c0a939..6ff6788 100644 --- a/packages/arx/src/arx/main.py +++ b/packages/arx/src/arx/main.py @@ -15,6 +15,7 @@ from irx.analysis.module_interfaces import ParsedModule from arx import builtins as arx_builtins +from arx import package_index from arx import settings as arx_settings from arx.codegen import ArxBuilder from arx.io import ArxIO @@ -260,11 +261,16 @@ class FileImportResolver: type: dict[str, ParsedModule] _source_root_cache: type: dict[Path, Path | None] + _installed_package_index: + type: package_index.InstalledArxPackageIndex | None """ input_files: tuple[str, ...] cache: dict[str, ParsedModule] = field(default_factory=dict) _source_root_cache: dict[Path, Path | None] = field(default_factory=dict) + _installed_package_index: package_index.InstalledArxPackageIndex | None = ( + None + ) def _project_source_root(self, directory: Path) -> Path | None: """ @@ -404,8 +410,114 @@ def _resolve_module_file(self, requested_specifier: str) -> Path: return init_path if has_file: return file_path + installed_path = self._resolve_installed_module_file( + requested_specifier + ) + if installed_path is not None: + return installed_path raise LookupError(requested_specifier) + def _installed_package_start(self) -> Path: + """ + title: Return the manifest search start for installed dependencies. + returns: + type: Path + """ + if not self.input_files: + return Path.cwd() + + input_root = Path(self.input_files[0]).resolve().parent + if arx_settings.find_config_file(start=input_root) is not None: + return input_root + return Path.cwd() + + def _installed_packages( + self, + ) -> package_index.InstalledArxPackageIndex: + """ + title: Lazily discover installed Arx package dependencies. + returns: + type: package_index.InstalledArxPackageIndex + """ + if self._installed_package_index is None: + self._installed_package_index = ( + package_index.discover_installed_arx_packages( + start=self._installed_package_start() + ) + ) + return self._installed_package_index + + def _resolve_installed_module_file( + self, + requested_specifier: str, + ) -> Path | None: + """ + title: Resolve one module specifier from installed Arx packages. + parameters: + requested_specifier: + type: str + returns: + type: Path | None + """ + specifier_parts = tuple( + part for part in requested_specifier.split(".") if part + ) + if not specifier_parts: + return None + + index = self._installed_packages() + package_name = specifier_parts[0] + conflicts = index.conflicts.get(package_name) + if conflicts is not None: + locations = ", ".join( + f"{package.distribution_name} at {package.source_root}" + for package in conflicts + ) + raise LookupError( + "ambiguous installed Arx package module " + f"'{package_name}': provided by {locations}" + ) + + package = index.packages.get(package_name) + if package is None: + missing_distribution = index.missing_distribution_for_module( + package_name + ) + if missing_distribution is None: + return None + raise LookupError( + "declared Arx dependency " + f"'{missing_distribution}' is not installed in the " + "current Python environment" + ) + + if len(specifier_parts) == 1: + init_path = (package.source_root / "__init__.x").resolve() + if init_path.is_file(): + return init_path + return None + + relative_path = Path(*specifier_parts[1:]) + file_path = ( + (package.source_root / relative_path).with_suffix(".x").resolve() + ) + init_path = ( + package.source_root / relative_path / "__init__.x" + ).resolve() + has_init = init_path.is_file() + has_file = file_path.is_file() + if has_init and has_file: + raise LookupError( + "ambiguous module specifier " + f"'{requested_specifier}': both " + f"'{file_path}' and '{init_path}' exist" + ) + if has_init: + return init_path + if has_file: + return file_path + return None + def _shadowing_reserved_path( self, requested_specifier: str, diff --git a/packages/arx/src/arx/package_index.py b/packages/arx/src/arx/package_index.py new file mode 100644 index 0000000..792cc91 --- /dev/null +++ b/packages/arx/src/arx/package_index.py @@ -0,0 +1,482 @@ +""" +title: Discover installed Arx source packages. +summary: >- + Build a scoped index of Arx packages installed as Python distributions. +""" + +from __future__ import annotations + +import re +import sys + +from collections.abc import Iterable +from dataclasses import dataclass, field +from importlib import metadata as importlib_metadata +from pathlib import Path +from typing import Any + +from packaging.requirements import InvalidRequirement, Requirement + +from arx import builtins as arx_builtins +from arx import settings as arx_settings + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover + import tomli as tomllib + +_ARX_MODULE_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_DEPENDENCY_NAME_PATTERN = re.compile( + r"^\s*(?P[A-Za-z0-9][A-Za-z0-9._-]*)" +) +_DISTRIBUTION_NORMALIZE_PATTERN = re.compile(r"[-_.]+") +_RESERVED_MODULE_NAMES = frozenset( + { + "stdlib", + arx_builtins.BUILTIN_NAMESPACE, + } +) +_SOURCE_SUFFIXES = frozenset({".x"}) + + +@dataclass(frozen=True) +class InstalledArxPackage: + """ + title: One installed Arx source package. + attributes: + module_name: + type: str + source_root: + type: Path + distribution_name: + type: str + """ + + module_name: str + source_root: Path + distribution_name: str + + +@dataclass(frozen=True) +class InstalledArxPackageIndex: + """ + title: Indexed installed Arx source packages. + attributes: + packages: + type: dict[str, InstalledArxPackage] + missing_distributions: + type: frozenset[str] + conflicts: + type: dict[str, tuple[InstalledArxPackage, Ellipsis]] + """ + + packages: dict[str, InstalledArxPackage] = field(default_factory=dict) + missing_distributions: frozenset[str] = frozenset() + conflicts: dict[str, tuple[InstalledArxPackage, ...]] = field( + default_factory=dict + ) + + def missing_distribution_for_module(self, module_name: str) -> str | None: + """ + title: Return a missing distribution matching one import head. + parameters: + module_name: + type: str + returns: + type: str | None + """ + normalized = normalize_distribution_name(module_name) + for distribution_name in self.missing_distributions: + if normalize_distribution_name(distribution_name) == normalized: + return distribution_name + return None + + +def normalize_distribution_name(name: str) -> str: + """ + title: Normalize a Python distribution name for comparisons. + parameters: + name: + type: str + returns: + type: str + """ + return _DISTRIBUTION_NORMALIZE_PATTERN.sub("-", name).lower() + + +def extract_dependency_name(dependency: str) -> str | None: + """ + title: Extract the distribution name from one dependency string. + parameters: + dependency: + type: str + returns: + type: str | None + """ + try: + return Requirement(dependency).name + except InvalidRequirement: + pass + + match = _DEPENDENCY_NAME_PATTERN.match(dependency) + if match is None: + return None + return match.group("name") + + +def active_requirement_name(requirement_text: str) -> str | None: + """ + title: Return the distribution name for an active metadata requirement. + parameters: + requirement_text: + type: str + returns: + type: str | None + """ + try: + requirement = Requirement(requirement_text) + except InvalidRequirement: + return None + + if requirement.marker is not None and not requirement.marker.evaluate(): + return None + return requirement.name + + +def discover_installed_arx_packages( + start: Path | None = None, +) -> InstalledArxPackageIndex: + """ + title: Discover installed Arx packages from project dependencies. + parameters: + start: + type: Path | None + returns: + type: InstalledArxPackageIndex + """ + config = arx_settings.find_config_file(start=start) + if config is None: + return InstalledArxPackageIndex() + + try: + settings = arx_settings.load_settings(config) + except arx_settings.ArxProjectError: + return InstalledArxPackageIndex() + + return discover_installed_arx_packages_from_dependencies( + settings.project.dependencies + ) + + +def discover_installed_arx_packages_from_dependencies( + dependencies: Iterable[str], +) -> InstalledArxPackageIndex: + """ + title: Discover installed Arx packages from dependency strings. + parameters: + dependencies: + type: Iterable[str] + returns: + type: InstalledArxPackageIndex + """ + package_entries: dict[str, InstalledArxPackage] = {} + conflict_entries: dict[str, tuple[InstalledArxPackage, ...]] = {} + missing_distributions: set[str] = set() + pending = [ + dependency_name + for dependency in dependencies + if (dependency_name := extract_dependency_name(dependency)) is not None + ] + visited: set[str] = set() + + while pending: + dependency_name = pending.pop(0) + normalized_name = normalize_distribution_name(dependency_name) + if normalized_name in visited: + continue + visited.add(normalized_name) + + try: + distribution = importlib_metadata.distribution(dependency_name) + except importlib_metadata.PackageNotFoundError: + missing_distributions.add(dependency_name) + continue + + for package in _arx_packages_from_distribution(distribution): + _add_package(package_entries, conflict_entries, package) + + for requirement in distribution.requires or (): + requirement_name = active_requirement_name(requirement) + if requirement_name is None: + continue + if normalize_distribution_name(requirement_name) in visited: + continue + pending.append(requirement_name) + + return InstalledArxPackageIndex( + packages=package_entries, + missing_distributions=frozenset(missing_distributions), + conflicts=conflict_entries, + ) + + +def _add_package( + packages: dict[str, InstalledArxPackage], + conflicts: dict[str, tuple[InstalledArxPackage, ...]], + package: InstalledArxPackage, +) -> None: + """ + title: Add one package to the mutable package index. + parameters: + packages: + type: dict[str, InstalledArxPackage] + conflicts: + type: dict[str, tuple[InstalledArxPackage, Ellipsis]] + package: + type: InstalledArxPackage + """ + conflict = conflicts.get(package.module_name) + if conflict is not None: + conflicts[package.module_name] = (*conflict, package) + return + + existing = packages.get(package.module_name) + if existing is None: + packages[package.module_name] = package + return + + del packages[package.module_name] + conflicts[package.module_name] = (existing, package) + + +def _arx_packages_from_distribution( + distribution: importlib_metadata.Distribution, +) -> tuple[InstalledArxPackage, ...]: + """ + title: Extract Arx package roots from one installed distribution. + parameters: + distribution: + type: importlib_metadata.Distribution + returns: + type: tuple[InstalledArxPackage, Ellipsis] + """ + files = distribution.files + if files is None: + return () + + packages: list[InstalledArxPackage] = [] + for distribution_file in files: + if distribution_file.name != arx_settings.DEFAULT_CONFIG_FILENAME: + continue + + manifest_path = Path( + str(distribution.locate_file(distribution_file)) + ).resolve() + if not manifest_path.is_file(): + continue + + package = _package_from_manifest( + _distribution_name(distribution), + manifest_path, + ) + if package is None: + continue + + packages.append(package) + + return tuple(packages) + + +def _distribution_name( + distribution: importlib_metadata.Distribution, +) -> str: + """ + title: Return the canonical display name for one distribution. + parameters: + distribution: + type: importlib_metadata.Distribution + returns: + type: str + """ + try: + return str(distribution.metadata["Name"]) + except KeyError: + return str(distribution) + + +def _has_arx_sources(source_root: Path) -> bool: + """ + title: Return whether one package root contains Arx source files. + parameters: + source_root: + type: Path + returns: + type: bool + """ + for source_path in source_root.rglob("*"): + if source_path.suffix in _SOURCE_SUFFIXES and source_path.is_file(): + return True + return False + + +def _package_from_manifest( + distribution_name: str, + manifest_path: Path, +) -> InstalledArxPackage | None: + """ + title: Build one installed Arx package entry from a manifest. + parameters: + distribution_name: + type: str + manifest_path: + type: Path + returns: + type: InstalledArxPackage | None + """ + data = _load_manifest_data(manifest_path) + if data is None: + return None + + module_name = _manifest_package_name(data) + source_root = _manifest_source_root( + data, + manifest_path.parent, + module_name, + ) + if source_root is None: + return None + + if module_name is None: + module_name = source_root.name + + if ( + _ARX_MODULE_NAME_PATTERN.fullmatch(module_name) is None + or module_name in _RESERVED_MODULE_NAMES + ): + return None + return InstalledArxPackage( + module_name=module_name, + source_root=source_root, + distribution_name=distribution_name, + ) + + +def _load_manifest_data(manifest_path: Path) -> dict[str, Any] | None: + """ + title: Load a packaged manifest without full project validation. + parameters: + manifest_path: + type: Path + returns: + type: dict[str, Any] | None + """ + try: + data = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return None + + if not isinstance(data, dict): + return None + return data + + +def _manifest_package_name(data: dict[str, Any]) -> str | None: + """ + title: Extract an optional ``[build].package`` value. + parameters: + data: + type: dict[str, Any] + returns: + type: str | None + """ + build = data.get("build") + if not isinstance(build, dict): + return None + + package_name = build.get("package") + if not isinstance(package_name, str): + return None + return package_name + + +def _manifest_src_dir(data: dict[str, Any]) -> str | None: + """ + title: Extract an optional explicit ``[build].src_dir`` value. + parameters: + data: + type: dict[str, Any] + returns: + type: str | None + """ + build = data.get("build") + if not isinstance(build, dict): + return None + + src_dir = build.get("src_dir") + if not isinstance(src_dir, str): + return None + return src_dir + + +def _manifest_source_root( + data: dict[str, Any], + manifest_parent: Path, + module_name: str | None, +) -> Path | None: + """ + title: Resolve the installed source root for one manifest. + parameters: + data: + type: dict[str, Any] + manifest_parent: + type: Path + module_name: + type: str | None + returns: + type: Path | None + """ + candidates = _source_root_candidates( + manifest_parent, + _manifest_src_dir(data), + module_name, + ) + for candidate in candidates: + source_root = candidate.resolve() + if _has_arx_sources(source_root): + return source_root + return None + + +def _source_root_candidates( + manifest_parent: Path, + src_dir: str | None, + module_name: str | None, +) -> tuple[Path, ...]: + """ + title: Build candidate installed source roots in precedence order. + parameters: + manifest_parent: + type: Path + src_dir: + type: str | None + module_name: + type: str | None + returns: + type: tuple[Path, Ellipsis] + """ + if src_dir is not None: + source_root = manifest_parent / src_dir + if module_name is not None: + return (source_root / module_name,) + return (source_root,) + + candidates: list[Path] = [] + if module_name is not None: + candidates.extend( + ( + manifest_parent / "src" / module_name, + manifest_parent / module_name, + ) + ) + candidates.append(manifest_parent) + return tuple(candidates) diff --git a/packages/arx/tests/python/test_app_paths.py b/packages/arx/tests/python/test_app_paths.py index 636f1d0..0d9fed0 100644 --- a/packages/arx/tests/python/test_app_paths.py +++ b/packages/arx/tests/python/test_app_paths.py @@ -1808,6 +1808,201 @@ def test_arxmain_run_shell_not_implemented() -> None: app.run_shell() +def _write_resolver_project( + project_root: Path, + name: str, + dependencies: tuple[str, ...] = (), +) -> None: + """ + title: Write a minimal project manifest for resolver tests. + parameters: + project_root: + type: Path + name: + type: str + dependencies: + type: tuple[str, Ellipsis] + """ + dependency_lines = "" + if dependencies: + dependency_values = ", ".join( + f'"{dependency}"' for dependency in dependencies + ) + dependency_lines = f"dependencies = [{dependency_values}]\n" + + (project_root / ".arxproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n' + f'{dependency_lines}[build]\nsrc_dir = "src"\n\n', + encoding="utf-8", + ) + + +def _write_installed_arx_distribution( + site_packages: Path, + distribution_name: str, + module_name: str, + files: dict[str, str], + requires: tuple[str, ...] = (), +) -> Path: + """ + title: Write a minimal installed Arx distribution fixture. + parameters: + site_packages: + type: Path + distribution_name: + type: str + module_name: + type: str + files: + type: dict[str, str] + requires: + type: tuple[str, Ellipsis] + returns: + type: Path + """ + package_dir = site_packages / module_name + package_dir.mkdir(parents=True) + manifest_path = package_dir / ".arxproject.toml" + manifest_path.write_text( + f'[project]\nname = "{distribution_name}"\nversion = "0.1.0"\n' + f'[build]\npackage = "{module_name}"\n\n', + encoding="utf-8", + ) + + record_paths = [manifest_path.relative_to(site_packages)] + for relative_name, content in files.items(): + file_path = package_dir / relative_name + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + record_paths.append(file_path.relative_to(site_packages)) + + dist_info_name = distribution_name.replace("-", "_") + dist_info = site_packages / f"{dist_info_name}-0.1.dist-info" + dist_info.mkdir() + metadata_path = dist_info / "METADATA" + requires_lines = "".join( + f"Requires-Dist: {requirement}\n" for requirement in requires + ) + metadata_path.write_text( + "Metadata-Version: 2.1\n" + f"Name: {distribution_name}\n" + "Version: 0.1.0\n" + f"{requires_lines}", + encoding="utf-8", + ) + record_path = dist_info / "RECORD" + record_paths.extend( + ( + metadata_path.relative_to(site_packages), + record_path.relative_to(site_packages), + ) + ) + record_path.write_text( + "".join(f"{path.as_posix()},,\n" for path in record_paths), + encoding="utf-8", + ) + return package_dir + + +def _write_installed_arx_src_distribution( + site_packages: Path, + distribution_name: str, + project_dir_name: str, + module_name: str, + files: dict[str, str], + requires: tuple[str, ...] = (), +) -> Path: + """ + title: Write an installed Arx distribution with a src package layout. + parameters: + site_packages: + type: Path + distribution_name: + type: str + project_dir_name: + type: str + module_name: + type: str + files: + type: dict[str, str] + requires: + type: tuple[str, Ellipsis] + returns: + type: Path + """ + project_dir = site_packages / project_dir_name + package_dir = project_dir / "src" / module_name + package_dir.mkdir(parents=True) + manifest_path = project_dir / ".arxproject.toml" + manifest_path.write_text( + f'[project]\nname = "{distribution_name}"\nversion = "0.1.0"\n' + f'[build]\nsrc_dir = "src"\npackage = "{module_name}"\n\n', + encoding="utf-8", + ) + + record_paths = [manifest_path.relative_to(site_packages)] + for relative_name, content in files.items(): + file_path = package_dir / relative_name + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + record_paths.append(file_path.relative_to(site_packages)) + + dist_info_name = distribution_name.replace("-", "_") + dist_info = site_packages / f"{dist_info_name}-0.1.dist-info" + dist_info.mkdir() + metadata_path = dist_info / "METADATA" + requires_lines = "".join( + f"Requires-Dist: {requirement}\n" for requirement in requires + ) + metadata_path.write_text( + "Metadata-Version: 2.1\n" + f"Name: {distribution_name}\n" + "Version: 0.1.0\n" + f"{requires_lines}", + encoding="utf-8", + ) + record_path = dist_info / "RECORD" + record_paths.extend( + ( + metadata_path.relative_to(site_packages), + record_path.relative_to(site_packages), + ) + ) + record_path.write_text( + "".join(f"{path.as_posix()},,\n" for path in record_paths), + encoding="utf-8", + ) + return package_dir + + +def _write_python_only_distribution( + site_packages: Path, + distribution_name: str, +) -> None: + """ + title: Write a minimal non-Arx installed distribution fixture. + parameters: + site_packages: + type: Path + distribution_name: + type: str + """ + dist_info_name = distribution_name.replace("-", "_") + dist_info = site_packages / f"{dist_info_name}-0.1.dist-info" + dist_info.mkdir(parents=True) + metadata_path = dist_info / "METADATA" + metadata_path.write_text( + f"Metadata-Version: 2.1\nName: {distribution_name}\nVersion: 0.1.0\n", + encoding="utf-8", + ) + record_path = dist_info / "RECORD" + record_path.write_text( + f"{metadata_path.relative_to(site_packages).as_posix()},,\n" + f"{record_path.relative_to(site_packages).as_posix()},,\n", + encoding="utf-8", + ) + + def test_file_import_resolver_rejects_ambiguous_package_and_module_paths( tmp_path: Path, ) -> None: @@ -1888,6 +2083,414 @@ def test_file_import_resolver_honors_build_src_dir(tmp_path: Path) -> None: assert resolved == (src_dir / "mypkg" / "__init__.x").resolve() +def test_file_import_resolver_resolves_direct_installed_arx_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: FileImportResolver resolves a direct installed Arx dependency. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import sum2 from local_lib.stats\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-local-lib",), + ) + + site_packages = tmp_path / "site-packages" + package_dir = _write_installed_arx_distribution( + site_packages, + "arx-test-local-lib", + "local_lib", + { + "__init__.x": "", + "stats.x": "fn sum2() -> i32:\n return 2\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + import_node = irx_astx.ImportFromStmt( + [irx_astx.AliasExpr("sum2")], + module="local_lib.stats", + level=0, + ) + parsed = resolver("main", import_node, "local_lib.stats") + + assert parsed.key == "local_lib.stats" + assert parsed.origin is not None + assert Path(parsed.origin) == (package_dir / "stats.x").resolve() + + +def test_file_import_resolver_resolves_transitive_installed_arx_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: FileImportResolver resolves transitive installed Arx dependencies. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import helper from project_b.tools\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-project-a",), + ) + + site_packages = tmp_path / "site-packages" + _write_installed_arx_distribution( + site_packages, + "arx-test-project-a", + "project_a", + {"__init__.x": "import helper from project_b.tools\n"}, + requires=("arx-test-project-b >= 0.1",), + ) + package_b_dir = _write_installed_arx_distribution( + site_packages, + "arx-test-project-b", + "project_b", + { + "__init__.x": "", + "tools.x": "fn helper() -> i32:\n return 4\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + resolved = resolver._resolve_module_file("project_b.tools") + + assert resolved == (package_b_dir / "tools.x").resolve() + + +def test_file_import_resolver_skips_inactive_transitive_requirements( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Inactive metadata markers are not indexed as Arx dependencies. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("fn main() -> i32:\n return 0\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-project-a",), + ) + + site_packages = tmp_path / "site-packages" + _write_installed_arx_distribution( + site_packages, + "arx-test-project-a", + "project_a", + {"__init__.x": ""}, + requires=( + 'arx-optional-lib; extra == "dev"', + 'arx-platform-lib; sys_platform == "never-matches"', + ), + ) + _write_installed_arx_distribution( + site_packages, + "arx-optional-lib", + "optional_lib", + {"__init__.x": "", "tools.x": "fn helper() -> i32:\n return 1\n"}, + ) + _write_installed_arx_distribution( + site_packages, + "arx-platform-lib", + "platform_lib", + {"__init__.x": "", "tools.x": "fn helper() -> i32:\n return 2\n"}, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + with pytest.raises(LookupError, match="optional_lib"): + resolver._resolve_module_file("optional_lib.tools") + with pytest.raises(LookupError, match="platform_lib"): + resolver._resolve_module_file("platform_lib.tools") + + +def test_file_import_resolver_honors_installed_package_src_dir( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Installed Arx package discovery honors build src_dir and package. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import sum2 from local_lib.stats\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-src-layout-lib",), + ) + + site_packages = tmp_path / "site-packages" + package_dir = _write_installed_arx_src_distribution( + site_packages, + "arx-test-src-layout-lib", + "src_layout_project", + "local_lib", + { + "__init__.x": "", + "stats.x": "fn sum2() -> i32:\n return 2\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + resolved = resolver._resolve_module_file("local_lib.stats") + + assert resolved == (package_dir / "stats.x").resolve() + + +def test_file_import_resolver_local_source_shadows_installed_package( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Local project source keeps precedence over installed packages. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + local_package = src_dir / "local_lib" + local_package.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import sum2 from local_lib.stats\n", encoding="utf-8") + local_stats = local_package / "stats.x" + local_stats.write_text("fn sum2() -> i32:\n return 1\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-shadow-lib",), + ) + + site_packages = tmp_path / "site-packages" + _write_installed_arx_distribution( + site_packages, + "arx-test-shadow-lib", + "local_lib", + { + "__init__.x": "", + "stats.x": "fn sum2() -> i32:\n return 2\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + resolved = resolver._resolve_module_file("local_lib.stats") + + assert resolved == local_stats.resolve() + + +def test_file_import_resolver_ignores_python_only_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Python-only installed dependencies are not Arx import roots. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("fn main() -> i32:\n return 0\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-python-only",), + ) + + site_packages = tmp_path / "site-packages" + _write_python_only_distribution(site_packages, "arx-test-python-only") + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + with pytest.raises(LookupError, match="python_only"): + resolver._resolve_module_file("python_only") + + +def test_file_import_resolver_reports_missing_installed_dependency( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Missing declared installed dependencies get a clear error. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text( + "import helper from arx_missing_lib.tools\n", encoding="utf-8" + ) + _write_resolver_project( + project_root, + "app", + dependencies=("arx-missing-lib",), + ) + + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + with pytest.raises( + LookupError, + match="declared Arx dependency 'arx-missing-lib' is not installed", + ): + resolver._resolve_module_file("arx_missing_lib.tools") + + +def test_file_import_resolver_rejects_ambiguous_installed_module_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Installed packages reject ambiguous module and package paths. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import value from local_lib.mod\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-ambiguous-lib",), + ) + + site_packages = tmp_path / "site-packages" + _write_installed_arx_distribution( + site_packages, + "arx-test-ambiguous-lib", + "local_lib", + { + "__init__.x": "", + "mod.x": "fn value() -> i32:\n return 1\n", + "mod/__init__.x": "fn value() -> i32:\n return 2\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + with pytest.raises( + LookupError, + match=r"ambiguous module specifier 'local_lib\.mod'", + ): + resolver._resolve_module_file("local_lib.mod") + + +def test_file_import_resolver_keeps_reserved_namespaces_bundled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + title: Installed packages do not shadow reserved compiler namespaces. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ + project_root = tmp_path / "app" + src_dir = project_root / "src" + src_dir.mkdir(parents=True) + source = src_dir / "main.x" + source.write_text("import math from stdlib\n", encoding="utf-8") + _write_resolver_project( + project_root, + "app", + dependencies=("arx-test-stdlib-shadow",), + ) + + site_packages = tmp_path / "site-packages" + _write_installed_arx_distribution( + site_packages, + "arx-test-stdlib-shadow", + "stdlib", + { + "__init__.x": "", + "math.x": "fn square(value: i32) -> i32:\n return value\n", + }, + ) + + monkeypatch.syspath_prepend(str(site_packages)) + monkeypatch.chdir(project_root) + + resolver = main_module.FileImportResolver((str(source),)) + resolved = resolver._resolve_module_file("stdlib.math") + + assert ( + resolved + == (main_module.get_bundled_stdlib_root() / "math.x").resolve() + ) + + def test_file_import_resolver_supports_nested_relative_imports( tmp_path: Path, ) -> None: diff --git a/poetry.lock b/poetry.lock index 215fd73..ec59b86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -178,6 +178,7 @@ develop = true [package.dependencies] astx = "1.21.0" jsonschema = ">=4.0.0" +packaging = ">=23" pyirx = "1.21.0" pyyaml = ">=4" tomli = {version = ">=2.0.0", markers = "python_version < \"3.11\""} @@ -2879,7 +2880,7 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},