diff --git a/README.md b/README.md index ebfb86f..6025973 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,170 @@ # sjnetPKG -Package manager \ No newline at end of file +A simple, Git-based package manager. + +A package is not a pre-compiled file or a special archive format - it is an +ordinary Git repository. `sjnetpkg` finds packages, clones them, keeps track +of what is installed, updates them and runs them. + +``` +Git repository -> ~/.sjnetpkg/pkg/ -> sjnetpkg.toml -> run command +``` + +The repository (including `.git`) is kept completely intact, and `sjnetpkg` +never modifies the package code itself. + +## Installation + +Requires Python 3.11 or newer and the `git` executable on `PATH`. + +```bash +pip install . +# or, for development +pip install -e . +``` + +You can also run it straight from the checkout: + +```bash +PYTHONPATH=src python3 -m sjnetpkg --help +``` + +## Package format + +Every package must contain a `sjnetpkg.toml` in its root directory: + +```toml +name = "cool-tool" +version = "1.0.0" + +[run] +command = "cargo run" +``` + +`name` and `version` are required. `version` is treated as an opaque string; +v0.1 performs no semantic-version resolution. + +The optional `[run]` section defines how the package is started. The command +is language-agnostic, for example: + +| Language | `command` | +| -------- | --------------------- | +| Rust | `cargo run` | +| Python | `python main.py` | +| Go | `go run .` | +| Node.js | `npm start` | +| Bash | `bash main.sh` | + +The command string is passed to the platform shell (`/bin/sh` on POSIX, +`cmd.exe` on Windows), so multi-argument commands such as +`cargo run --release` work as written. The process runs with the package +directory as its working directory, and STDIN, STDOUT and STDERR are passed +through directly. + +## Registry + +The registry is a plain TOML file mapping package names to Git repositories: + +```toml +[packages.cool-tool] +repo = "https://github.com/example/cool-tool.git" +``` + +The registry URL is configured in `~/.sjnetpkg/config.toml`: + +```toml +registry = "https://pkg.serverjonas.de/registry.toml" +``` + +A downloaded copy is kept at `~/.sjnetpkg/registry.toml` so packages can be +resolved even when the remote registry is unreachable. A failed or invalid +download never destroys the existing local copy. + +The default registry is `https://pkg.serverjonas.de/registry.toml`, defined by +`DEFAULT_REGISTRY_URL` in `src/sjnetpkg/config.py`. It is written into +`config.toml` on first start; afterwards the file in the home directory wins. + +## CLI + +``` +Usage: + sjnetpkg -S install a package from the registry + sjnetpkg -U install a package directly from a Git URL + sjnetpkg -R remove an installed package + sjnetpkg -Q list installed packages + sjnetpkg -Q show information about an installed package + sjnetpkg -Sy update the local copy of the registry + sjnetpkg run run an installed package + sjnetpkg --help show the help + sjnetpkg --version show the version +``` + +With `-U`, the directory name is derived from the repository name in the URL, +while the `name` in `sjnetpkg.toml` is the final package identity. The two are +allowed to differ. + +Installation never starts a package automatically - use `sjnetpkg run `. +Packages are installed into `~/.sjnetpkg/pkg/` and are never copied to +`/usr/bin`, `/usr/local/bin` or `~/.local/bin`. + +Exit codes: `0` on success, `1` on errors, `2` on command line usage errors. +Errors are reported as short messages, never as Python tracebacks. + +## Directory layout + +``` +~/.sjnetpkg/ +|-- config.toml +|-- registry.toml +`-- pkg/ + `-- cool-tool/ + |-- .git/ + |-- sjnetpkg.toml + `-- ... +``` + +The home directory can be overridden with the `SJNETPKG_HOME` environment +variable (useful for testing and custom setups). It defaults to +`pathlib.Path.home() / ".sjnetpkg"`. + +## Updating packages + +Updates run `git pull` inside a package directory. `sjnetpkg -Su`, which +updates all installed packages, is planned; the update concept is defined but +v0.1 does not implement it yet. An update will never start a package. + +## Development + +Modules and responsibilities: + +* `cli.py` - arguments, options, command dispatch, exit codes, error messages +* `config.py` - the `~/.sjnetpkg/` layout, `config.toml`, registry configuration +* `registry.py` - downloading, reading and searching the registry +* `package.py` - package paths, `sjnetpkg.toml`, metadata, install state +* `git.py` - `git clone`, `git pull`, git availability and errors +* `runner.py` - run command lookup, working directory, process, exit code + +Run the tests (standard library `unittest`, no extra dependencies): + +```bash +PYTHONPATH=src python3 -m unittest discover -s tests -t tests +``` + +They also run under pytest, which picks up `tests/conftest.py`. + +## Scope of v0.1 + +Deliberately not included: dependency resolver, version constraints, +automatic dependency installation, sandbox, signatures, checksums, binary +packages, build system, automatic compiler installation, global executables, +daemon, package hooks, an own Git implementation, remote package cache. + +## Security + +A package is potentially executable foreign code: its `[run] command` may +start arbitrary commands. v0.1 provides no sandbox - only install and run +packages you trust. + +## License + +To be determined. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..075df0f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "sjnetpkg" +version = "0.1.0" +description = "A simple Git-based package manager." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +sjnetpkg = "sjnetpkg.cli:main" + +[project.optional-dependencies] +test = ["pytest>=7"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/sjnetpkg/__init__.py b/src/sjnetpkg/__init__.py new file mode 100644 index 0000000..7cc926a --- /dev/null +++ b/src/sjnetpkg/__init__.py @@ -0,0 +1,5 @@ +"""sjnetpkg - a simple, Git-based package manager.""" + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/sjnetpkg/__main__.py b/src/sjnetpkg/__main__.py new file mode 100644 index 0000000..1bd69d6 --- /dev/null +++ b/src/sjnetpkg/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for ``python -m sjnetpkg``.""" + +from .cli import main + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/sjnetpkg/cli.py b/src/sjnetpkg/cli.py new file mode 100644 index 0000000..e3cbebc --- /dev/null +++ b/src/sjnetpkg/cli.py @@ -0,0 +1,203 @@ +"""Command line interface. + +The argument syntax (``-S``, ``-Sy``, ``-Q``, ``run`` ...) mixes combined +short flags with sub-commands, which argparse does not model well. The +options are therefore parsed by hand, which also keeps full control over the +help text and the user-facing error messages. +""" + +from __future__ import annotations + +import shutil +import sys + +from . import __version__, config, git, package, registry, runner +from .errors import SjnetpkgError, UsageError + +PROGRAM = "sjnetpkg" + +HELP_TEXT = f"""\ +{PROGRAM} {__version__} - a simple Git-based package manager + +Usage: + sjnetpkg -S + sjnetpkg -U + sjnetpkg -R + sjnetpkg -Q + sjnetpkg -Q + sjnetpkg -Sy + sjnetpkg run + sjnetpkg --help + sjnetpkg --version + +Commands: + -S Install a package listed in the registry. + -U Install a package directly from a Git URL. + -R Remove an installed package. + -Q List installed packages. + -Q Show information about an installed package. + -Sy Update the local copy of the registry. + run Run an installed package. + --help Show this help. + --version Show the version. + +Packages are installed into {config.default_home() / config.PACKAGES_DIRNAME}. +""" + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI and return the process exit code.""" + args = list(sys.argv[1:] if argv is None else argv) + if not args: + print(HELP_TEXT, end="", file=sys.stderr) + return 2 + try: + return _dispatch(args) + except UsageError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + except SjnetpkgError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: # pragma: no cover - interactive + print(file=sys.stderr) + return 130 + + +def _dispatch(args: list[str]) -> int: + command, rest = args[0], args[1:] + + if command in ("--help", "-h", "help"): + print(HELP_TEXT, end="") + return 0 + if command in ("--version", "-V"): + print(f"{PROGRAM} {__version__}") + return 0 + if command == "-Sy": + return _cmd_update_registry(rest) + if command == "-S": + return _cmd_install(rest) + if command == "-U": + return _cmd_install_url(rest) + if command == "-R": + return _cmd_remove(rest) + if command == "-Q": + return _cmd_query(rest) + if command == "run": + return _cmd_run(rest) + + raise UsageError(f"unknown command '{command}'. Run '{PROGRAM} --help' for usage.") + + +def _single_argument(args: list[str], option: str) -> str: + if not args: + raise UsageError(f"{option} requires an argument.") + if len(args) > 1: + raise UsageError(f"{option} accepts a single argument.") + return args[0] + + +def _cmd_update_registry(args: list[str]) -> int: + if args: + raise UsageError("-Sy takes no arguments.") + paths = config.get_paths() + config.ensure_home(paths) + registry.update_registry(paths) + print(f"Registry updated: {paths.registry}") + return 0 + + +def _cmd_install(args: list[str]) -> int: + name = _single_argument(args, "-S") + paths = config.get_paths() + config.ensure_home(paths) + packages = registry.load_registry(paths) + url = registry.resolve(packages, name) + manifest = _install_from_url(url, paths) + print(f"Installed {manifest.name} {manifest.version} -> {manifest.path}") + return 0 + + +def _cmd_install_url(args: list[str]) -> int: + url = _single_argument(args, "-U") + paths = config.get_paths() + config.ensure_home(paths) + manifest = _install_from_url(url, paths) + print(f"Installed {manifest.name} {manifest.version} -> {manifest.path}") + return 0 + + +def _install_from_url(url: str, paths: config.Paths) -> package.Manifest: + """Clone *url* into the package directory and return its manifest.""" + url = git.validate_url(url) + repo_name = git.repository_name(url) + if not package.is_valid_name(repo_name): + raise git.InvalidGitUrlError(f"cannot derive a package name from '{url}'.") + + target = paths.package(repo_name) + if target.exists(): + raise package.PackageError(f"package '{repo_name}' is already installed.") + + git.clone(url, target) + try: + return package.read_manifest(target) + except SjnetpkgError: + # An invalid manifest means the clone is not a usable package. + shutil.rmtree(target, ignore_errors=True) + raise + + +def _cmd_remove(args: list[str]) -> int: + name = _single_argument(args, "-R") + paths = config.get_paths() + directory = package.remove_package(paths, name) + print(f"Removed {name} ({directory})") + return 0 + + +def _cmd_query(args: list[str]) -> int: + paths = config.get_paths() + if not args: + return _list_packages(paths) + if len(args) > 1: + raise UsageError("-Q accepts at most one argument.") + + manifest = package.read_manifest(package.find_installed(paths, args[0])) + _print_package_info(manifest) + return 0 + + +def _list_packages(paths: config.Paths) -> int: + manifests = sorted(package.list_installed(paths), key=lambda m: m.name) + if not manifests: + print("No packages installed.") + return 0 + + width = max(len(manifest.name) for manifest in manifests) + for manifest in manifests: + print(f"{manifest.name:<{width}} {manifest.version}") + return 0 + + +def _print_package_info(manifest: package.Manifest) -> None: + rows = [ + ("Name:", manifest.name), + ("Version:", manifest.version), + ("Path:", str(manifest.path)), + ("Repository:", git.remote_url(manifest.path) or "unknown"), + ] + width = max(len(label) for label, _ in rows) + for label, value in rows: + print(f"{label:<{width}} {value}") + + +def _cmd_run(args: list[str]) -> int: + name = _single_argument(args, "run") + paths = config.get_paths() + directory = package.find_installed(paths, name) + manifest = package.read_manifest(directory) + if manifest.run_command is None: + raise runner.RunError( + f"package '{manifest.name}' does not define a run command." + ) + return runner.run_command(manifest.run_command, directory) diff --git a/src/sjnetpkg/config.py b/src/sjnetpkg/config.py new file mode 100644 index 0000000..b7e4780 --- /dev/null +++ b/src/sjnetpkg/config.py @@ -0,0 +1,103 @@ +"""Filesystem layout and configuration for sjnetpkg. + +All persistent data lives inside the sjnetpkg home directory, which defaults +to ``~/.sjnetpkg``. The location is resolved through +:func:`pathlib.Path.home` so the tool stays portable across Linux, macOS and +Windows. Tests (and users with unusual setups) can override it with the +``SJNETPKG_HOME`` environment variable. + +Layout:: + + ~/.sjnetpkg/ + |-- config.toml + |-- registry.toml + `-- pkg/ + `-- / +""" + +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from .errors import SjnetpkgError + +CONFIG_FILENAME = "config.toml" +REGISTRY_FILENAME = "registry.toml" +PACKAGES_DIRNAME = "pkg" + +#: Registry used when ``config.toml`` does not configure one yet. +DEFAULT_REGISTRY_URL = "https://pkg.serverjonas.de/registry.toml" + + +class ConfigError(SjnetpkgError): + """Raised for missing or invalid configuration.""" + + +def default_home() -> Path: + """Return the sjnetpkg home directory (``~/.sjnetpkg`` by default).""" + override = os.environ.get("SJNETPKG_HOME") + if override: + return Path(override).expanduser() + return Path.home() / ".sjnetpkg" + + +@dataclass(frozen=True) +class Paths: + """Resolved locations of sjnetpkg's files and directories.""" + + home: Path + config: Path + registry: Path + pkg: Path + + @classmethod + def from_home(cls, home: Path) -> "Paths": + return cls( + home=home, + config=home / CONFIG_FILENAME, + registry=home / REGISTRY_FILENAME, + pkg=home / PACKAGES_DIRNAME, + ) + + def package(self, name: str) -> Path: + """Return the install directory for *name*.""" + return self.pkg / name + + +def get_paths(home: Path | None = None) -> Paths: + """Return the :class:`Paths` for *home* (or the default home).""" + return Paths.from_home(home if home is not None else default_home()) + + +def ensure_home(paths: Paths) -> None: + """Create ``~/.sjnetpkg`` and its subdirectories on first use.""" + paths.pkg.mkdir(parents=True, exist_ok=True) + if not paths.config.exists(): + paths.config.write_text( + "# sjnetpkg configuration\n" + f'registry = "{DEFAULT_REGISTRY_URL}"\n', + encoding="utf-8", + ) + + +def read_config(paths: Paths) -> dict: + """Return the parsed ``config.toml`` (empty dict if it is missing).""" + if not paths.config.is_file(): + return {} + try: + return tomllib.loads(paths.config.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"invalid config file '{paths.config}': {exc}") from exc + except OSError as exc: + raise ConfigError(f"cannot read config file '{paths.config}': {exc}") from exc + + +def registry_url(paths: Paths) -> str: + """Return the configured registry URL.""" + url = read_config(paths).get("registry", DEFAULT_REGISTRY_URL) + if not isinstance(url, str) or not url.strip(): + raise ConfigError("config.toml contains an invalid 'registry' value.") + return url.strip() diff --git a/src/sjnetpkg/errors.py b/src/sjnetpkg/errors.py new file mode 100644 index 0000000..33586f6 --- /dev/null +++ b/src/sjnetpkg/errors.py @@ -0,0 +1,14 @@ +"""Shared exception hierarchy. + +Every error that is expected during normal operation derives from +:class:`SjnetpkgError`. The CLI catches these and prints a short message +instead of letting a Python traceback reach the user. +""" + + +class SjnetpkgError(Exception): + """Base class for all user-facing sjnetpkg errors.""" + + +class UsageError(SjnetpkgError): + """Raised when the command line arguments are invalid.""" diff --git a/src/sjnetpkg/git.py b/src/sjnetpkg/git.py new file mode 100644 index 0000000..ec412b0 --- /dev/null +++ b/src/sjnetpkg/git.py @@ -0,0 +1,113 @@ +"""Thin wrapper around the system ``git`` executable. + +sjnetpkg deliberately does not implement its own Git client; it shells out to +the ``git`` program that must be installed on the system. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +from .errors import SjnetpkgError + +_VALID_SCHEMES = {"http", "https", "ssh", "git", "file"} +_SCP_LIKE_RE = re.compile(r"^[^@\s/]+@[^:\s/]+:[^\s]+$") + + +class GitError(SjnetpkgError): + """Raised when a git command fails or git is unavailable.""" + + +class GitNotFoundError(GitError): + """Raised when the git executable cannot be found.""" + + +class InvalidGitUrlError(GitError): + """Raised when a repository URL is not usable.""" + + +def git_executable() -> str: + """Return the path to the git executable or raise :class:`GitNotFoundError`.""" + executable = shutil.which("git") + if executable is None: + raise GitNotFoundError("git executable not found.\nPlease install git.") + return executable + + +def is_valid_url(url: str) -> bool: + """Return whether *url* looks like a usable Git repository URL.""" + url = url.strip() + if not url: + return False + if _SCP_LIKE_RE.match(url): + return True + parsed = urlparse(url) + return parsed.scheme in _VALID_SCHEMES and bool(parsed.netloc or parsed.path) + + +def validate_url(url: str) -> str: + """Return the cleaned *url* or raise :class:`InvalidGitUrlError`.""" + url = url.strip() + if not is_valid_url(url): + raise InvalidGitUrlError(f"invalid git URL: '{url}'.") + return url + + +def repository_name(url: str) -> str: + """Derive the repository name (last path component) from *url*.""" + cleaned = url.strip().rstrip("/") + if cleaned.endswith(".git"): + cleaned = cleaned[: -len(".git")] + name = re.split(r"[/:]", cleaned)[-1] + if not name: + raise InvalidGitUrlError(f"cannot determine a repository name from '{url}'.") + return name + + +def _run_git(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: + executable = git_executable() + try: + return subprocess.run( + [executable, *args], + cwd=cwd, + capture_output=True, + text=True, + ) + except OSError as exc: # pragma: no cover - depends on the environment + raise GitError(f"failed to run git: {exc}") from exc + + +def clone(url: str, destination: Path) -> None: + """Clone *url* into *destination*.""" + url = validate_url(url) + result = _run_git(["clone", url, str(destination)]) + if result.returncode != 0: + shutil.rmtree(destination, ignore_errors=True) + raise GitError(_failure_message("git clone failed", result)) + + +def pull(directory: Path) -> None: + """Run ``git pull`` inside *directory*.""" + result = _run_git(["pull"], cwd=directory) + if result.returncode != 0: + raise GitError(_failure_message("git pull failed", result)) + + +def remote_url(directory: Path) -> str | None: + """Return the ``origin`` remote URL of *directory*, if any.""" + try: + result = _run_git(["remote", "get-url", "origin"], cwd=directory) + except GitError: + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def _failure_message(prefix: str, result: subprocess.CompletedProcess) -> str: + detail = (result.stderr or result.stdout).strip() + return f"{prefix}: {detail}" if detail else f"{prefix}." diff --git a/src/sjnetpkg/package.py b/src/sjnetpkg/package.py new file mode 100644 index 0000000..868a92f --- /dev/null +++ b/src/sjnetpkg/package.py @@ -0,0 +1,138 @@ +"""Package directories, ``sjnetpkg.toml`` metadata and install state.""" + +from __future__ import annotations + +import re +import shutil +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from .config import Paths +from .errors import SjnetpkgError + +MANIFEST_FILENAME = "sjnetpkg.toml" + +# A package name must be non-empty, start and end with a letter or digit and +# may contain letters, digits, dots, underscores and dashes in between. +_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$") + + +class PackageError(SjnetpkgError): + """Raised for invalid package metadata and install/remove problems.""" + + +class NotInstalledError(PackageError): + """Raised when a package directory does not exist.""" + + +def is_valid_name(name: str) -> bool: + """Return whether *name* is a valid package name.""" + return bool(_NAME_RE.match(name)) + + +@dataclass(frozen=True) +class Manifest: + """Parsed contents of a package's ``sjnetpkg.toml``.""" + + name: str + version: str + path: Path + run_command: str | None = None + + +def read_manifest(package_dir: Path) -> Manifest: + """Read and validate ``sjnetpkg.toml`` inside *package_dir*.""" + manifest_path = package_dir / MANIFEST_FILENAME + if not manifest_path.is_file(): + raise PackageError(f"'{package_dir}' has no {MANIFEST_FILENAME}.") + + try: + data = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise PackageError( + f"invalid {MANIFEST_FILENAME} in '{package_dir}': {exc}" + ) from exc + except OSError as exc: + raise PackageError( + f"cannot read {MANIFEST_FILENAME} in '{package_dir}': {exc}" + ) from exc + + name = data.get("name") + if not isinstance(name, str) or not name.strip(): + raise PackageError(f"{MANIFEST_FILENAME} in '{package_dir}' is missing a 'name'.") + name = name.strip() + if not is_valid_name(name): + raise PackageError(f"'{name}' is not a valid package name.") + + version = data.get("version") + if not isinstance(version, str) or not version.strip(): + raise PackageError(f"package '{name}' is missing a 'version'.") + version = version.strip() + + run_command = None + run = data.get("run") + if isinstance(run, dict): + command = run.get("command") + if isinstance(command, str) and command.strip(): + run_command = command.strip() + + return Manifest(name=name, version=version, path=package_dir, run_command=run_command) + + +def is_installed(paths: Paths, name: str) -> bool: + """Return whether a package directory for *name* exists.""" + return paths.package(name).is_dir() + + +def find_installed(paths: Paths, name: str) -> Path: + """Return the directory of an installed package. + + The package is looked up by directory name first and by the ``name`` + declared in ``sjnetpkg.toml`` afterwards, so packages whose repository + name differs from their manifest name can still be addressed. + """ + direct = paths.package(name) + if direct.is_dir() and is_valid_name(name): + return direct + + for manifest in list_installed(paths): + if manifest.name == name: + return manifest.path + + raise NotInstalledError(f"package '{name}' is not installed.") + + +def list_installed(paths: Paths) -> list[Manifest]: + """Return the manifests of all installed packages. + + Directories without a readable, valid ``sjnetpkg.toml`` are ignored. + """ + if not paths.pkg.is_dir(): + return [] + + manifests: list[Manifest] = [] + for entry in sorted(paths.pkg.iterdir()): + if not entry.is_dir(): + continue + try: + manifests.append(read_manifest(entry)) + except PackageError: + continue + return manifests + + +def remove_package(paths: Paths, name: str) -> Path: + """Remove an installed package and return its former directory.""" + directory = find_installed(paths, name) + _ensure_within(directory, paths.pkg) + shutil.rmtree(directory) + return directory + + +def _ensure_within(path: Path, root: Path) -> None: + """Guard against removing anything outside of ``~/.sjnetpkg/pkg``.""" + resolved = path.resolve() + root_resolved = root.resolve() + if resolved != root_resolved and root_resolved not in resolved.parents: + raise PackageError(f"refusing to remove '{path}': it is outside '{root}'.") diff --git a/src/sjnetpkg/registry.py b/src/sjnetpkg/registry.py new file mode 100644 index 0000000..30fc2eb --- /dev/null +++ b/src/sjnetpkg/registry.py @@ -0,0 +1,101 @@ +"""Downloading, reading and querying the package registry. + +The registry is a plain TOML file mapping package names to Git repositories:: + + [packages.cool-tool] + repo = "https://github.com/example/cool-tool.git" + +A copy is kept at ``~/.sjnetpkg/registry.toml`` so packages can be resolved +even when the remote registry is unreachable. +""" + +from __future__ import annotations + +import os +import tomllib +import urllib.error +import urllib.request +from pathlib import Path + +from . import config +from .config import Paths +from .errors import SjnetpkgError + +_DOWNLOAD_TIMEOUT_SECONDS = 30 + + +class RegistryError(SjnetpkgError): + """Raised when the registry cannot be read, parsed or downloaded.""" + + +class PackageNotFoundError(RegistryError): + """Raised when a package name is not present in the registry.""" + + +def download_registry(url: str, destination: Path) -> None: + """Download the registry and atomically replace the local copy. + + The existing registry is only replaced after the download succeeded *and* + the content parsed as valid TOML, so a failed download never destroys a + working local registry. + """ + try: + with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response: + raw = response.read() + except (urllib.error.URLError, OSError, ValueError) as exc: + raise RegistryError(f"could not download registry from '{url}': {exc}") from exc + + try: + parsed = tomllib.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise RegistryError(f"registry from '{url}' is invalid: {exc}") from exc + + _parse_packages(parsed, source=url) + + destination.parent.mkdir(parents=True, exist_ok=True) + tmp = destination.with_name(destination.name + ".tmp") + try: + tmp.write_bytes(raw) + os.replace(tmp, destination) + finally: + tmp.unlink(missing_ok=True) + + +def load_registry(paths: Paths) -> dict[str, str]: + """Return the local registry as a mapping of package name to repository URL.""" + if not paths.registry.is_file(): + raise RegistryError("no local registry found. Run 'sjnetpkg -Sy' first.") + try: + data = tomllib.loads(paths.registry.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise RegistryError(f"local registry '{paths.registry}' is invalid: {exc}") from exc + except OSError as exc: + raise RegistryError(f"cannot read local registry '{paths.registry}': {exc}") from exc + return _parse_packages(data, source=str(paths.registry)) + + +def resolve(packages: dict[str, str], name: str) -> str: + """Return the repository URL for *name* or raise :class:`PackageNotFoundError`.""" + try: + return packages[name] + except KeyError: + raise PackageNotFoundError(f"package '{name}' was not found.") from None + + +def update_registry(paths: Paths) -> None: + """Download the configured registry into the local copy.""" + download_registry(config.registry_url(paths), paths.registry) + + +def _parse_packages(data: dict, source: str) -> dict[str, str]: + packages = data.get("packages") + if not isinstance(packages, dict): + raise RegistryError(f"registry from '{source}' has no [packages] table.") + + result: dict[str, str] = {} + for name, entry in packages.items(): + repo = entry.get("repo") if isinstance(entry, dict) else None + if not isinstance(repo, str) or not repo.strip(): + raise RegistryError(f"registry entry '{name}' is missing a 'repo' URL.") + result[name] = repo.strip() + return result diff --git a/src/sjnetpkg/runner.py b/src/sjnetpkg/runner.py new file mode 100644 index 0000000..0f0e9c3 --- /dev/null +++ b/src/sjnetpkg/runner.py @@ -0,0 +1,30 @@ +"""Starting packages with their configured run command.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .errors import SjnetpkgError + + +class RunError(SjnetpkgError): + """Raised when a package cannot be started.""" + + +def run_command(command: str, working_dir: Path) -> int: + """Run *command* inside *working_dir* and return its exit code. + + The command string is handed to the platform shell (``/bin/sh`` on POSIX, + ``cmd.exe`` on Windows) so that multi-argument commands such as + ``cargo run --release`` behave exactly as written in ``sjnetpkg.toml``. + STDIN, STDOUT and STDERR are inherited from the sjnetpkg process, which + lets packages act like ordinary terminal programs. + """ + if not command.strip(): + raise RunError("no run command given.") + try: + completed = subprocess.run(command, cwd=working_dir, shell=True) + except OSError as exc: + raise RunError(f"failed to start command: {exc}") from exc + return completed.returncode diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9b491cf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Test bootstrap: make the ``src`` layout importable. + +This is used by pytest. When running with the standard library test runner, +pass the source tree explicitly, e.g.:: + + PYTHONPATH=src python3 -m unittest discover -s tests -t tests +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SRC = Path(__file__).resolve().parents[1] / "src" +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..658cb04 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import contextlib +import io +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sjnetpkg import cli, config + +GIT_AVAILABLE = shutil.which("git") is not None + + +def run_cli(*args: str) -> tuple[int, str, str]: + """Invoke the CLI, capturing stdout/stderr and the exit code.""" + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = cli.main(list(args)) + return code, stdout.getvalue(), stderr.getvalue() + + +def write_package(root: Path, directory: str, manifest: str, files: dict[str, str] | None = None) -> Path: + target = root / directory + target.mkdir(parents=True, exist_ok=True) + (target / "sjnetpkg.toml").write_text(manifest, encoding="utf-8") + for name, content in (files or {}).items(): + (target / name).write_text(content, encoding="utf-8") + return target + + +def make_git_repo(path: Path, manifest: str, files: dict[str, str] | None = None) -> Path: + write_package(path.parent, path.name, manifest, files) + subprocess.run(["git", "init", "-q", str(path)], check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run( + [ + "git", "-C", str(path), + "-c", "user.email=test@example.com", + "-c", "user.name=Test", + "commit", "-q", "-m", "init", + ], + check=True, + ) + return path + + +class CliTestCase(unittest.TestCase): + """Base class that isolates the sjnetpkg home directory.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.home = self.root / ".sjnetpkg" + patcher = mock.patch.dict(os.environ, {"SJNETPKG_HOME": str(self.home)}) + patcher.start() + self.addCleanup(patcher.stop) + self.paths = config.get_paths() + + def tearDown(self) -> None: + self._tmp.cleanup() + + def install_fixture(self, name: str = "cool-tool", manifest: str | None = None, + files: dict[str, str] | None = None) -> Path: + manifest = manifest or f'name = "{name}"\nversion = "1.0.0"\n' + repo = make_git_repo(self.root / "repos" / name, manifest, files) + code, _, err = run_cli("-U", repo.as_uri()) + self.assertEqual(code, 0, err) + return repo + + +class VersionAndHelpTests(CliTestCase): + def test_version(self) -> None: + code, out, _ = run_cli("--version") + self.assertEqual(code, 0) + self.assertEqual(out.strip(), "sjnetpkg 0.1.0") + + def test_help(self) -> None: + code, out, _ = run_cli("--help") + self.assertEqual(code, 0) + self.assertIn("Usage:", out) + for line in ("sjnetpkg -S ", "sjnetpkg -Sy", "sjnetpkg run "): + self.assertIn(line, out) + + def test_no_arguments_is_a_usage_error(self) -> None: + code, _, err = run_cli() + self.assertEqual(code, 2) + self.assertIn("Usage:", err) + + def test_unknown_command(self) -> None: + code, _, err = run_cli("frobnicate") + self.assertEqual(code, 2) + self.assertIn("unknown command", err) + + +class QueryTests(CliTestCase): + def test_list_without_packages(self) -> None: + code, out, _ = run_cli("-Q") + self.assertEqual(code, 0) + self.assertIn("No packages installed.", out) + + def test_list_alignment(self) -> None: + config.ensure_home(self.paths) + write_package(self.paths.pkg, "cool-tool", 'name = "cool-tool"\nversion = "1.0.0"\n') + write_package(self.paths.pkg, "another-tool", 'name = "another-tool"\nversion = "2.4.1"\n') + code, out, _ = run_cli("-Q") + self.assertEqual(code, 0) + lines = out.strip().splitlines() + self.assertEqual(len(lines), 2) + self.assertRegex(lines[0], r"^another-tool\s+2\.4\.1$") + self.assertRegex(lines[1], r"^cool-tool\s+1\.0\.0$") + # The versions are printed in the same column. + self.assertEqual(lines[0].index("2.4.1"), lines[1].index("1.0.0")) + + def test_info_for_unknown_package(self) -> None: + code, _, err = run_cli("-Q", "missing") + self.assertEqual(code, 1) + self.assertIn("package 'missing' is not installed.", err) + + +@unittest.skipUnless(GIT_AVAILABLE, "git is required for these tests") +class InstallTests(CliTestCase): + def test_install_from_url(self) -> None: + repo = self.install_fixture() + target = self.home / "pkg" / "cool-tool" + self.assertTrue((target / ".git").is_dir()) + self.assertTrue((target / "sjnetpkg.toml").is_file()) + + def test_install_reports_package(self) -> None: + repo = make_git_repo( + self.root / "repos" / "cool-tool", + 'name = "cool-tool"\nversion = "1.0.0"\n', + ) + code, out, _ = run_cli("-U", repo.as_uri()) + self.assertEqual(code, 0) + self.assertIn("Installed cool-tool 1.0.0", out) + + def test_second_install_is_rejected(self) -> None: + repo = self.install_fixture() + code, _, err = run_cli("-U", repo.as_uri()) + self.assertEqual(code, 1) + self.assertIn("package 'cool-tool' is already installed.", err) + + def test_invalid_url_is_rejected(self) -> None: + code, _, err = run_cli("-U", "not-a-url") + self.assertEqual(code, 1) + self.assertIn("invalid git URL", err) + + def test_missing_manifest_does_not_leave_a_directory(self) -> None: + repo_dir = self.root / "repos" / "empty-tool" + repo_dir.mkdir(parents=True) + (repo_dir / "README.md").write_text("no manifest here") + subprocess.run(["git", "init", "-q", str(repo_dir)], check=True) + subprocess.run(["git", "-C", str(repo_dir), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(repo_dir), "-c", "user.email=t@e", "-c", "user.name=T", + "commit", "-q", "-m", "init"], + check=True, + ) + code, _, err = run_cli("-U", repo_dir.as_uri()) + self.assertEqual(code, 1) + self.assertIn("sjnetpkg.toml", err) + self.assertFalse((self.home / "pkg" / "empty-tool").exists()) + + +@unittest.skipUnless(GIT_AVAILABLE, "git is required for these tests") +class RegistryInstallTests(CliTestCase): + def _write_registry(self, url: str, name: str = "cool-tool") -> None: + config.ensure_home(self.paths) + self.paths.registry.write_text( + f'[packages.{name}]\nrepo = "{url}"\n', encoding="utf-8" + ) + + def test_install_from_registry(self) -> None: + repo = make_git_repo( + self.root / "repos" / "cool-tool", + 'name = "cool-tool"\nversion = "1.0.0"\n', + ) + self._write_registry(repo.as_uri()) + code, out, err = run_cli("-S", "cool-tool") + self.assertEqual(code, 0, err) + self.assertIn("Installed cool-tool 1.0.0", out) + + def test_unknown_package(self) -> None: + self._write_registry("https://example.com/other.git", name="other") + code, _, err = run_cli("-S", "cool-tool") + self.assertEqual(code, 1) + self.assertIn("package 'cool-tool' was not found.", err) + + def test_missing_local_registry(self) -> None: + code, _, err = run_cli("-S", "cool-tool") + self.assertEqual(code, 1) + self.assertIn("no local registry found", err) + + def test_update_registry(self) -> None: + source = self.root / "remote-registry.toml" + source.write_text('[packages.cool-tool]\nrepo = "https://example.com/cool-tool.git"\n') + config.ensure_home(self.paths) + self.paths.config.write_text(f'registry = "{source.as_uri()}"\n', encoding="utf-8") + code, out, _ = run_cli("-Sy") + self.assertEqual(code, 0) + self.assertIn("Registry updated", out) + self.assertTrue(self.paths.registry.is_file()) + + +@unittest.skipUnless(GIT_AVAILABLE, "git is required for these tests") +class RunAndRemoveTests(CliTestCase): + def test_run_returns_process_exit_code(self) -> None: + manifest = ( + 'name = "cool-tool"\nversion = "1.0.0"\n\n' + f'[run]\ncommand = \'"{sys.executable}" main.py\'\n' + ) + self.install_fixture(manifest=manifest, files={"main.py": "import sys\nsys.exit(7)\n"}) + code, _, _ = run_cli("run", "cool-tool") + self.assertEqual(code, 7) + + def test_run_uses_package_directory_as_working_directory(self) -> None: + manifest = ( + 'name = "cool-tool"\nversion = "1.0.0"\n\n' + f'[run]\ncommand = \'"{sys.executable}" check_cwd.py\'\n' + ) + script = ( + "import pathlib, sys\n" + "sys.exit(0 if pathlib.Path('sjnetpkg.toml').exists() else 3)\n" + ) + self.install_fixture(manifest=manifest, files={"check_cwd.py": script}) + code, _, _ = run_cli("run", "cool-tool") + self.assertEqual(code, 0) + + def test_run_without_run_command(self) -> None: + self.install_fixture() + code, _, err = run_cli("run", "cool-tool") + self.assertEqual(code, 1) + self.assertIn("does not define a run command", err) + + def test_run_unknown_package(self) -> None: + code, _, err = run_cli("run", "missing") + self.assertEqual(code, 1) + self.assertIn("package 'missing' is not installed.", err) + + def test_info_and_remove(self) -> None: + self.install_fixture() + code, out, _ = run_cli("-Q", "cool-tool") + self.assertEqual(code, 0) + self.assertIn("Name: cool-tool", out) + self.assertIn("Version: 1.0.0", out) + self.assertIn("Repository:", out) + + code, out, _ = run_cli("-R", "cool-tool") + self.assertEqual(code, 0) + self.assertIn("Removed cool-tool", out) + self.assertFalse((self.home / "pkg" / "cool-tool").exists()) + + def test_remove_unknown_package(self) -> None: + code, _, err = run_cli("-R", "missing") + self.assertEqual(code, 1) + self.assertIn("package 'missing' is not installed.", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..939b291 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sjnetpkg import config + + +class DefaultHomeTests(unittest.TestCase): + def test_env_override_is_used(self) -> None: + with mock.patch.dict(os.environ, {"SJNETPKG_HOME": "/tmp/custom-home"}): + self.assertEqual(config.default_home(), Path("/tmp/custom-home")) + + def test_falls_back_to_home_directory(self) -> None: + env = {k: v for k, v in os.environ.items() if k != "SJNETPKG_HOME"} + with mock.patch.dict(os.environ, env, clear=True): + self.assertEqual(config.default_home(), Path.home() / ".sjnetpkg") + + +class PathsTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.home = Path(self._tmp.name) / ".sjnetpkg" + self.paths = config.get_paths(self.home) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_layout(self) -> None: + self.assertEqual(self.paths.config, self.home / "config.toml") + self.assertEqual(self.paths.registry, self.home / "registry.toml") + self.assertEqual(self.paths.pkg, self.home / "pkg") + self.assertEqual(self.paths.package("cool-tool"), self.home / "pkg" / "cool-tool") + + def test_ensure_home_creates_directories_and_config(self) -> None: + config.ensure_home(self.paths) + self.assertTrue(self.paths.pkg.is_dir()) + self.assertTrue(self.paths.config.is_file()) + self.assertEqual(config.registry_url(self.paths), config.DEFAULT_REGISTRY_URL) + + def test_ensure_home_keeps_existing_config(self) -> None: + self.home.mkdir(parents=True) + self.paths.config.write_text('registry = "https://example.org/r.toml"\n') + config.ensure_home(self.paths) + self.assertEqual(config.registry_url(self.paths), "https://example.org/r.toml") + + def test_missing_config_uses_default_registry(self) -> None: + self.assertEqual(config.registry_url(self.paths), config.DEFAULT_REGISTRY_URL) + + def test_invalid_config_raises(self) -> None: + self.home.mkdir(parents=True) + self.paths.config.write_text("this is = not toml\n") + with self.assertRaises(config.ConfigError): + config.registry_url(self.paths) + + def test_empty_registry_value_raises(self) -> None: + self.home.mkdir(parents=True) + self.paths.config.write_text('registry = ""\n') + with self.assertRaises(config.ConfigError): + config.registry_url(self.paths) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..6e72882 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from sjnetpkg import config, package + + +def write_package(root: Path, directory: str, manifest: str) -> Path: + target = root / directory + target.mkdir(parents=True, exist_ok=True) + (target / "sjnetpkg.toml").write_text(manifest, encoding="utf-8") + return target + + +class ValidNameTests(unittest.TestCase): + def test_valid_names(self) -> None: + for name in ("cool-tool", "a", "tool_2", "tool.py", "A1"): + self.assertTrue(package.is_valid_name(name), name) + + def test_invalid_names(self) -> None: + for name in ("", ".", "..", "-tool", "tool-", "bad name", "../escape", "a/b"): + self.assertFalse(package.is_valid_name(name), name) + + +class ManifestTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_reads_name_version_and_run_command(self) -> None: + directory = write_package( + self.root, + "cool-tool", + 'name = "cool-tool"\nversion = "1.0.0"\n\n[run]\ncommand = "cargo run"\n', + ) + manifest = package.read_manifest(directory) + self.assertEqual(manifest.name, "cool-tool") + self.assertEqual(manifest.version, "1.0.0") + self.assertEqual(manifest.run_command, "cargo run") + self.assertEqual(manifest.path, directory) + + def test_run_section_is_optional(self) -> None: + directory = write_package( + self.root, "lib", 'name = "lib"\nversion = "0.1.0"\n' + ) + self.assertIsNone(package.read_manifest(directory).run_command) + + def test_missing_manifest_raises(self) -> None: + directory = self.root / "empty" + directory.mkdir() + with self.assertRaises(package.PackageError): + package.read_manifest(directory) + + def test_invalid_toml_raises(self) -> None: + directory = write_package(self.root, "broken", "name = = \n") + with self.assertRaises(package.PackageError): + package.read_manifest(directory) + + def test_missing_name_raises(self) -> None: + directory = write_package(self.root, "noname", 'version = "1.0.0"\n') + with self.assertRaises(package.PackageError): + package.read_manifest(directory) + + def test_missing_version_raises(self) -> None: + directory = write_package(self.root, "noversion", 'name = "noversion"\n') + with self.assertRaises(package.PackageError): + package.read_manifest(directory) + + def test_invalid_name_raises(self) -> None: + directory = write_package( + self.root, "bad", 'name = "../escape"\nversion = "1.0.0"\n' + ) + with self.assertRaises(package.PackageError): + package.read_manifest(directory) + + +class InstalledPackagesTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.paths = config.get_paths(Path(self._tmp.name) / ".sjnetpkg") + self.paths.pkg.mkdir(parents=True) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_list_is_empty_without_packages_directory(self) -> None: + self.assertEqual(package.list_installed(config.get_paths(self.paths.home / "x")), []) + + def test_list_ignores_invalid_and_non_directory_entries(self) -> None: + write_package(self.paths.pkg, "cool-tool", 'name = "cool-tool"\nversion = "1.0.0"\n') + write_package(self.paths.pkg, "broken", "nonsense") + (self.paths.pkg / "loose-file").write_text("hello") + names = [m.name for m in package.list_installed(self.paths)] + self.assertEqual(names, ["cool-tool"]) + + def test_find_installed_by_directory_name(self) -> None: + directory = write_package( + self.paths.pkg, "repo-name", 'name = "manifest-name"\nversion = "1.0.0"\n' + ) + self.assertEqual(package.find_installed(self.paths, "repo-name"), directory) + + def test_find_installed_by_manifest_name(self) -> None: + directory = write_package( + self.paths.pkg, "repo-name", 'name = "manifest-name"\nversion = "1.0.0"\n' + ) + self.assertEqual(package.find_installed(self.paths, "manifest-name"), directory) + + def test_find_installed_unknown_raises(self) -> None: + with self.assertRaises(package.NotInstalledError): + package.find_installed(self.paths, "missing") + + def test_remove_package(self) -> None: + directory = write_package(self.paths.pkg, "cool-tool", 'name = "cool-tool"\nversion = "1.0.0"\n') + self.assertEqual(package.remove_package(self.paths, "cool-tool"), directory) + self.assertFalse(directory.exists()) + + def test_remove_missing_package_raises(self) -> None: + with self.assertRaises(package.NotInstalledError): + package.remove_package(self.paths, "missing") + + def test_remove_refuses_to_leave_package_directory(self) -> None: + outside = Path(self._tmp.name) / "outside" + outside.mkdir() + link = self.paths.pkg / "link" + try: + link.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): # pragma: no cover - platform dependent + self.skipTest("symlinks are not supported on this platform") + with self.assertRaises(package.PackageError): + package.remove_package(self.paths, "link") + self.assertTrue(outside.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..b599682 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from sjnetpkg import config, registry + + +class LoadRegistryTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.paths = config.get_paths(Path(self._tmp.name) / ".sjnetpkg") + self.paths.home.mkdir(parents=True) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_loads_packages(self) -> None: + self.paths.registry.write_text( + '[packages.cool-tool]\nrepo = "https://example.com/cool-tool.git"\n' + '[packages.other]\nrepo = "https://example.com/other.git"\n' + ) + self.assertEqual( + registry.load_registry(self.paths), + { + "cool-tool": "https://example.com/cool-tool.git", + "other": "https://example.com/other.git", + }, + ) + + def test_missing_registry_raises(self) -> None: + with self.assertRaises(registry.RegistryError): + registry.load_registry(self.paths) + + def test_invalid_toml_raises(self) -> None: + self.paths.registry.write_text("not = = toml\n") + with self.assertRaises(registry.RegistryError): + registry.load_registry(self.paths) + + def test_missing_packages_table_raises(self) -> None: + self.paths.registry.write_text('something = "else"\n') + with self.assertRaises(registry.RegistryError): + registry.load_registry(self.paths) + + def test_entry_without_repo_raises(self) -> None: + self.paths.registry.write_text('[packages.cool-tool]\nname = "cool-tool"\n') + with self.assertRaises(registry.RegistryError): + registry.load_registry(self.paths) + + def test_resolve_returns_repo(self) -> None: + self.assertEqual( + registry.resolve({"cool-tool": "https://example.com/cool-tool.git"}, "cool-tool"), + "https://example.com/cool-tool.git", + ) + + def test_resolve_unknown_raises(self) -> None: + with self.assertRaises(registry.PackageNotFoundError): + registry.resolve({}, "missing") + + +class DownloadRegistryTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.paths = config.get_paths(self.root / ".sjnetpkg") + self.paths.home.mkdir(parents=True) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _source(self, name: str, content: str) -> str: + path = self.root / name + path.write_text(content, encoding="utf-8") + return path.as_uri() + + def test_download_replaces_local_registry(self) -> None: + url = self._source( + "remote.toml", + '[packages.cool-tool]\nrepo = "https://example.com/cool-tool.git"\n', + ) + registry.download_registry(url, self.paths.registry) + self.assertEqual( + registry.load_registry(self.paths), + {"cool-tool": "https://example.com/cool-tool.git"}, + ) + + def test_failed_download_keeps_existing_registry(self) -> None: + self.paths.registry.write_text('[packages.old]\nrepo = "https://example.com/old.git"\n') + missing = (self.root / "missing.toml").as_uri() + with self.assertRaises(registry.RegistryError): + registry.download_registry(missing, self.paths.registry) + self.assertEqual( + registry.load_registry(self.paths), + {"old": "https://example.com/old.git"}, + ) + + def test_invalid_download_keeps_existing_registry(self) -> None: + self.paths.registry.write_text('[packages.old]\nrepo = "https://example.com/old.git"\n') + url = self._source("broken.toml", "this = = invalid\n") + with self.assertRaises(registry.RegistryError): + registry.download_registry(url, self.paths.registry) + self.assertEqual( + registry.load_registry(self.paths), + {"old": "https://example.com/old.git"}, + ) + + def test_update_uses_configured_url(self) -> None: + url = self._source( + "remote.toml", + '[packages.cool-tool]\nrepo = "https://example.com/cool-tool.git"\n', + ) + config.ensure_home(self.paths) + self.paths.config.write_text(f'registry = "{url}"\n', encoding="utf-8") + registry.update_registry(self.paths) + self.assertEqual( + registry.load_registry(self.paths), + {"cool-tool": "https://example.com/cool-tool.git"}, + ) + + +if __name__ == "__main__": + unittest.main()