From f1250881f4b3b0f5c515a05d6b6efa185f16110a Mon Sep 17 00:00:00 2001 From: jhkaktus Date: Thu, 24 Sep 2026 16:51:29 +0200 Subject: [PATCH] Implement sjnetpkg 0.1.0, a Git-based package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages are ordinary Git repositories carrying a sjnetpkg.toml, so sjnetpkg only needs to resolve, clone, track and run them instead of inventing an archive format or build system. This adds the v0.1 CLI (install from registry or Git URL, remove, list/inspect, registry update, run) on top of the Python standard library, together with a test suite. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- README.md | 169 ++++++++++++++++++++++++- pyproject.toml | 23 ++++ src/sjnetpkg/__init__.py | 5 + src/sjnetpkg/__main__.py | 6 + src/sjnetpkg/cli.py | 203 ++++++++++++++++++++++++++++++ src/sjnetpkg/config.py | 103 +++++++++++++++ src/sjnetpkg/errors.py | 14 +++ src/sjnetpkg/git.py | 113 +++++++++++++++++ src/sjnetpkg/package.py | 138 ++++++++++++++++++++ src/sjnetpkg/registry.py | 101 +++++++++++++++ src/sjnetpkg/runner.py | 30 +++++ tests/conftest.py | 16 +++ tests/test_cli.py | 265 +++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 67 ++++++++++ tests/test_package.py | 140 +++++++++++++++++++++ tests/test_registry.py | 123 ++++++++++++++++++ 16 files changed, 1515 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml create mode 100644 src/sjnetpkg/__init__.py create mode 100644 src/sjnetpkg/__main__.py create mode 100644 src/sjnetpkg/cli.py create mode 100644 src/sjnetpkg/config.py create mode 100644 src/sjnetpkg/errors.py create mode 100644 src/sjnetpkg/git.py create mode 100644 src/sjnetpkg/package.py create mode 100644 src/sjnetpkg/registry.py create mode 100644 src/sjnetpkg/runner.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_package.py create mode 100644 tests/test_registry.py 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()