Commit 7f06c771e05562d1f20e03e296606540c11a5e0e
Commits[COMMIT BEGIN]commit 7f06c771e05562d1f20e03e296606540c11a5e0e Author: 0x4248 <[email protected]> Date: Fri Mar 6 14:05:10 2026 +0000 npkg: init diff --git a/npkg-testing/README.md b/npkg-testing/README.md new file mode 100644 index 0000000..2ea743a --- /dev/null +++ b/npkg-testing/README.md @@ -0,0 +1,43 @@ +# NPKG Testing Workspace + +Flat, dev-first workspace for package migration experiments. + +The package CLI is `./npkg`, with package build artifacts in `./npkg-build/`. +Implementation modules live in `tools/npkg/*.py`. +Code and package folders live directly here so development stays simple. + +## Category Layout + +- `bin/` - user-facing binaries +- `sbin/` - admin/service binaries +- `toolkits/` - toolkit code and utilities +- `lib/public/` - public/installable libraries +- `lib/private/` - personal/internal libraries +- `lab/` - unstable prototypes +- `systems/` - system/platform-specific things (configs, patches, scripts) + +## Demo Package + +`bin/hello_world` builds a C binary and packages it as `/bin/hello-world`. + +## Commands + +From repository root: + +```bash +./npkg list +./npkg installed +./npkg build hello-world +./npkg install hello-world +./npkg install-prebuilt --file ./npkg-build/packages/hello-world-0.1.0.tar.gz +./npkg uninstall hello-world +``` + +If `/opt/npkg` is not writable for your user, run install/uninstall with `sudo`. + +To run as `npkg ...` directly, symlink once: + +```bash +mkdir -p ~/.local/bin +ln -sf "$PWD/npkg" ~/.local/bin/npkg +``` diff --git a/npkg-testing/lab/README.md b/npkg-testing/lab/README.md new file mode 100644 index 0000000..23fe5f7 --- /dev/null +++ b/npkg-testing/lab/README.md @@ -0,0 +1,3 @@ +# lab + +Unstable and in-progress experiments. diff --git a/npkg-testing/lib/private/README.md b/npkg-testing/lib/private/README.md new file mode 100644 index 0000000..8c180b1 --- /dev/null +++ b/npkg-testing/lib/private/README.md @@ -0,0 +1,3 @@ +# private lib + +Internal/personal libraries not intended as public API. diff --git a/npkg-testing/lib/public/README.md b/npkg-testing/lib/public/README.md new file mode 100644 index 0000000..f2b8c0b --- /dev/null +++ b/npkg-testing/lib/public/README.md @@ -0,0 +1,3 @@ +# public lib + +Installable/public-facing libraries. diff --git a/npkg-testing/npkg b/npkg-testing/npkg new file mode 100755 index 0000000..c8fedb9 --- /dev/null +++ b/npkg-testing/npkg @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import sys + +from tools.npkg.cli import main + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) \ No newline at end of file diff --git a/npkg-testing/npkg-build/README.md b/npkg-testing/npkg-build/README.md new file mode 100644 index 0000000..a382c82 --- /dev/null +++ b/npkg-testing/npkg-build/README.md @@ -0,0 +1,89 @@ +# NPKG tools + +Package tooling lives here. + +Implementation modules are in `tools/npkg/*.py` and loaded by the root `./npkg` entrypoint. + +- `../npkg` - CLI entrypoint (`list`, `build`, `install`, `uninstall`) +- `work/` - staging work dirs +- `packages/` - built package archives + +## Quick start + +```bash +./npkg list +./npkg installed +./npkg build hello-world +./npkg install hello-world +./npkg install-prebuilt --file ./npkg-build/packages/hello-world-0.1.0.tar.gz +./npkg uninstall hello-world +``` + +Default install root is `/opt/npkg` and default prefix is `/` (so binaries land under `/opt/npkg/bin`, etc). +If `/opt/npkg` is not writable for your user, run install/uninstall with `sudo`. + +## Package metadata + +Packages are discovered by scanning these directories for one metadata file per package directory: + +- `npkg.conf` (preferred) +- `npkg.ini` +- `npkg.toml` (compatibility) +- `npkg.json` (legacy compatibility) + +If more than one metadata file exists in a package directory, `npkg` errors to avoid ambiguity. + +Scan roots: + +- `bin/` +- `sbin/` +- `toolkits/` +- `lib/public/` +- `lib/private/` +- `lab/` +- `systems/` + +Minimal `npkg.conf` example: + +```toml +name = "hello-world" +version = "0.1.0" +description = "Tiny demo C package" + +[build] +command = "make -C {package_dir} all" + +[stage] +command = "make -C {package_dir} install DESTDIR={stage_dir} PREFIX={prefix}" + +[capabilities] +installable = true +``` + +INI equivalent: + +```ini +[package] +name = hello-world +version = 0.1.0 +description = Tiny demo C package + +[build] +command = make -C {package_dir} all + +[stage] +command = make -C {package_dir} install DESTDIR={stage_dir} PREFIX={prefix} + +[capabilities] +installable = true +``` + +Notes: + +- Optional defaults: + - `name` defaults to folder name (with `_` converted to `-`) + - `version` defaults to `0.1.0` + - `description` defaults to empty + - `capabilities.installable` defaults to `true` if `stage.command` is set, else `false` +- `build.command` remains optional. +- `stage.command` is required only for installable packages. diff --git a/npkg-testing/sbin/README.md b/npkg-testing/sbin/README.md new file mode 100644 index 0000000..e31a12c --- /dev/null +++ b/npkg-testing/sbin/README.md @@ -0,0 +1,3 @@ +# sbin + +Admin/service-oriented binaries for migration testing. diff --git a/npkg-testing/toolkits/README.md b/npkg-testing/toolkits/README.md new file mode 100644 index 0000000..74fb3f5 --- /dev/null +++ b/npkg-testing/toolkits/README.md @@ -0,0 +1,3 @@ +# toolkits + +Toolkit projects and shared utilities for migration testing. diff --git a/npkg-testing/tools/__init__.py b/npkg-testing/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/npkg-testing/tools/npkg/__init__.py b/npkg-testing/tools/npkg/__init__.py new file mode 100644 index 0000000..ed32c05 --- /dev/null +++ b/npkg-testing/tools/npkg/__init__.py @@ -0,0 +1,3 @@ +from .cli import main + +__all__ = ["main"] diff --git a/npkg-testing/tools/npkg/archive.py b/npkg-testing/tools/npkg/archive.py new file mode 100644 index 0000000..12c4dc6 --- /dev/null +++ b/npkg-testing/tools/npkg/archive.py @@ -0,0 +1,89 @@ +import re +import shutil +import subprocess +import tarfile +from pathlib import Path +from typing import Dict, List, Optional + +from .console import info, ok +from .paths import package_archive_path, package_stage_dir, workspace_root +from .types import Package + + +def command_context(pkg: Package, stage_dir: Optional[Path] = None) -> Dict[str, str]: + context = { + "workspace_root": str(workspace_root()), + "npkg_root": str(workspace_root()), + "npkg_build_root": str(workspace_root() / "npkg-build"), + "package_dir": str(pkg.package_dir), + } + if stage_dir is not None: + context["stage_dir"] = str(stage_dir) + return context + + +def render_command(template: str, context: Dict[str, str]) -> str: + return template.format(**context) + + +def run_shell(command: str, cwd: Path) -> None: + result = subprocess.run(command, cwd=str(cwd), shell=True) + if result.returncode != 0: + raise RuntimeError(f"Command failed with exit code {result.returncode}: {command}") + + +def ensure_package_archive(pkg: Package, prefix: str) -> Path: + if not pkg.installable: + raise RuntimeError(f"Package '{pkg.name}' is not installable") + if not pkg.stage_command: + raise RuntimeError(f"Package '{pkg.name}' does not define stage.command") + + stage_dir = package_stage_dir(pkg) + archive_path = package_archive_path(pkg) + + shutil.rmtree(stage_dir, ignore_errors=True) + stage_dir.mkdir(parents=True, exist_ok=True) + + context = command_context(pkg, stage_dir=stage_dir) + context["prefix"] = prefix + stage_command = render_command(pkg.stage_command, context) + + info(f"staging {pkg.name} with: {stage_command}") + run_shell(stage_command, cwd=workspace_root()) + + archive_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "w:gz") as tar: + for item in sorted(stage_dir.iterdir()): + tar.add(item, arcname=item.name) + + ok(f"packaged {pkg.name} -> {archive_path}") + return archive_path + + +def extract_archive_into_root(archive_path: Path, install_root: Path) -> List[str]: + try: + with tarfile.open(archive_path, "r:gz") as tar: + member_names = [member.name for member in tar.getmembers() if member.name and member.name != "."] + try: + tar.extractall(path=install_root, filter="data") + except TypeError: + tar.extractall(path=install_root) + except (tarfile.TarError, OSError) as error: + raise RuntimeError(f"failed to install archive: {error}") from error + return member_names + + +def infer_package_from_archive(archive_path: Path) -> tuple[str, str]: + base = archive_path.name + if base.endswith(".tar.gz"): + base = base[:-7] + elif base.endswith(".tgz"): + base = base[:-4] + else: + base = archive_path.stem + + match = re.match(r"^(?P<name>.+)-(?P<version>\d+\.\d+\.\d+.*)$", base) + if match: + return match.group("name"), match.group("version") + + return base, "prebuilt" diff --git a/npkg-testing/tools/npkg/cli.py b/npkg-testing/tools/npkg/cli.py new file mode 100644 index 0000000..f472665 --- /dev/null +++ b/npkg-testing/tools/npkg/cli.py @@ -0,0 +1,97 @@ +import argparse + +from .commands import ( + cmd_build, + cmd_install, + cmd_install_prebuilt, + cmd_installed, + cmd_list, + cmd_uninstall, +) +from .console import fail + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="npkg", + description="Nexus package helper", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " npkg list\n" + " npkg installed\n" + " npkg build hello-world\n" + " npkg install hello-world\n" + " npkg install-prebuilt --file ./npkg-build/packages/hello-world-0.1.0.tar.gz\n" + " npkg uninstall hello-world" + ), + ) + + sub = parser.add_subparsers(dest="command", required=True) + + list_parser = sub.add_parser("list", help="List available packages") + list_parser.set_defaults(func=cmd_list) + + installed_parser = sub.add_parser("installed", help="List installed packages from manifest database") + installed_parser.add_argument( + "--root", + default="/opt/npkg", + help="Installation root directory (default: /opt/npkg)", + ) + installed_parser.set_defaults(func=cmd_installed) + + build_parser = sub.add_parser("build", help="Build a package") + build_parser.add_argument("package", help="Package name or package directory path") + build_parser.set_defaults(func=cmd_build) + + install_parser = sub.add_parser("install", help="Install a package") + install_parser.add_argument("package", help="Package name or package directory path") + install_parser.add_argument( + "--root", + default="/opt/npkg", + help="Installation root directory (default: /opt/npkg)", + ) + install_parser.add_argument( + "--prefix", + default="/", + help="Prefix passed to package stage command (default: /)", + ) + install_parser.set_defaults(func=cmd_install) + + install_prebuilt_parser = sub.add_parser("install-prebuilt", help="Install from a prebuilt tarball") + install_prebuilt_parser.add_argument( + "--file", + required=True, + help="Path to .tar.gz or .tgz package archive", + ) + install_prebuilt_parser.add_argument( + "--package", + help="Package name override (defaults to name inferred from archive filename)", + ) + install_prebuilt_parser.add_argument( + "--root", + default="/opt/npkg", + help="Installation root directory (default: /opt/npkg)", + ) + install_prebuilt_parser.set_defaults(func=cmd_install_prebuilt) + + uninstall_parser = sub.add_parser("uninstall", help="Uninstall a package") + uninstall_parser.add_argument("package", help="Package name") + uninstall_parser.add_argument( + "--root", + default="/opt/npkg", + help="Installation root directory (default: /opt/npkg)", + ) + uninstall_parser.set_defaults(func=cmd_uninstall) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except ValueError as error: + fail(str(error)) + return 2 diff --git a/npkg-testing/tools/npkg/commands.py b/npkg-testing/tools/npkg/commands.py new file mode 100644 index 0000000..dd09ed2 --- /dev/null +++ b/npkg-testing/tools/npkg/commands.py @@ -0,0 +1,171 @@ +from pathlib import Path + +from .archive import command_context, ensure_package_archive, extract_archive_into_root, infer_package_from_archive, render_command, run_shell +from .console import fail, info, ok, style, warn +from .install_db import load_installed_rows, uninstall_from_manifest, write_install_manifest, write_prebuilt_manifest +from .metadata import discover_packages, select_package +from .paths import workspace_root + + +def cmd_list(_args) -> int: + packages = discover_packages() + print(style("NPKG packages", bold=True)) + if not packages: + warn("no packages found") + return 0 + + for pkg in packages.values(): + flags = [] + flags.append("build" if pkg.build_command else "no-build") + if pkg.installable and pkg.stage_command: + flags.append("install") + elif pkg.installable and not pkg.stage_command: + flags.append("no-stage") + else: + flags.append("build-only") + + rel_dir = pkg.package_dir.relative_to(workspace_root()) + print( + f" {style(pkg.name, bold=True)} {pkg.version} " + f"[{', '.join(flags)}] {rel_dir}" + ) + if pkg.description: + print(f" {pkg.description}") + + return 0 + + +def cmd_build(args) -> int: + packages = discover_packages() + try: + pkg = select_package(packages, args.package) + except KeyError: + fail(f"package not found: {args.package}") + return 2 + + if not pkg.build_command: + fail(f"package '{pkg.name}' does not define build.command") + return 2 + + command = render_command(pkg.build_command, command_context(pkg)) + info(f"building {pkg.name} with: {command}") + try: + run_shell(command, cwd=workspace_root()) + except RuntimeError as error: + fail(str(error)) + return 1 + + ok(f"built {pkg.name}") + return 0 + + +def cmd_install(args) -> int: + packages = discover_packages() + try: + pkg = select_package(packages, args.package) + except KeyError: + fail(f"package not found: {args.package}") + return 2 + + install_root = Path(args.root).expanduser().resolve() + prefix = args.prefix + + try: + archive_path = ensure_package_archive(pkg, prefix=prefix) + except RuntimeError as error: + fail(str(error)) + return 2 + + install_root.mkdir(parents=True, exist_ok=True) + info(f"installing {pkg.name} into {install_root}") + try: + member_names = extract_archive_into_root(archive_path, install_root) + except RuntimeError as error: + fail(str(error)) + return 1 + + write_install_manifest(pkg, install_root, member_names) + + ok(f"installed {pkg.name} -> {install_root}") + return 0 + + +def cmd_install_prebuilt(args) -> int: + archive_path = Path(args.file).expanduser().resolve() + if not archive_path.exists() or not archive_path.is_file(): + fail(f"archive not found: {archive_path}") + return 2 + + install_root = Path(args.root).expanduser().resolve() + install_root.mkdir(parents=True, exist_ok=True) + + inferred_name, inferred_version = infer_package_from_archive(archive_path) + package_name = (args.package or inferred_name).strip() + package_version = inferred_version + + if not package_name: + fail("unable to determine package name; pass --package") + return 2 + + info(f"installing prebuilt {package_name} from {archive_path} into {install_root}") + try: + member_names = extract_archive_into_root(archive_path, install_root) + except RuntimeError as error: + fail(str(error)) + return 1 + + write_prebuilt_manifest( + package_name=package_name, + version=package_version, + install_root=install_root, + members=member_names, + source_archive=archive_path, + ) + + ok(f"installed prebuilt {package_name} -> {install_root}") + return 0 + + +def cmd_uninstall(args) -> int: + packages = discover_packages() + pkg_name = args.package + if pkg_name in packages: + pkg_name = packages[pkg_name].name + else: + try: + pkg_name = select_package(packages, args.package).name + except KeyError: + pkg_name = args.package + + install_root = Path(args.root).expanduser().resolve() + info(f"uninstalling {pkg_name} from {install_root}") + try: + uninstall_from_manifest(pkg_name, install_root) + except RuntimeError as error: + fail(str(error)) + return 2 + + ok(f"uninstalled {pkg_name} from {install_root}") + return 0 + + +def cmd_installed(args) -> int: + install_root = Path(args.root).expanduser().resolve() + rows = load_installed_rows(install_root) + + print(style(f"Installed packages in {install_root}", bold=True)) + if not rows: + warn("no installed packages found") + return 0 + + for row in rows: + if row.get("error"): + warn(f"skipping invalid manifest {row['manifest'].name}: {row['error']}") + continue + + print( + f" {style(row['package'], bold=True)} {row['version']} " + f"({row['path_count']} paths)" + ) + + return 0 diff --git a/npkg-testing/tools/npkg/console.py b/npkg-testing/tools/npkg/console.py new file mode 100644 index 0000000..d76d3b8 --- /dev/null +++ b/npkg-testing/tools/npkg/console.py @@ -0,0 +1,41 @@ +import os +import sys + + +RESET = "\033[0m" +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +RED = "\033[31m" +BLUE = "\033[34m" + + +def use_color() -> bool: + return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + + +def style(text: str, color: str = "", bold: bool = False) -> str: + if not use_color(): + return text + prefix = "" + if bold: + prefix += BOLD + if color: + prefix += color + return f"{prefix}{text}{RESET}" + + +def info(message: str) -> None: + print(f"[{style('>', BLUE)}] {message}") + + +def ok(message: str) -> None: + print(f"[{style('+', GREEN)}] {message}") + + +def warn(message: str) -> None: + print(f"[{style('!', YELLOW)}] {message}") + + +def fail(message: str) -> None: + print(f"[{style('x', RED)}] {message}", file=sys.stderr) diff --git a/npkg-testing/tools/npkg/install_db.py b/npkg-testing/tools/npkg/install_db.py new file mode 100644 index 0000000..e9be84b --- /dev/null +++ b/npkg-testing/tools/npkg/install_db.py @@ -0,0 +1,126 @@ +import json +from pathlib import Path +from typing import Any, Dict, List + +from .paths import list_manifest_paths, manifest_root, package_manifest_path +from .types import Package + + +def write_install_manifest(pkg: Package, install_root: Path, members: List[str]) -> None: + db_dir = manifest_root(install_root) + db_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "package": pkg.name, + "version": pkg.version, + "install_root": str(install_root), + "paths": members, + } + manifest_path = package_manifest_path(pkg.name, install_root) + with manifest_path.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2) + + +def write_prebuilt_manifest( + package_name: str, + version: str, + install_root: Path, + members: List[str], + source_archive: Path, +) -> None: + db_dir = manifest_root(install_root) + db_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "package": package_name, + "version": version, + "install_root": str(install_root), + "source_archive": str(source_archive), + "paths": members, + } + manifest_path = package_manifest_path(package_name, install_root) + with manifest_path.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2) + + +def read_install_manifest(pkg_name: str, install_root: Path) -> Dict[str, Any]: + manifest_path = package_manifest_path(pkg_name, install_root) + if not manifest_path.exists(): + raise RuntimeError(f"package '{pkg_name}' is not installed in {install_root}") + with manifest_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + raise RuntimeError(f"invalid manifest for package '{pkg_name}'") + return data + + +def uninstall_from_manifest(pkg_name: str, install_root: Path) -> None: + data = read_install_manifest(pkg_name, install_root) + raw_paths = data.get("paths", []) + if not isinstance(raw_paths, list): + raise RuntimeError(f"invalid manifest path list for package '{pkg_name}'") + + unique_paths = [] + seen = set() + for item in raw_paths: + if not isinstance(item, str): + continue + normalized = item.strip().lstrip("/") + if not normalized or normalized in seen: + continue + seen.add(normalized) + unique_paths.append(normalized) + + for rel_path in sorted(unique_paths, key=lambda value: (value.count("/"), len(value)), reverse=True): + target = install_root / rel_path + if target.is_symlink() or target.is_file(): + target.unlink(missing_ok=True) + elif target.is_dir(): + try: + target.rmdir() + except OSError: + pass + + for rel_path in sorted(unique_paths, key=lambda value: value.count("/"), reverse=True): + current = (install_root / rel_path).parent + while current != install_root and current.exists(): + try: + current.rmdir() + except OSError: + break + current = current.parent + + manifest_path = package_manifest_path(pkg_name, install_root) + manifest_path.unlink(missing_ok=True) + + db_dir = manifest_root(install_root) + try: + db_dir.rmdir() + except OSError: + pass + + +def load_installed_rows(install_root: Path) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for manifest_path in list_manifest_paths(install_root): + try: + with manifest_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + rows.append( + { + "manifest": manifest_path, + "error": str(error), + } + ) + continue + + rows.append( + { + "manifest": manifest_path, + "package": str(data.get("package") or manifest_path.stem), + "version": str(data.get("version") or "unknown"), + "path_count": len(data.get("paths", [])) if isinstance(data.get("paths", []), list) else 0, + "error": None, + } + ) + + return rows diff --git a/npkg-testing/tools/npkg/metadata.py b/npkg-testing/tools/npkg/metadata.py new file mode 100644 index 0000000..f5cc75b --- /dev/null +++ b/npkg-testing/tools/npkg/metadata.py @@ -0,0 +1,205 @@ +import configparser +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional + +try: + import tomllib +except ModuleNotFoundError: + tomllib = None + +from .paths import workspace_root +from .types import Package + + +def normalize_command(raw: Optional[str]) -> Optional[str]: + if raw is None: + return None + command = raw.strip() + if not command: + return None + return command + + +def as_dict(value: Any) -> Dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def parse_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + raw = str(value).strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return default + + +def load_json_package(meta_path: Path) -> Dict[str, Any]: + with meta_path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def load_toml_package(meta_path: Path) -> Dict[str, Any]: + if tomllib is None: + raise ValueError("TOML metadata requires Python 3.11+ (tomllib not available)") + with meta_path.open("rb") as handle: + parsed = tomllib.load(handle) + if not isinstance(parsed, dict): + raise ValueError(f"Invalid metadata in {meta_path}: expected top-level table") + return parsed + + +def load_ini_package(meta_path: Path) -> Dict[str, Any]: + parser = configparser.ConfigParser() + parser.optionxform = str + read_ok = parser.read(meta_path, encoding="utf-8") + if not read_ok: + raise ValueError(f"Unable to read metadata: {meta_path}") + + package: Dict[str, Any] = {} + build: Dict[str, Any] = {} + stage: Dict[str, Any] = {} + capabilities: Dict[str, Any] = {} + + if parser.has_section("package"): + section = parser["package"] + package["name"] = section.get("name") + package["version"] = section.get("version") + package["description"] = section.get("description") + + if parser.has_section("build"): + build["command"] = parser["build"].get("command") + + if parser.has_section("stage"): + stage["command"] = parser["stage"].get("command") + + if parser.has_section("capabilities"): + capabilities["installable"] = parser["capabilities"].get("installable") + + package["build"] = build + package["stage"] = stage + package["capabilities"] = capabilities + return package + + +def load_raw_package(meta_path: Path) -> Dict[str, Any]: + name = meta_path.name.lower() + suffix = meta_path.suffix.lower() + if name == "npkg.conf": + try: + data = load_toml_package(meta_path) + except ValueError: + data = load_ini_package(meta_path) + elif suffix == ".json": + data = load_json_package(meta_path) + elif suffix == ".toml": + data = load_toml_package(meta_path) + elif suffix == ".ini": + data = load_ini_package(meta_path) + else: + raise ValueError(f"Unsupported metadata format: {meta_path.name}") + + if not isinstance(data, dict): + raise ValueError(f"Invalid metadata in {meta_path}: expected object/table") + return data + + +def load_package(meta_path: Path) -> Package: + data = load_raw_package(meta_path) + + default_name = meta_path.parent.name.replace("_", "-") + name = str(data.get("name") or default_name).strip() + version = str(data.get("version") or "0.1.0").strip() + description = str(data.get("description") or "").strip() + build = as_dict(data.get("build", {})) + stage = as_dict(data.get("stage", {})) + capabilities = as_dict(data.get("capabilities", {})) + + if not name or not version: + raise ValueError(f"Invalid metadata in {meta_path}: missing name/version") + + build_command = normalize_command(build.get("command")) + stage_command = normalize_command(stage.get("command")) + installable_default = stage_command is not None + installable = parse_bool(capabilities.get("installable"), default=installable_default) + + return Package( + name=name, + version=version, + description=description, + package_dir=meta_path.parent, + build_command=build_command, + stage_command=stage_command, + installable=installable, + ) + + +def metadata_file_in_dir(package_dir: Path) -> Optional[Path]: + candidates = [ + package_dir / "npkg.conf", + package_dir / "npkg.toml", + package_dir / "npkg.ini", + package_dir / "npkg.json", + ] + found = [path for path in candidates if path.exists()] + if len(found) > 1: + names = ", ".join(path.name for path in found) + raise ValueError(f"Multiple metadata files in {package_dir}: {names}") + return found[0] if found else None + + +def discover_packages() -> Dict[str, Package]: + root = workspace_root() + packages: Dict[str, Package] = {} + search_roots = [ + root / "bin", + root / "sbin", + root / "toolkits", + root / "lib" / "public", + root / "lib" / "private", + root / "lab", + root / "systems", + ] + + for top in search_roots: + if not top.exists(): + continue + for dirpath, _, filenames in os.walk(top): + filename_set = set(filenames) + if not ({"npkg.conf", "npkg.toml", "npkg.ini", "npkg.json"} & filename_set): + continue + package_dir = Path(dirpath) + meta = metadata_file_in_dir(package_dir) + if meta is None: + continue + package = load_package(meta) + if package.name in packages: + first = packages[package.name].package_dir + raise ValueError( + f"Duplicate package name '{package.name}' in {first} and {package.package_dir}" + ) + packages[package.name] = package + + return dict(sorted(packages.items(), key=lambda item: item[0])) + + +def select_package(packages: Dict[str, Package], selector: str) -> Package: + if selector in packages: + return packages[selector] + + root = workspace_root() + normalized = selector.strip().strip("/") + if normalized: + selector_path = (root / normalized).resolve() + for pkg in packages.values(): + if pkg.package_dir.resolve() == selector_path: + return pkg + + raise KeyError(selector) diff --git a/npkg-testing/tools/npkg/paths.py b/npkg-testing/tools/npkg/paths.py new file mode 100644 index 0000000..3029adf --- /dev/null +++ b/npkg-testing/tools/npkg/paths.py @@ -0,0 +1,39 @@ +from pathlib import Path +from typing import List + +from .types import Package + + +def workspace_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def npkg_build_root() -> Path: + return workspace_root() / "npkg-build" + + +def out_root() -> Path: + return npkg_build_root() + + +def package_stage_dir(pkg: Package) -> Path: + return out_root() / "work" / pkg.name / "stage" + + +def package_archive_path(pkg: Package) -> Path: + return out_root() / "packages" / f"{pkg.name}-{pkg.version}.tar.gz" + + +def manifest_root(install_root: Path) -> Path: + return install_root / ".npkg-db" + + +def package_manifest_path(pkg_name: str, install_root: Path) -> Path: + return manifest_root(install_root) / f"{pkg_name}.json" + + +def list_manifest_paths(install_root: Path) -> List[Path]: + db_dir = manifest_root(install_root) + if not db_dir.exists() or not db_dir.is_dir(): + return [] + return sorted(db_dir.glob("*.json")) diff --git a/npkg-testing/tools/npkg/types.py b/npkg-testing/tools/npkg/types.py new file mode 100644 index 0000000..3d31325 --- /dev/null +++ b/npkg-testing/tools/npkg/types.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass +class Package: + name: str + version: str + description: str + package_dir: Path + build_command: Optional[str] + stage_command: Optional[str] + installable: bool[COMMIT END](C) 2025 0x4248 (C) 2025 4248 Media and 4248 Systems, All part of 0x4248 See LICENCE files for more information. Not all files are by 0x4248 always check Licencing.