From b6b9cb20688ce964c9fe4eba19a10128898c23f4 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:47:57 -0400 Subject: [PATCH 1/9] Move shared core and domain adapters into package --- MODULARIZATION_PLAN.md | 16 +- app_paths.py | 209 +---------------- app_version.py | 7 +- tests.py | 39 ++++ unityscraper/app/cli/__init__.py | 6 + unityscraper/app/cli/__main__.py | 7 + unityscraper/app/cli/commands.py | 54 +++++ unityscraper/app/cli/entrypoint.py | 8 +- unityscraper/app/cli/legacy.py | 32 +++ unityscraper/app/cli/registry.py | 22 ++ unityscraper/core/__init__.py | 6 + unityscraper/core/metadata.py | 28 +++ unityscraper/core/paths.py | 243 +++++++++++++++++--- unityscraper/core/version.py | 6 + unityscraper/domains/backups/__init__.py | 37 +++ unityscraper/domains/backups/models.py | 25 ++ unityscraper/domains/backups/operations.py | 17 ++ unityscraper/domains/backups/service.py | 12 +- unityscraper/domains/packages/__init__.py | 22 ++ unityscraper/domains/packages/inspectors.py | 19 ++ unityscraper/domains/packages/models.py | 7 + unityscraper/domains/packages/service.py | 13 +- unityscraper/domains/profiles/__init__.py | 23 ++ unityscraper/domains/profiles/models.py | 12 + unityscraper/domains/profiles/operations.py | 7 + unityscraper/domains/profiles/service.py | 10 +- 26 files changed, 626 insertions(+), 261 deletions(-) create mode 100644 unityscraper/app/cli/__main__.py create mode 100644 unityscraper/app/cli/commands.py create mode 100644 unityscraper/app/cli/legacy.py create mode 100644 unityscraper/app/cli/registry.py create mode 100644 unityscraper/core/metadata.py create mode 100644 unityscraper/core/version.py create mode 100644 unityscraper/domains/backups/models.py create mode 100644 unityscraper/domains/backups/operations.py create mode 100644 unityscraper/domains/packages/inspectors.py create mode 100644 unityscraper/domains/packages/models.py create mode 100644 unityscraper/domains/profiles/models.py create mode 100644 unityscraper/domains/profiles/operations.py diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index 40cad6c..55538fc 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -74,6 +74,21 @@ domain/ 5. Move command handlers after services are UI-neutral. 6. Split large UI pages only after their services are stable. +## Current Progress + +- `unityscraper.core.paths` owns application storage and resource resolution; + `app_paths.py` is a compatibility wrapper. +- `unityscraper.core.version` owns version constants; `app_version.py` is a + compatibility wrapper. +- `unityscraper.core.metadata` exposes app name, slug, and version metadata for + UI, CLI, API, diagnostics, and packaging. +- `unityscraper.app.cli` has a command registry and lazy legacy CLI adapter so + package command discovery does not import the full scraper runtime. +- `unityscraper.domains.packages` exposes read-only package models and + inspectors. +- `unityscraper.domains.backups` exposes backup models and operations. +- `unityscraper.domains.profiles` exposes profile/save models and helpers. + ## Feature Ownership | Domain | Owns | @@ -93,4 +108,3 @@ domain/ New Xbox 360 capabilities should enter as domain use cases first. Desktop buttons, REST routes, and CLI flags should call those use cases rather than owning file, database, FTP, or package logic themselves. - diff --git a/app_paths.py b/app_paths.py index bc698c6..c48645d 100644 --- a/app_paths.py +++ b/app_paths.py @@ -1,210 +1,5 @@ -"""Cross-platform application storage and bundled-resource paths.""" +"""Compatibility wrapper for package-owned application paths.""" from __future__ import annotations -import os -import posixpath -import shutil -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - -APP_NAME = "UnityScraper" -APP_SLUG = "unityscraper" - - -@dataclass(frozen=True) -class StoragePaths: - """Resolved writable directories for one UnityScraper installation.""" - - base: Path - downloads: Path - logs: Path - config: Path - data: Path - cache: Path - exports: Path - diagnostics: Path - - -def app_root() -> Path: - """Return the directory containing bundled application resources.""" - if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): - return Path(sys._MEIPASS) - if getattr(sys, "frozen", False): - return Path(sys.executable).resolve().parent - return Path(__file__).resolve().parent - - -def executable_root() -> Path: - """Return the directory beside the executable or source checkout.""" - if getattr(sys, "frozen", False): - return Path(sys.executable).resolve().parent - return Path(__file__).resolve().parent - - -def portable_mode_enabled() -> bool: - """Return True when the application should store all data locally.""" - env_enabled = os.environ.get("UNITYSCRAPER_PORTABLE", "").strip() == "1" - marker_enabled = (executable_root() / "portable.mode").exists() - return env_enabled or marker_enabled - - -def resolve_storage_paths( - *, - os_name: str | None = None, - platform_name: str | None = None, - environ: Mapping[str, str] | None = None, - home: Path | None = None, - portable_root: Path | None = None, -) -> StoragePaths: - """Resolve platform-native paths without creating them. - - Explicit parameters keep path behavior straightforward to test on any host. - """ - current_os = os_name or os.name - current_platform = platform_name or sys.platform - env = environ if environ is not None else os.environ - user_home = Path(home) if home is not None else Path.home() - - def xdg_path(variable: str, fallback: Path) -> Path: - value = env.get(variable) - if value: - candidate = Path(value).expanduser() - if posixpath.isabs(value): - return candidate - return fallback - - if portable_root is not None: - base = Path(portable_root) / "UnityScraperData" - return StoragePaths( - base=base, - downloads=base / "downloads", - logs=base / "logs", - config=base / "config", - data=base / "data", - cache=base / "cache", - exports=base / "exports", - diagnostics=base / "diagnostics", - ) - - if current_os == "nt": - root = env.get("LOCALAPPDATA") or env.get("APPDATA") - base = Path(root) / APP_NAME if root else user_home / APP_NAME - return StoragePaths( - base=base, - downloads=base / "downloads", - logs=base / "logs", - config=base / "config", - data=base / "data", - cache=base / "cache", - exports=base / "exports", - diagnostics=base / "diagnostics", - ) - - if current_platform == "darwin": - base = user_home / "Library" / "Application Support" / APP_NAME - return StoragePaths( - base=base, - downloads=base / "downloads", - logs=user_home / "Library" / "Logs" / APP_NAME, - config=base / "config", - data=base / "data", - cache=user_home / "Library" / "Caches" / APP_NAME, - exports=base / "exports", - diagnostics=base / "diagnostics", - ) - - data_home = xdg_path("XDG_DATA_HOME", user_home / ".local" / "share") - config_home = xdg_path("XDG_CONFIG_HOME", user_home / ".config") - cache_home = xdg_path("XDG_CACHE_HOME", user_home / ".cache") - state_home = xdg_path("XDG_STATE_HOME", user_home / ".local" / "state") - base = data_home / APP_SLUG - return StoragePaths( - base=base, - downloads=base / "downloads", - logs=state_home / APP_SLUG / "logs", - config=config_home / APP_SLUG, - data=base / "data", - cache=cache_home / APP_SLUG, - exports=base / "exports", - diagnostics=base / "diagnostics", - ) - - -_PATHS = resolve_storage_paths( - portable_root=executable_root() if portable_mode_enabled() else None -) - -BASE_DIR = _PATHS.base -DOWNLOADS_DIR = _PATHS.downloads -LOG_DIR = _PATHS.logs -CONFIG_DIR = _PATHS.config -DATA_DIR = _PATHS.data -CACHE_DIR = _PATHS.cache -EXPORTS_DIR = _PATHS.exports -DIAGNOSTICS_DIR = _PATHS.diagnostics -PROFILE_BACKUPS_DIR = DATA_DIR / "profile_backups" -PLUGINS_DIR = DATA_DIR / "plugins" -LANGUAGE_PACKS_DIR = DATA_DIR / "languages" -OFFLINE_KNOWLEDGE_DIR = DATA_DIR / "offline_knowledge" - -DATABASE_PATH = DATA_DIR / "unityscraper.db" -CONFIG_PATH = CONFIG_DIR / "config.json" -TITLEIDS_PATH = CONFIG_DIR / "JSON.txt" -CLI_LOG_PATH = LOG_DIR / "unityscraper.log" -GUI_LOG_PATH = LOG_DIR / "unityscraper_gui.log" -FIRST_RUN_PATH = CONFIG_DIR / "first_run_complete" - - -def resource_path(*parts: str) -> Path: - """Return a bundled resource path in source and PyInstaller builds.""" - return app_root().joinpath(*parts) - - -def ensure_app_dirs() -> None: - """Create every writable application directory.""" - for path in ( - BASE_DIR, - DOWNLOADS_DIR, - LOG_DIR, - CONFIG_DIR, - DATA_DIR, - CACHE_DIR, - EXPORTS_DIR, - DIAGNOSTICS_DIR, - PROFILE_BACKUPS_DIR, - PLUGINS_DIR, - LANGUAGE_PACKS_DIR, - OFFLINE_KNOWLEDGE_DIR, - ): - path.mkdir(parents=True, exist_ok=True) - - -def ensure_user_titleids_file() -> Path: - """Create the user's editable TitleID list when it does not yet exist.""" - ensure_app_dirs() - - if not TITLEIDS_PATH.exists(): - bundled = resource_path("JSON.txt") - if bundled.exists(): - shutil.copyfile(bundled, TITLEIDS_PATH) - else: - TITLEIDS_PATH.write_text("", encoding="utf-8") - - return TITLEIDS_PATH - - -def describe_storage() -> str: - """Return a human-readable storage summary for diagnostics and the UI.""" - mode = "Portable" if portable_mode_enabled() else "Installed" - return ( - f"Mode: {mode}\n" - f"Data: {DATA_DIR}\n" - f"Downloads: {DOWNLOADS_DIR}\n" - f"Exports: {EXPORTS_DIR}\n" - f"Config: {CONFIG_DIR}\n" - f"Cache: {CACHE_DIR}\n" - f"Logs: {LOG_DIR}" - ) +from unityscraper.core.paths import * # noqa: F403 diff --git a/app_version.py b/app_version.py index d48c2cd..7886d4e 100644 --- a/app_version.py +++ b/app_version.py @@ -1,4 +1,5 @@ -"""Single source of truth for UnityScraper version information.""" +"""Compatibility wrapper for package-owned version information.""" -APP_VERSION = "1.2.0b1" -DISPLAY_VERSION = "1.2.0-beta.1" +from __future__ import annotations + +from unityscraper.core.version import * # noqa: F403 diff --git a/tests.py b/tests.py index 3182004..83e5b96 100644 --- a/tests.py +++ b/tests.py @@ -69,8 +69,14 @@ from app_paths import resolve_storage_paths from platform_support import desktop_font_family, path_opener_command from profile_manager import ProfileSaveManager, find_content_root, mask_identifier +from unityscraper.app.cli import CliCommand, CliCommandRegistry, build_cli_registry +from unityscraper.app.cli.legacy import run_legacy_cli +from unityscraper.core import APP_METADATA from unityscraper.core.db import MigrationRegistry from unityscraper.core.jobs import JobProgress, JobResult +from unityscraper.core.paths import app_root as package_app_root +from unityscraper.core.paths import resource_path as package_resource_path +from unityscraper.core.version import DISPLAY_VERSION as PACKAGE_DISPLAY_VERSION from unityscraper.domains.backups.service import BackupService as ModularBackupService from unityscraper.domains.library.service import LibraryService as ModularLibraryService @@ -199,6 +205,39 @@ def test_domain_service_exports_preserve_existing_implementations(self): self.assertIs(ModularBackupService, BackupService) self.assertIs(ModularLibraryService, LibraryService) + def test_core_paths_match_legacy_asset_resolution(self): + self.assertEqual(package_app_root(), Path.cwd()) + self.assertTrue(package_resource_path("JSON.txt").is_file()) + + def test_core_metadata_matches_legacy_version(self): + self.assertEqual(PACKAGE_DISPLAY_VERSION, DISPLAY_VERSION) + self.assertEqual(APP_METADATA.name, "UnityScraper") + self.assertEqual(APP_METADATA.display_version, DISPLAY_VERSION) + + def test_cli_registry_exposes_legacy_adapter(self): + registry = build_cli_registry() + command = registry.get("legacy") + + self.assertIn("legacy", registry.as_dict()) + self.assertEqual(command.description, "Run the existing full UnityScraper CLI surface.") + + def test_cli_registry_rejects_duplicate_command_names(self): + registry = CliCommandRegistry() + command = CliCommand(name="example", description="Example", handler=lambda argv: 0) + registry.register(command) + + with self.assertRaises(ValueError): + registry.register(command) + + def test_legacy_cli_adapter_restores_sys_argv(self): + original = sys.argv[:] + with patch("main.main", return_value=None) as legacy: + result = run_legacy_cli(["--help"]) + + self.assertEqual(result, 0) + self.assertEqual(sys.argv, original) + self.assertEqual(legacy.call_count, 1) + def test_job_progress_percent_is_bounded(self): self.assertEqual( JobProgress(status="running", message="working", current=5, total=10).percent, diff --git a/unityscraper/app/cli/__init__.py b/unityscraper/app/cli/__init__.py index bbdaa73..e1288b7 100644 --- a/unityscraper/app/cli/__init__.py +++ b/unityscraper/app/cli/__init__.py @@ -1,2 +1,8 @@ """Command-line application adapters.""" +from __future__ import annotations + +from .commands import CliCommand, CliCommandRegistry +from .registry import build_cli_registry + +__all__ = ["CliCommand", "CliCommandRegistry", "build_cli_registry"] diff --git a/unityscraper/app/cli/__main__.py b/unityscraper/app/cli/__main__.py new file mode 100644 index 0000000..2a115f6 --- /dev/null +++ b/unityscraper/app/cli/__main__.py @@ -0,0 +1,7 @@ +"""Run the UnityScraper CLI package directly.""" + +from __future__ import annotations + +from .entrypoint import main + +raise SystemExit(main()) diff --git a/unityscraper/app/cli/commands.py b/unityscraper/app/cli/commands.py new file mode 100644 index 0000000..b5d8862 --- /dev/null +++ b/unityscraper/app/cli/commands.py @@ -0,0 +1,54 @@ +"""CLI command registration contracts. + +The current CLI is still implemented by the top-level ``main.py`` module. New +domain commands should register here first, then the legacy parser can shrink +as each feature moves to a package-owned command handler. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +CommandHandler = Callable[[list[str] | None], int | None] + + +@dataclass(frozen=True) +class CliCommand: + """A UI-neutral command exposed by the command-line app.""" + + name: str + description: str + handler: CommandHandler + + def run(self, argv: list[str] | None = None) -> int: + result = self.handler(argv) + return 0 if result is None else int(result) + + +@dataclass +class CliCommandRegistry: + """Ordered registry for package-owned CLI commands.""" + + commands: dict[str, CliCommand] = field(default_factory=dict) + + def register(self, command: CliCommand) -> None: + if command.name in self.commands: + raise ValueError(f"Duplicate CLI command: {command.name}") + self.commands[command.name] = command + + def get(self, name: str) -> CliCommand: + try: + return self.commands[name] + except KeyError as exc: + raise KeyError(f"Unknown CLI command: {name}") from exc + + def as_dict(self) -> dict[str, dict[str, Any]]: + return { + name: {"name": command.name, "description": command.description} + for name, command in sorted(self.commands.items()) + } + + +__all__ = ["CliCommand", "CliCommandRegistry", "CommandHandler"] diff --git a/unityscraper/app/cli/entrypoint.py b/unityscraper/app/cli/entrypoint.py index 3201b00..129add8 100644 --- a/unityscraper/app/cli/entrypoint.py +++ b/unityscraper/app/cli/entrypoint.py @@ -2,7 +2,11 @@ from __future__ import annotations -from main import main +from .legacy import run_legacy_cli -__all__ = ["main"] +def main() -> int: + """Run the current CLI through the package-owned adapter.""" + return run_legacy_cli() + +__all__ = ["main"] diff --git a/unityscraper/app/cli/legacy.py b/unityscraper/app/cli/legacy.py new file mode 100644 index 0000000..cd84d72 --- /dev/null +++ b/unityscraper/app/cli/legacy.py @@ -0,0 +1,32 @@ +"""Adapter for the existing top-level CLI implementation.""" + +from __future__ import annotations + +from collections.abc import Sequence +from contextlib import contextmanager +import sys + + +@contextmanager +def _temporary_argv(argv: Sequence[str] | None): + if argv is None: + yield + return + original = sys.argv[:] + sys.argv = [original[0], *argv] + try: + yield + finally: + sys.argv = original + + +def run_legacy_cli(argv: list[str] | None = None) -> int: + """Run the legacy CLI while package-owned commands are extracted.""" + from main import main as legacy_main + + with _temporary_argv(argv): + result = legacy_main() + return 0 if result is None else int(result) + + +__all__ = ["run_legacy_cli"] diff --git a/unityscraper/app/cli/registry.py b/unityscraper/app/cli/registry.py new file mode 100644 index 0000000..3b0c70c --- /dev/null +++ b/unityscraper/app/cli/registry.py @@ -0,0 +1,22 @@ +"""Default command registry for the UnityScraper CLI.""" + +from __future__ import annotations + +from .commands import CliCommand, CliCommandRegistry +from .legacy import run_legacy_cli + + +def build_cli_registry() -> CliCommandRegistry: + """Build the CLI registry with the legacy command as the default surface.""" + registry = CliCommandRegistry() + registry.register( + CliCommand( + name="legacy", + description="Run the existing full UnityScraper CLI surface.", + handler=run_legacy_cli, + ) + ) + return registry + + +__all__ = ["build_cli_registry"] diff --git a/unityscraper/core/__init__.py b/unityscraper/core/__init__.py index 5c1333a..42f8dbd 100644 --- a/unityscraper/core/__init__.py +++ b/unityscraper/core/__init__.py @@ -1,2 +1,8 @@ """Shared infrastructure used across UnityScraper domains.""" +from __future__ import annotations + +from .metadata import APP_METADATA, AppMetadata +from .version import APP_VERSION, DISPLAY_VERSION + +__all__ = ["APP_METADATA", "APP_VERSION", "DISPLAY_VERSION", "AppMetadata"] diff --git a/unityscraper/core/metadata.py b/unityscraper/core/metadata.py new file mode 100644 index 0000000..9321f86 --- /dev/null +++ b/unityscraper/core/metadata.py @@ -0,0 +1,28 @@ +"""Application metadata shared by every surface.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .paths import APP_NAME, APP_SLUG +from .version import APP_VERSION, DISPLAY_VERSION + + +@dataclass(frozen=True) +class AppMetadata: + """Stable metadata for UI, CLI, API, packaging, and diagnostics.""" + + name: str + slug: str + version: str + display_version: str + + +APP_METADATA = AppMetadata( + name=APP_NAME, + slug=APP_SLUG, + version=APP_VERSION, + display_version=DISPLAY_VERSION, +) + +__all__ = ["APP_METADATA", "AppMetadata"] diff --git a/unityscraper/core/paths.py b/unityscraper/core/paths.py index 65d4dcc..6c3b293 100644 --- a/unityscraper/core/paths.py +++ b/unityscraper/core/paths.py @@ -1,38 +1,222 @@ -"""Package-facing access to application storage and resource paths.""" +"""Cross-platform application storage and bundled-resource paths.""" from __future__ import annotations -from app_paths import ( - BASE_DIR, - CACHE_DIR, - CLI_LOG_PATH, - CONFIG_DIR, - CONFIG_PATH, - DATABASE_PATH, - DATA_DIR, - DIAGNOSTICS_DIR, - DOWNLOADS_DIR, - EXPORTS_DIR, - FIRST_RUN_PATH, - GUI_LOG_PATH, - LANGUAGE_PACKS_DIR, - LOG_DIR, - OFFLINE_KNOWLEDGE_DIR, - PLUGINS_DIR, - PROFILE_BACKUPS_DIR, - TITLEIDS_PATH, - StoragePaths, - app_root, - describe_storage, - ensure_app_dirs, - ensure_user_titleids_file, - executable_root, - portable_mode_enabled, - resolve_storage_paths, - resource_path, +import os +import posixpath +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +APP_NAME = "UnityScraper" +APP_SLUG = "unityscraper" + + +@dataclass(frozen=True) +class StoragePaths: + """Resolved writable directories for one UnityScraper installation.""" + + base: Path + downloads: Path + logs: Path + config: Path + data: Path + cache: Path + exports: Path + diagnostics: Path + + +def _source_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def app_root() -> Path: + """Return the directory containing bundled application resources.""" + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS) + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return _source_root() + + +def executable_root() -> Path: + """Return the directory beside the executable or source checkout.""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return _source_root() + + +def portable_mode_enabled() -> bool: + """Return True when the application should store all data locally.""" + env_enabled = os.environ.get("UNITYSCRAPER_PORTABLE", "").strip() == "1" + marker_enabled = (executable_root() / "portable.mode").exists() + return env_enabled or marker_enabled + + +def resolve_storage_paths( + *, + os_name: str | None = None, + platform_name: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + portable_root: Path | None = None, +) -> StoragePaths: + """Resolve platform-native paths without creating them. + + Explicit parameters keep path behavior straightforward to test on any host. + """ + current_os = os_name or os.name + current_platform = platform_name or sys.platform + env = environ if environ is not None else os.environ + user_home = Path(home) if home is not None else Path.home() + + def xdg_path(variable: str, fallback: Path) -> Path: + value = env.get(variable) + if value: + candidate = Path(value).expanduser() + if posixpath.isabs(value): + return candidate + return fallback + + if portable_root is not None: + base = Path(portable_root) / "UnityScraperData" + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=base / "logs", + config=base / "config", + data=base / "data", + cache=base / "cache", + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + if current_os == "nt": + root = env.get("LOCALAPPDATA") or env.get("APPDATA") + base = Path(root) / APP_NAME if root else user_home / APP_NAME + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=base / "logs", + config=base / "config", + data=base / "data", + cache=base / "cache", + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + if current_platform == "darwin": + base = user_home / "Library" / "Application Support" / APP_NAME + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=user_home / "Library" / "Logs" / APP_NAME, + config=base / "config", + data=base / "data", + cache=user_home / "Library" / "Caches" / APP_NAME, + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + data_home = xdg_path("XDG_DATA_HOME", user_home / ".local" / "share") + config_home = xdg_path("XDG_CONFIG_HOME", user_home / ".config") + cache_home = xdg_path("XDG_CACHE_HOME", user_home / ".cache") + state_home = xdg_path("XDG_STATE_HOME", user_home / ".local" / "state") + base = data_home / APP_SLUG + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=state_home / APP_SLUG / "logs", + config=config_home / APP_SLUG, + data=base / "data", + cache=cache_home / APP_SLUG, + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + +_PATHS = resolve_storage_paths( + portable_root=executable_root() if portable_mode_enabled() else None ) +BASE_DIR = _PATHS.base +DOWNLOADS_DIR = _PATHS.downloads +LOG_DIR = _PATHS.logs +CONFIG_DIR = _PATHS.config +DATA_DIR = _PATHS.data +CACHE_DIR = _PATHS.cache +EXPORTS_DIR = _PATHS.exports +DIAGNOSTICS_DIR = _PATHS.diagnostics +PROFILE_BACKUPS_DIR = DATA_DIR / "profile_backups" +PLUGINS_DIR = DATA_DIR / "plugins" +LANGUAGE_PACKS_DIR = DATA_DIR / "languages" +OFFLINE_KNOWLEDGE_DIR = DATA_DIR / "offline_knowledge" + +DATABASE_PATH = DATA_DIR / "unityscraper.db" +CONFIG_PATH = CONFIG_DIR / "config.json" +TITLEIDS_PATH = CONFIG_DIR / "JSON.txt" +CLI_LOG_PATH = LOG_DIR / "unityscraper.log" +GUI_LOG_PATH = LOG_DIR / "unityscraper_gui.log" +FIRST_RUN_PATH = CONFIG_DIR / "first_run_complete" + + +def resource_path(*parts: str) -> Path: + """Return a bundled resource path in source and PyInstaller builds.""" + return app_root().joinpath(*parts) + + +def ensure_app_dirs() -> None: + """Create every writable application directory.""" + for path in ( + BASE_DIR, + DOWNLOADS_DIR, + LOG_DIR, + CONFIG_DIR, + DATA_DIR, + CACHE_DIR, + EXPORTS_DIR, + DIAGNOSTICS_DIR, + PROFILE_BACKUPS_DIR, + PLUGINS_DIR, + LANGUAGE_PACKS_DIR, + OFFLINE_KNOWLEDGE_DIR, + ): + path.mkdir(parents=True, exist_ok=True) + + +def ensure_user_titleids_file() -> Path: + """Create the user's editable TitleID list when it does not yet exist.""" + ensure_app_dirs() + + if not TITLEIDS_PATH.exists(): + bundled = resource_path("JSON.txt") + if bundled.exists(): + shutil.copyfile(bundled, TITLEIDS_PATH) + else: + TITLEIDS_PATH.write_text("", encoding="utf-8") + + return TITLEIDS_PATH + + +def describe_storage() -> str: + """Return a human-readable storage summary for diagnostics and the UI.""" + mode = "Portable" if portable_mode_enabled() else "Installed" + return ( + f"Mode: {mode}\n" + f"Data: {DATA_DIR}\n" + f"Downloads: {DOWNLOADS_DIR}\n" + f"Exports: {EXPORTS_DIR}\n" + f"Config: {CONFIG_DIR}\n" + f"Cache: {CACHE_DIR}\n" + f"Logs: {LOG_DIR}" + ) + + __all__ = [ + "APP_NAME", + "APP_SLUG", "BASE_DIR", "CACHE_DIR", "CLI_LOG_PATH", @@ -61,4 +245,3 @@ "resolve_storage_paths", "resource_path", ] - diff --git a/unityscraper/core/version.py b/unityscraper/core/version.py new file mode 100644 index 0000000..4324577 --- /dev/null +++ b/unityscraper/core/version.py @@ -0,0 +1,6 @@ +"""Single source of truth for UnityScraper version information.""" + +APP_VERSION = "1.2.0b1" +DISPLAY_VERSION = "1.2.0-beta.1" + +__all__ = ["APP_VERSION", "DISPLAY_VERSION"] diff --git a/unityscraper/domains/backups/__init__.py b/unityscraper/domains/backups/__init__.py index 49c371f..a211029 100644 --- a/unityscraper/domains/backups/__init__.py +++ b/unityscraper/domains/backups/__init__.py @@ -1,2 +1,39 @@ """Owned-content backup, inventory, and transfer domain.""" +from __future__ import annotations + +from .models import ( + BackupError, + BackupItem, + ConflictError, + FtpTarget, + InvalidPackageError, + ScanResult, + TransferResult, + UnsafeArchiveError, +) +from .operations import ( + FtpBackupClient, + install_stfs_package, + scan_local_target, + verify_backup_item, +) +from .service import BackupRepository, BackupService, ensure_backup_schema + +__all__ = [ + "BackupError", + "BackupItem", + "BackupRepository", + "BackupService", + "ConflictError", + "FtpBackupClient", + "FtpTarget", + "InvalidPackageError", + "ScanResult", + "TransferResult", + "UnsafeArchiveError", + "ensure_backup_schema", + "install_stfs_package", + "scan_local_target", + "verify_backup_item", +] diff --git a/unityscraper/domains/backups/models.py b/unityscraper/domains/backups/models.py new file mode 100644 index 0000000..da5ec18 --- /dev/null +++ b/unityscraper/domains/backups/models.py @@ -0,0 +1,25 @@ +"""Backup domain data models and errors.""" + +from __future__ import annotations + +from backup_manager import ( + BackupError, + BackupItem, + ConflictError, + FtpTarget, + InvalidPackageError, + ScanResult, + TransferResult, + UnsafeArchiveError, +) + +__all__ = [ + "BackupError", + "BackupItem", + "ConflictError", + "FtpTarget", + "InvalidPackageError", + "ScanResult", + "TransferResult", + "UnsafeArchiveError", +] diff --git a/unityscraper/domains/backups/operations.py b/unityscraper/domains/backups/operations.py new file mode 100644 index 0000000..d6fad21 --- /dev/null +++ b/unityscraper/domains/backups/operations.py @@ -0,0 +1,17 @@ +"""Backup domain filesystem and transfer operations.""" + +from __future__ import annotations + +from backup_manager import ( + FtpBackupClient, + install_stfs_package, + scan_local_target, + verify_backup_item, +) + +__all__ = [ + "FtpBackupClient", + "install_stfs_package", + "scan_local_target", + "verify_backup_item", +] diff --git a/unityscraper/domains/backups/service.py b/unityscraper/domains/backups/service.py index 0978302..e2e2961 100644 --- a/unityscraper/domains/backups/service.py +++ b/unityscraper/domains/backups/service.py @@ -2,22 +2,25 @@ from __future__ import annotations -from backup_manager import ( +from backup_service import BackupRepository, BackupService, ensure_backup_schema + +from .models import ( BackupError, BackupItem, ConflictError, - FtpBackupClient, FtpTarget, InvalidPackageError, ScanResult, - StfsPackage, TransferResult, UnsafeArchiveError, +) +from .operations import ( + FtpBackupClient, install_stfs_package, scan_local_target, verify_backup_item, ) -from backup_service import BackupRepository, BackupService, ensure_backup_schema +from unityscraper.domains.packages.models import StfsPackage __all__ = [ "BackupError", @@ -37,4 +40,3 @@ "scan_local_target", "verify_backup_item", ] - diff --git a/unityscraper/domains/packages/__init__.py b/unityscraper/domains/packages/__init__.py index cb4100d..646c0b2 100644 --- a/unityscraper/domains/packages/__init__.py +++ b/unityscraper/domains/packages/__init__.py @@ -1,2 +1,24 @@ """STFS, XEX, XBE, and package inspection domain.""" +from __future__ import annotations + +from .inspectors import ( + extract_stfs_files, + inspect_stfs, + inspect_xbe, + inspect_xex, + list_stfs_entries, +) +from .models import StfsEntry, StfsPackage, XbePackage, XexPackage + +__all__ = [ + "StfsEntry", + "StfsPackage", + "XbePackage", + "XexPackage", + "extract_stfs_files", + "inspect_stfs", + "inspect_xbe", + "inspect_xex", + "list_stfs_entries", +] diff --git a/unityscraper/domains/packages/inspectors.py b/unityscraper/domains/packages/inspectors.py new file mode 100644 index 0000000..995a65b --- /dev/null +++ b/unityscraper/domains/packages/inspectors.py @@ -0,0 +1,19 @@ +"""Read-only package inspection operations.""" + +from __future__ import annotations + +from backup_manager import ( + extract_stfs_files, + inspect_stfs, + inspect_xbe, + inspect_xex, + list_stfs_entries, +) + +__all__ = [ + "extract_stfs_files", + "inspect_stfs", + "inspect_xbe", + "inspect_xex", + "list_stfs_entries", +] diff --git a/unityscraper/domains/packages/models.py b/unityscraper/domains/packages/models.py new file mode 100644 index 0000000..8ac17c0 --- /dev/null +++ b/unityscraper/domains/packages/models.py @@ -0,0 +1,7 @@ +"""Package inspection data models.""" + +from __future__ import annotations + +from backup_manager import StfsEntry, StfsPackage, XbePackage, XexPackage + +__all__ = ["StfsEntry", "StfsPackage", "XbePackage", "XexPackage"] diff --git a/unityscraper/domains/packages/service.py b/unityscraper/domains/packages/service.py index fec20a3..c8014be 100644 --- a/unityscraper/domains/packages/service.py +++ b/unityscraper/domains/packages/service.py @@ -2,17 +2,19 @@ from __future__ import annotations -from backup_manager import ( - StfsEntry, - StfsPackage, - XbePackage, - XexPackage, +from .inspectors import ( extract_stfs_files, inspect_stfs, inspect_xbe, inspect_xex, list_stfs_entries, ) +from .models import ( + StfsEntry, + StfsPackage, + XbePackage, + XexPackage, +) __all__ = [ "StfsEntry", @@ -25,4 +27,3 @@ "inspect_xex", "list_stfs_entries", ] - diff --git a/unityscraper/domains/profiles/__init__.py b/unityscraper/domains/profiles/__init__.py index 98a594c..e1b5fde 100644 --- a/unityscraper/domains/profiles/__init__.py +++ b/unityscraper/domains/profiles/__init__.py @@ -1,2 +1,25 @@ """Xbox 360 profile, save, and achievement inspection domain.""" +from __future__ import annotations + +from .models import ProfileInfo, ProfileScanResult, RestoreResult, SaveInfo +from .operations import find_content_root, mask_identifier +from .service import ( + ProfileSaveConflict, + ProfileSaveError, + ProfileSaveManager, + ProfileSaveScanner, +) + +__all__ = [ + "ProfileInfo", + "ProfileSaveConflict", + "ProfileSaveError", + "ProfileSaveManager", + "ProfileSaveScanner", + "ProfileScanResult", + "RestoreResult", + "SaveInfo", + "find_content_root", + "mask_identifier", +] diff --git a/unityscraper/domains/profiles/models.py b/unityscraper/domains/profiles/models.py new file mode 100644 index 0000000..8a2505f --- /dev/null +++ b/unityscraper/domains/profiles/models.py @@ -0,0 +1,12 @@ +"""Profile and save data models.""" + +from __future__ import annotations + +from profile_manager import ( + ProfileInfo, + ProfileScanResult, + RestoreResult, + SaveInfo, +) + +__all__ = ["ProfileInfo", "ProfileScanResult", "RestoreResult", "SaveInfo"] diff --git a/unityscraper/domains/profiles/operations.py b/unityscraper/domains/profiles/operations.py new file mode 100644 index 0000000..401c437 --- /dev/null +++ b/unityscraper/domains/profiles/operations.py @@ -0,0 +1,7 @@ +"""Profile and save discovery helpers.""" + +from __future__ import annotations + +from profile_manager import find_content_root, mask_identifier + +__all__ = ["find_content_root", "mask_identifier"] diff --git a/unityscraper/domains/profiles/service.py b/unityscraper/domains/profiles/service.py index d6c7a1a..abb57a7 100644 --- a/unityscraper/domains/profiles/service.py +++ b/unityscraper/domains/profiles/service.py @@ -3,18 +3,15 @@ from __future__ import annotations from profile_manager import ( - ProfileInfo, ProfileSaveConflict, ProfileSaveError, ProfileSaveManager, ProfileSaveScanner, - ProfileScanResult, - RestoreResult, - SaveInfo, - find_content_root, - mask_identifier, ) +from .models import ProfileInfo, ProfileScanResult, RestoreResult, SaveInfo +from .operations import find_content_root, mask_identifier + __all__ = [ "ProfileInfo", "ProfileSaveConflict", @@ -27,4 +24,3 @@ "find_content_root", "mask_identifier", ] - From a2777fbaa4b2a05a1af695c1c7210f60e834a166 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:50:40 -0400 Subject: [PATCH 2/9] Move backup schema ownership into domain --- MODULARIZATION_PLAN.md | 2 + backup_service.py | 69 +------------------- tests.py | 6 ++ unityscraper/domains/backups/__init__.py | 52 ++++++++++----- unityscraper/domains/backups/migrations.py | 76 ++++++++++++++++++++++ unityscraper/domains/backups/service.py | 3 +- 6 files changed, 121 insertions(+), 87 deletions(-) create mode 100644 unityscraper/domains/backups/migrations.py diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index 55538fc..a31a521 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -87,6 +87,8 @@ domain/ - `unityscraper.domains.packages` exposes read-only package models and inspectors. - `unityscraper.domains.backups` exposes backup models and operations. +- `unityscraper.domains.backups.migrations` owns the backup schema function; + `backup_service.ensure_backup_schema` remains import-compatible. - `unityscraper.domains.profiles` exposes profile/save models and helpers. ## Feature Ownership diff --git a/backup_service.py b/backup_service.py index 067ede4..fd4eb84 100644 --- a/backup_service.py +++ b/backup_service.py @@ -24,74 +24,7 @@ scan_local_target, verify_backup_item, ) - - -def ensure_backup_schema(connection: sqlite3.Connection) -> None: - """Create additive backup-manager tables on an existing connection.""" - connection.executescript( - """ - CREATE TABLE IF NOT EXISTS backup_targets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('local', 'ftp')), - location TEXT NOT NULL, - settings_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(kind, location) - ); - - CREATE TABLE IF NOT EXISTS backup_scans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - target_id INTEGER, - location TEXT NOT NULL, - status TEXT NOT NULL, - item_count INTEGER NOT NULL DEFAULT 0, - total_size INTEGER NOT NULL DEFAULT 0, - warnings_json TEXT, - started_at TEXT NOT NULL, - finished_at TEXT, - error_message TEXT, - FOREIGN KEY (target_id) REFERENCES backup_targets(id) - ); - - CREATE TABLE IF NOT EXISTS backup_inventory ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scan_id INTEGER NOT NULL, - titleid TEXT, - name TEXT NOT NULL, - format TEXT NOT NULL, - content_type TEXT, - media_id TEXT, - path TEXT NOT NULL, - size INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL, - notes_json TEXT, - FOREIGN KEY (scan_id) REFERENCES backup_scans(id) - ); - - CREATE TABLE IF NOT EXISTS backup_operations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - operation TEXT NOT NULL, - source TEXT NOT NULL, - destination TEXT, - status TEXT NOT NULL, - bytes_copied INTEGER NOT NULL DEFAULT 0, - sha256 TEXT, - details_json TEXT, - started_at TEXT NOT NULL, - finished_at TEXT, - error_message TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_backup_inventory_titleid - ON backup_inventory(titleid); - CREATE INDEX IF NOT EXISTS idx_backup_inventory_scan - ON backup_inventory(scan_id); - CREATE INDEX IF NOT EXISTS idx_backup_operations_status - ON backup_operations(status, started_at); - """ - ) +from unityscraper.domains.backups.migrations import ensure_backup_schema class BackupRepository: diff --git a/tests.py b/tests.py index 83e5b96..173a1f5 100644 --- a/tests.py +++ b/tests.py @@ -78,6 +78,7 @@ from unityscraper.core.paths import resource_path as package_resource_path from unityscraper.core.version import DISPLAY_VERSION as PACKAGE_DISPLAY_VERSION from unityscraper.domains.backups.service import BackupService as ModularBackupService +from unityscraper.domains.backups.migrations import ensure_backup_schema as DomainBackupSchema from unityscraper.domains.library.service import LibraryService as ModularLibraryService @@ -205,6 +206,11 @@ def test_domain_service_exports_preserve_existing_implementations(self): self.assertIs(ModularBackupService, BackupService) self.assertIs(ModularLibraryService, LibraryService) + def test_backup_schema_is_domain_owned_with_legacy_compatibility(self): + from backup_service import ensure_backup_schema as LegacyBackupSchema + + self.assertIs(LegacyBackupSchema, DomainBackupSchema) + def test_core_paths_match_legacy_asset_resolution(self): self.assertEqual(package_app_root(), Path.cwd()) self.assertTrue(package_resource_path("JSON.txt").is_file()) diff --git a/unityscraper/domains/backups/__init__.py b/unityscraper/domains/backups/__init__.py index a211029..fa8195e 100644 --- a/unityscraper/domains/backups/__init__.py +++ b/unityscraper/domains/backups/__init__.py @@ -2,24 +2,6 @@ from __future__ import annotations -from .models import ( - BackupError, - BackupItem, - ConflictError, - FtpTarget, - InvalidPackageError, - ScanResult, - TransferResult, - UnsafeArchiveError, -) -from .operations import ( - FtpBackupClient, - install_stfs_package, - scan_local_target, - verify_backup_item, -) -from .service import BackupRepository, BackupService, ensure_backup_schema - __all__ = [ "BackupError", "BackupItem", @@ -37,3 +19,37 @@ "scan_local_target", "verify_backup_item", ] + + +def __getattr__(name: str): + if name == "ensure_backup_schema": + from .migrations import ensure_backup_schema + + return ensure_backup_schema + if name in { + "BackupError", + "BackupItem", + "ConflictError", + "FtpTarget", + "InvalidPackageError", + "ScanResult", + "TransferResult", + "UnsafeArchiveError", + }: + from . import models + + return getattr(models, name) + if name in { + "FtpBackupClient", + "install_stfs_package", + "scan_local_target", + "verify_backup_item", + }: + from . import operations + + return getattr(operations, name) + if name in {"BackupRepository", "BackupService"}: + from . import service + + return getattr(service, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/unityscraper/domains/backups/migrations.py b/unityscraper/domains/backups/migrations.py new file mode 100644 index 0000000..c4f8fb5 --- /dev/null +++ b/unityscraper/domains/backups/migrations.py @@ -0,0 +1,76 @@ +"""Backup domain schema migrations.""" + +from __future__ import annotations + +import sqlite3 + + +def ensure_backup_schema(connection: sqlite3.Connection) -> None: + """Create additive backup-manager tables on an existing connection.""" + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS backup_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('local', 'ftp')), + location TEXT NOT NULL, + settings_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(kind, location) + ); + + CREATE TABLE IF NOT EXISTS backup_scans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER, + location TEXT NOT NULL, + status TEXT NOT NULL, + item_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + warnings_json TEXT, + started_at TEXT NOT NULL, + finished_at TEXT, + error_message TEXT, + FOREIGN KEY (target_id) REFERENCES backup_targets(id) + ); + + CREATE TABLE IF NOT EXISTS backup_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + titleid TEXT, + name TEXT NOT NULL, + format TEXT NOT NULL, + content_type TEXT, + media_id TEXT, + path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + notes_json TEXT, + FOREIGN KEY (scan_id) REFERENCES backup_scans(id) + ); + + CREATE TABLE IF NOT EXISTS backup_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + operation TEXT NOT NULL, + source TEXT NOT NULL, + destination TEXT, + status TEXT NOT NULL, + bytes_copied INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + details_json TEXT, + started_at TEXT NOT NULL, + finished_at TEXT, + error_message TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_backup_inventory_titleid + ON backup_inventory(titleid); + CREATE INDEX IF NOT EXISTS idx_backup_inventory_scan + ON backup_inventory(scan_id); + CREATE INDEX IF NOT EXISTS idx_backup_operations_status + ON backup_operations(status, started_at); + """ + ) + + +__all__ = ["ensure_backup_schema"] diff --git a/unityscraper/domains/backups/service.py b/unityscraper/domains/backups/service.py index e2e2961..8df19bc 100644 --- a/unityscraper/domains/backups/service.py +++ b/unityscraper/domains/backups/service.py @@ -2,7 +2,8 @@ from __future__ import annotations -from backup_service import BackupRepository, BackupService, ensure_backup_schema +from backup_service import BackupRepository, BackupService +from unityscraper.domains.backups.migrations import ensure_backup_schema from .models import ( BackupError, From 57e5411f7d4044812cb4bdf7d15468bbdd19d09a Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:53:20 -0400 Subject: [PATCH 3/9] Add package inspection domain commands --- MODULARIZATION_PLAN.md | 2 + tests.py | 35 +++++++++++++++ unityscraper/domains/packages/__init__.py | 3 ++ unityscraper/domains/packages/commands.py | 54 +++++++++++++++++++++++ unityscraper/domains/packages/service.py | 3 ++ 5 files changed, 97 insertions(+) create mode 100644 unityscraper/domains/packages/commands.py diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index a31a521..e6a2367 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -86,6 +86,8 @@ domain/ package command discovery does not import the full scraper runtime. - `unityscraper.domains.packages` exposes read-only package models and inspectors. +- `unityscraper.domains.packages.commands` exposes the first UI-neutral + package use cases for STFS inspection and file-table inventory. - `unityscraper.domains.backups` exposes backup models and operations. - `unityscraper.domains.backups.migrations` owns the backup schema function; `backup_service.ensure_backup_schema` remains import-compatible. diff --git a/tests.py b/tests.py index 173a1f5..bed151a 100644 --- a/tests.py +++ b/tests.py @@ -80,6 +80,7 @@ from unityscraper.domains.backups.service import BackupService as ModularBackupService from unityscraper.domains.backups.migrations import ensure_backup_schema as DomainBackupSchema from unityscraper.domains.library.service import LibraryService as ModularLibraryService +from unityscraper.domains.packages.commands import InspectStfsPackage, InventoryStfsFileTable class TestPlatformSupport(unittest.TestCase): @@ -211,6 +212,40 @@ def test_backup_schema_is_domain_owned_with_legacy_compatibility(self): self.assertIs(LegacyBackupSchema, DomainBackupSchema) + def test_package_inspection_command_returns_job_result(self): + root = Path(tempfile.mkdtemp()) + try: + package = root / "save.bin" + header = bytearray(0x1791) + header[:4] = b"CON " + header[0x344:0x348] = (1).to_bytes(4, "big") + header[0x354:0x358] = bytes.fromhex("12345678") + header[0x360:0x364] = bytes.fromhex("53510804") + title = "Hitman: Absolution".encode("utf-16-be") + header[0x411:0x411 + len(title)] = title + package.write_bytes(header) + + result = InspectStfsPackage().run(package) + + self.assertEqual(result.status, "completed") + self.assertEqual(result.payload["package"]["title_id"], "53510804") + self.assertEqual(result.payload["package"]["display_name"], "Hitman: Absolution") + finally: + shutil.rmtree(root) + + def test_package_inventory_command_returns_failed_job_result(self): + root = Path(tempfile.mkdtemp()) + try: + package = root / "invalid.bin" + package.write_bytes(b"not a package") + + result = InventoryStfsFileTable().run(package) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.payload["source"], str(package)) + finally: + shutil.rmtree(root) + def test_core_paths_match_legacy_asset_resolution(self): self.assertEqual(package_app_root(), Path.cwd()) self.assertTrue(package_resource_path("JSON.txt").is_file()) diff --git a/unityscraper/domains/packages/__init__.py b/unityscraper/domains/packages/__init__.py index 646c0b2..d7822bb 100644 --- a/unityscraper/domains/packages/__init__.py +++ b/unityscraper/domains/packages/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .commands import InspectStfsPackage, InventoryStfsFileTable from .inspectors import ( extract_stfs_files, inspect_stfs, @@ -16,6 +17,8 @@ "StfsPackage", "XbePackage", "XexPackage", + "InspectStfsPackage", + "InventoryStfsFileTable", "extract_stfs_files", "inspect_stfs", "inspect_xbe", diff --git a/unityscraper/domains/packages/commands.py b/unityscraper/domains/packages/commands.py new file mode 100644 index 0000000..6528414 --- /dev/null +++ b/unityscraper/domains/packages/commands.py @@ -0,0 +1,54 @@ +"""UI-neutral package inspection use cases.""" + +from __future__ import annotations + +from dataclasses import asdict +from pathlib import Path + +from unityscraper.core.jobs import JobResult + +from .inspectors import inspect_stfs, list_stfs_entries + + +class InspectStfsPackage: + """Read public STFS metadata without modifying the package.""" + + def run(self, source: str | Path) -> JobResult: + path = Path(source) + try: + package = inspect_stfs(path) + except Exception as exc: + return JobResult.failed( + "STFS inspection failed", + source=str(path), + error=str(exc), + ) + return JobResult.completed( + "STFS inspection completed", + source=str(path), + package=asdict(package), + ) + + +class InventoryStfsFileTable: + """Read the supported consecutive STFS file table without extraction.""" + + def run(self, source: str | Path, *, max_entries: int = 100_000) -> JobResult: + path = Path(source) + try: + entries = list_stfs_entries(path, max_entries=max_entries) + except Exception as exc: + return JobResult.failed( + "STFS file table inventory failed", + source=str(path), + error=str(exc), + ) + return JobResult.completed( + "STFS file table inventory completed", + source=str(path), + entry_count=len(entries), + entries=[asdict(entry) for entry in entries], + ) + + +__all__ = ["InspectStfsPackage", "InventoryStfsFileTable"] diff --git a/unityscraper/domains/packages/service.py b/unityscraper/domains/packages/service.py index c8014be..a7ad570 100644 --- a/unityscraper/domains/packages/service.py +++ b/unityscraper/domains/packages/service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .commands import InspectStfsPackage, InventoryStfsFileTable from .inspectors import ( extract_stfs_files, inspect_stfs, @@ -21,6 +22,8 @@ "StfsPackage", "XbePackage", "XexPackage", + "InspectStfsPackage", + "InventoryStfsFileTable", "extract_stfs_files", "inspect_stfs", "inspect_xbe", From 6f34a74a6a3dbc1111d47ebbff739133d747fb4b Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:55:30 -0400 Subject: [PATCH 4/9] Add shared job runner foundation --- MODULARIZATION_PLAN.md | 3 ++ tests.py | 39 +++++++++++++- unityscraper/core/jobs.py | 111 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index e6a2367..9ff82f1 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -82,6 +82,9 @@ domain/ compatibility wrapper. - `unityscraper.core.metadata` exposes app name, slug, and version metadata for UI, CLI, API, diagnostics, and packaging. +- `unityscraper.core.jobs` exposes shared job results, progress events, + cancellation tokens, contexts, and a synchronous runner for future desktop, + CLI, and API workflow reuse. - `unityscraper.app.cli` has a command registry and lazy legacy CLI adapter so package command discovery does not import the full scraper runtime. - `unityscraper.domains.packages` exposes read-only package models and diff --git a/tests.py b/tests.py index bed151a..b63a98d 100644 --- a/tests.py +++ b/tests.py @@ -73,7 +73,7 @@ from unityscraper.app.cli.legacy import run_legacy_cli from unityscraper.core import APP_METADATA from unityscraper.core.db import MigrationRegistry -from unityscraper.core.jobs import JobProgress, JobResult +from unityscraper.core.jobs import CancellationToken, JobProgress, JobResult, JobRunner from unityscraper.core.paths import app_root as package_app_root from unityscraper.core.paths import resource_path as package_resource_path from unityscraper.core.version import DISPLAY_VERSION as PACKAGE_DISPLAY_VERSION @@ -293,6 +293,7 @@ def test_job_progress_percent_is_bounded(self): def test_job_result_factories_set_terminal_state(self): completed = JobResult.completed("done", count=2) failed = JobResult.failed("failed", reason="example") + cancelled = JobResult.cancelled() self.assertEqual(completed.status, "completed") self.assertEqual(completed.payload["count"], 2) @@ -300,6 +301,42 @@ def test_job_result_factories_set_terminal_state(self): self.assertEqual(failed.status, "failed") self.assertEqual(failed.payload["reason"], "example") self.assertIsNotNone(failed.finished_at) + self.assertEqual(cancelled.status, "cancelled") + self.assertIsNotNone(cancelled.finished_at) + + def test_job_runner_normalizes_success_failure_and_progress(self): + progress = [] + runner = JobRunner(progress_callback=progress.append) + + success = runner.run( + "example", + lambda context: JobResult.completed("done", name=context.name), + ) + + failure = runner.run( + "failing", + lambda context: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + self.assertEqual(success.status, "completed") + self.assertEqual(success.payload["name"], "example") + self.assertEqual(failure.status, "failed") + self.assertEqual(failure.payload["job"], "failing") + self.assertGreaterEqual(len(progress), 4) + self.assertEqual(progress[0].message, "example started") + + def test_job_runner_honors_pre_cancelled_token(self): + token = CancellationToken() + token.cancel() + + result = JobRunner().run( + "cancelled", + lambda context: JobResult.completed("should not run"), + token=token, + ) + + self.assertEqual(result.status, "cancelled") + self.assertEqual(result.payload["job"], "cancelled") def test_domain_migration_registry_applies_once(self): calls = [] diff --git a/unityscraper/core/jobs.py b/unityscraper/core/jobs.py index 69cadd2..d837ed7 100644 --- a/unityscraper/core/jobs.py +++ b/unityscraper/core/jobs.py @@ -2,11 +2,14 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Literal JobStatus = Literal["queued", "running", "completed", "failed", "cancelled"] +ProgressCallback = Callable[["JobProgress"], None] +JobOperation = Callable[["JobContext"], "JobResult"] def utc_now() -> str: @@ -31,6 +34,56 @@ def percent(self) -> float | None: return min(100.0, max(0.0, (self.current / self.total) * 100.0)) +class JobCancelled(RuntimeError): + """Raised when a job notices a cancellation request.""" + + +@dataclass +class CancellationToken: + """Small cooperative cancellation token shared with long operations.""" + + requested: bool = False + + def cancel(self) -> None: + self.requested = True + + def throw_if_cancelled(self) -> None: + if self.requested: + raise JobCancelled("Job was cancelled") + + +@dataclass +class JobContext: + """Context passed to a UI-neutral job operation.""" + + name: str + token: CancellationToken = field(default_factory=CancellationToken) + progress_callback: ProgressCallback | None = None + + def emit( + self, + message: str, + *, + status: JobStatus = "running", + current: int = 0, + total: int = 0, + **details: Any, + ) -> JobProgress: + progress = JobProgress( + status=status, + message=message, + current=current, + total=total, + details=details, + ) + if self.progress_callback is not None: + self.progress_callback(progress) + return progress + + def throw_if_cancelled(self) -> None: + self.token.throw_if_cancelled() + + @dataclass(frozen=True) class JobResult: """A UI-neutral operation result.""" @@ -63,3 +116,61 @@ def failed(cls, message: str, **payload: Any) -> "JobResult": finished_at=now, ) + @classmethod + def cancelled(cls, message: str = "Job was cancelled", **payload: Any) -> "JobResult": + now = utc_now() + return cls( + status="cancelled", + message=message, + payload=payload, + started_at=now, + finished_at=now, + ) + + +@dataclass(frozen=True) +class JobRunner: + """Run a UI-neutral operation and normalize its terminal result.""" + + progress_callback: ProgressCallback | None = None + + def run( + self, + name: str, + operation: JobOperation, + *, + token: CancellationToken | None = None, + ) -> JobResult: + context = JobContext( + name=name, + token=token or CancellationToken(), + progress_callback=self.progress_callback, + ) + context.emit(f"{name} started", status="running") + try: + context.throw_if_cancelled() + result = operation(context) + context.emit(result.message, status=result.status) + return result + except JobCancelled as exc: + result = JobResult.cancelled(str(exc), job=name) + context.emit(result.message, status="cancelled") + return result + except Exception as exc: + result = JobResult.failed(str(exc), job=name) + context.emit(result.message, status="failed") + return result + + +__all__ = [ + "CancellationToken", + "JobCancelled", + "JobContext", + "JobOperation", + "JobProgress", + "JobResult", + "JobRunner", + "JobStatus", + "ProgressCallback", + "utc_now", +] From 249894f9c5aabd89e619650222a161dfb65e59ab Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:57:32 -0400 Subject: [PATCH 5/9] Keep app surface adapters lazy --- MODULARIZATION_PLAN.md | 2 ++ tests.py | 11 +++++++++++ unityscraper/app/api/__init__.py | 13 +++++++++++++ unityscraper/app/api/entrypoint.py | 17 +++++++++++++++-- unityscraper/app/desktop/entrypoint.py | 9 +++++++-- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index 9ff82f1..67feedf 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -87,6 +87,8 @@ domain/ CLI, and API workflow reuse. - `unityscraper.app.cli` has a command registry and lazy legacy CLI adapter so package command discovery does not import the full scraper runtime. +- `unityscraper.app.api` and `unityscraper.app.desktop` keep package imports + light by importing their legacy runtime only when their surface starts. - `unityscraper.domains.packages` exposes read-only package models and inspectors. - `unityscraper.domains.packages.commands` exposes the first UI-neutral diff --git a/tests.py b/tests.py index b63a98d..0770989 100644 --- a/tests.py +++ b/tests.py @@ -69,8 +69,10 @@ from app_paths import resolve_storage_paths from platform_support import desktop_font_family, path_opener_command from profile_manager import ProfileSaveManager, find_content_root, mask_identifier +from unityscraper.app.api.entrypoint import create_api from unityscraper.app.cli import CliCommand, CliCommandRegistry, build_cli_registry from unityscraper.app.cli.legacy import run_legacy_cli +from unityscraper.app.desktop.entrypoint import main as package_desktop_main from unityscraper.core import APP_METADATA from unityscraper.core.db import MigrationRegistry from unityscraper.core.jobs import CancellationToken, JobProgress, JobResult, JobRunner @@ -279,6 +281,15 @@ def test_legacy_cli_adapter_restores_sys_argv(self): self.assertEqual(sys.argv, original) self.assertEqual(legacy.call_count, 1) + def test_app_surface_entrypoints_delegate_lazily(self): + with patch("desktop_app.main", return_value=0) as desktop: + self.assertEqual(package_desktop_main(), 0) + with patch("api.UnityScraperAPI", return_value="api") as api_class: + self.assertEqual(create_api(), "api") + + self.assertEqual(desktop.call_count, 1) + self.assertEqual(api_class.call_count, 1) + def test_job_progress_percent_is_bounded(self): self.assertEqual( JobProgress(status="running", message="working", current=5, total=10).percent, diff --git a/unityscraper/app/api/__init__.py b/unityscraper/app/api/__init__.py index f776a09..e785e68 100644 --- a/unityscraper/app/api/__init__.py +++ b/unityscraper/app/api/__init__.py @@ -1,2 +1,15 @@ """Local REST API application adapters.""" +from __future__ import annotations + +from .entrypoint import create_api + +__all__ = ["UnityScraperAPI", "create_api"] + + +def __getattr__(name: str): + if name == "UnityScraperAPI": + from .entrypoint import UnityScraperAPI + + return UnityScraperAPI + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/unityscraper/app/api/entrypoint.py b/unityscraper/app/api/entrypoint.py index 17e66cf..a7ded13 100644 --- a/unityscraper/app/api/entrypoint.py +++ b/unityscraper/app/api/entrypoint.py @@ -2,7 +2,20 @@ from __future__ import annotations -from api import UnityScraperAPI -__all__ = ["UnityScraperAPI"] +def create_api(*args, **kwargs): + """Create the local REST API without importing Flask at package import time.""" + from api import UnityScraperAPI + return UnityScraperAPI(*args, **kwargs) + + +def __getattr__(name: str): + if name == "UnityScraperAPI": + from api import UnityScraperAPI + + return UnityScraperAPI + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["UnityScraperAPI", "create_api"] diff --git a/unityscraper/app/desktop/entrypoint.py b/unityscraper/app/desktop/entrypoint.py index d34fe4d..e174a8b 100644 --- a/unityscraper/app/desktop/entrypoint.py +++ b/unityscraper/app/desktop/entrypoint.py @@ -2,7 +2,12 @@ from __future__ import annotations -from desktop_app import main -__all__ = ["main"] +def main() -> int: + """Run the desktop app through the package-owned entry point.""" + from desktop_app import main as desktop_main + + return desktop_main() + +__all__ = ["main"] From 8d2b1c37666f3ac23707fb7a243172d32020353e Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:00:27 -0400 Subject: [PATCH 6/9] Add knowledge library and tool domain exports --- MODULARIZATION_PLAN.md | 3 +++ tests.py | 10 ++++++++-- unityscraper/domains/knowledge/__init__.py | 15 +++++++++++++++ unityscraper/domains/knowledge/models.py | 8 ++++++++ unityscraper/domains/knowledge/repository.py | 8 ++++++++ unityscraper/domains/knowledge/service.py | 13 +++++++++++-- unityscraper/domains/library/__init__.py | 13 +++++++++++++ unityscraper/domains/library/catalog.py | 8 ++++++++ unityscraper/domains/library/models.py | 9 +++++++++ unityscraper/domains/library/service.py | 8 +++++--- unityscraper/domains/tools/__init__.py | 19 +++++++++++++++++++ unityscraper/domains/tools/catalog.py | 8 ++++++++ unityscraper/domains/tools/models.py | 9 +++++++++ unityscraper/domains/tools/runner.py | 18 ++++++++++++++++++ unityscraper/domains/tools/service.py | 12 +++++++++--- 15 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 unityscraper/domains/knowledge/models.py create mode 100644 unityscraper/domains/knowledge/repository.py create mode 100644 unityscraper/domains/library/catalog.py create mode 100644 unityscraper/domains/library/models.py create mode 100644 unityscraper/domains/tools/catalog.py create mode 100644 unityscraper/domains/tools/models.py create mode 100644 unityscraper/domains/tools/runner.py diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index 67feedf..6de4c85 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -97,6 +97,9 @@ domain/ - `unityscraper.domains.backups.migrations` owns the backup schema function; `backup_service.ensure_backup_schema` remains import-compatible. - `unityscraper.domains.profiles` exposes profile/save models and helpers. +- `unityscraper.domains.knowledge`, `unityscraper.domains.library`, and + `unityscraper.domains.tools` expose first-class model, repository, catalog, + and runner modules around their existing implementations. ## Feature Ownership diff --git a/tests.py b/tests.py index 0770989..c16f642 100644 --- a/tests.py +++ b/tests.py @@ -35,7 +35,7 @@ split_arguments, ) from external_tools_gui import bundled_xextool_path -from tool_catalog import ToolCatalog, operation_for +from tool_catalog import ToolCatalog, ToolDefinition, operation_for from knowledge_service import KnowledgeService from knowledge_sources import ( CachedHttpClient, @@ -44,7 +44,7 @@ SourceInfo, ) from offline_knowledge import OfflineKnowledgeArchive -from library_service import LibraryService +from library_service import GameSummary, LibraryService from modern_gui import LE_FLUFFIE_CREATOR, XEXTOOL_CREATOR, navigation_shortcut from title_catalog import XboxUnityTitleCatalog from wiki_adapters import extract_article_text, parse_sitemap @@ -81,8 +81,11 @@ from unityscraper.core.version import DISPLAY_VERSION as PACKAGE_DISPLAY_VERSION from unityscraper.domains.backups.service import BackupService as ModularBackupService from unityscraper.domains.backups.migrations import ensure_backup_schema as DomainBackupSchema +from unityscraper.domains.knowledge.models import EntityRecord as ModularEntityRecord +from unityscraper.domains.library.models import GameSummary as ModularGameSummary from unityscraper.domains.library.service import LibraryService as ModularLibraryService from unityscraper.domains.packages.commands import InspectStfsPackage, InventoryStfsFileTable +from unityscraper.domains.tools.models import ToolDefinition as ModularToolDefinition class TestPlatformSupport(unittest.TestCase): @@ -208,6 +211,9 @@ class TestModularFoundation(unittest.TestCase): def test_domain_service_exports_preserve_existing_implementations(self): self.assertIs(ModularBackupService, BackupService) self.assertIs(ModularLibraryService, LibraryService) + self.assertIs(ModularGameSummary, GameSummary) + self.assertIs(ModularEntityRecord, EntityRecord) + self.assertIs(ModularToolDefinition, ToolDefinition) def test_backup_schema_is_domain_owned_with_legacy_compatibility(self): from backup_service import ensure_backup_schema as LegacyBackupSchema diff --git a/unityscraper/domains/knowledge/__init__.py b/unityscraper/domains/knowledge/__init__.py index 51c6c90..d1cf893 100644 --- a/unityscraper/domains/knowledge/__init__.py +++ b/unityscraper/domains/knowledge/__init__.py @@ -1,2 +1,17 @@ """Source-attributed knowledge and offline reference domain.""" +from __future__ import annotations + +from .models import EntityRecord, Fact, Identifier +from .repository import KnowledgeRepository, is_unknown, normalize_titleid +from .service import KnowledgeService + +__all__ = [ + "EntityRecord", + "Fact", + "Identifier", + "KnowledgeRepository", + "KnowledgeService", + "is_unknown", + "normalize_titleid", +] diff --git a/unityscraper/domains/knowledge/models.py b/unityscraper/domains/knowledge/models.py new file mode 100644 index 0000000..8c2dbff --- /dev/null +++ b/unityscraper/domains/knowledge/models.py @@ -0,0 +1,8 @@ +"""Knowledge domain data models.""" + +from __future__ import annotations + +from knowledge_base import EntityRecord, Fact, Identifier + +__all__ = ["EntityRecord", "Fact", "Identifier"] + diff --git a/unityscraper/domains/knowledge/repository.py b/unityscraper/domains/knowledge/repository.py new file mode 100644 index 0000000..2b15c7e --- /dev/null +++ b/unityscraper/domains/knowledge/repository.py @@ -0,0 +1,8 @@ +"""Knowledge domain repository exports.""" + +from __future__ import annotations + +from knowledge_base import KnowledgeRepository, is_unknown, normalize_titleid + +__all__ = ["KnowledgeRepository", "is_unknown", "normalize_titleid"] + diff --git a/unityscraper/domains/knowledge/service.py b/unityscraper/domains/knowledge/service.py index a4b8212..5ce4c71 100644 --- a/unityscraper/domains/knowledge/service.py +++ b/unityscraper/domains/knowledge/service.py @@ -2,8 +2,17 @@ from __future__ import annotations -from knowledge_base import KnowledgeRepository, is_unknown from knowledge_service import KnowledgeService -__all__ = ["KnowledgeRepository", "KnowledgeService", "is_unknown"] +from .models import EntityRecord, Fact, Identifier +from .repository import KnowledgeRepository, is_unknown, normalize_titleid +__all__ = [ + "EntityRecord", + "Fact", + "Identifier", + "KnowledgeRepository", + "KnowledgeService", + "is_unknown", + "normalize_titleid", +] diff --git a/unityscraper/domains/library/__init__.py b/unityscraper/domains/library/__init__.py index 2a549a0..ca394e9 100644 --- a/unityscraper/domains/library/__init__.py +++ b/unityscraper/domains/library/__init__.py @@ -1,2 +1,15 @@ """Library and XboxUnity title/update domain.""" +from __future__ import annotations + +from .catalog import XboxUnityTitleCatalog +from .models import CatalogSyncResult, GameSummary, TitleSuggestion +from .service import LibraryService + +__all__ = [ + "CatalogSyncResult", + "GameSummary", + "LibraryService", + "TitleSuggestion", + "XboxUnityTitleCatalog", +] diff --git a/unityscraper/domains/library/catalog.py b/unityscraper/domains/library/catalog.py new file mode 100644 index 0000000..173dff0 --- /dev/null +++ b/unityscraper/domains/library/catalog.py @@ -0,0 +1,8 @@ +"""XboxUnity title catalog exports.""" + +from __future__ import annotations + +from title_catalog import XboxUnityTitleCatalog + +__all__ = ["XboxUnityTitleCatalog"] + diff --git a/unityscraper/domains/library/models.py b/unityscraper/domains/library/models.py new file mode 100644 index 0000000..bffc112 --- /dev/null +++ b/unityscraper/domains/library/models.py @@ -0,0 +1,9 @@ +"""Library and catalog data models.""" + +from __future__ import annotations + +from library_service import GameSummary +from title_catalog import CatalogSyncResult, TitleSuggestion + +__all__ = ["CatalogSyncResult", "GameSummary", "TitleSuggestion"] + diff --git a/unityscraper/domains/library/service.py b/unityscraper/domains/library/service.py index 5f574a3..0b37edf 100644 --- a/unityscraper/domains/library/service.py +++ b/unityscraper/domains/library/service.py @@ -2,13 +2,15 @@ from __future__ import annotations -from library_service import GameSummary, LibraryService -from title_catalog import TitleSuggestion, XboxUnityTitleCatalog +from library_service import LibraryService + +from .catalog import XboxUnityTitleCatalog +from .models import CatalogSyncResult, GameSummary, TitleSuggestion __all__ = [ + "CatalogSyncResult", "GameSummary", "LibraryService", "TitleSuggestion", "XboxUnityTitleCatalog", ] - diff --git a/unityscraper/domains/tools/__init__.py b/unityscraper/domains/tools/__init__.py index 4e207b0..37b37f3 100644 --- a/unityscraper/domains/tools/__init__.py +++ b/unityscraper/domains/tools/__init__.py @@ -1,2 +1,21 @@ """External tool catalog and execution domain.""" +from __future__ import annotations + +from .catalog import ToolCatalog, operation_for, platform_key +from .models import ToolDefinition, ToolLaunch, ToolOperation, ToolResult +from .runner import ExternalToolError, ExternalToolRunner, format_command, split_arguments + +__all__ = [ + "ExternalToolError", + "ExternalToolRunner", + "ToolCatalog", + "ToolDefinition", + "ToolLaunch", + "ToolOperation", + "ToolResult", + "format_command", + "operation_for", + "platform_key", + "split_arguments", +] diff --git a/unityscraper/domains/tools/catalog.py b/unityscraper/domains/tools/catalog.py new file mode 100644 index 0000000..e405a30 --- /dev/null +++ b/unityscraper/domains/tools/catalog.py @@ -0,0 +1,8 @@ +"""External tool catalog exports.""" + +from __future__ import annotations + +from tool_catalog import ToolCatalog, operation_for, platform_key + +__all__ = ["ToolCatalog", "operation_for", "platform_key"] + diff --git a/unityscraper/domains/tools/models.py b/unityscraper/domains/tools/models.py new file mode 100644 index 0000000..4bee02f --- /dev/null +++ b/unityscraper/domains/tools/models.py @@ -0,0 +1,9 @@ +"""External tool domain data models.""" + +from __future__ import annotations + +from external_tools import ToolLaunch, ToolResult +from tool_catalog import ToolDefinition, ToolOperation + +__all__ = ["ToolDefinition", "ToolLaunch", "ToolOperation", "ToolResult"] + diff --git a/unityscraper/domains/tools/runner.py b/unityscraper/domains/tools/runner.py new file mode 100644 index 0000000..8c0af0c --- /dev/null +++ b/unityscraper/domains/tools/runner.py @@ -0,0 +1,18 @@ +"""External tool execution exports.""" + +from __future__ import annotations + +from external_tools import ( + ExternalToolError, + ExternalToolRunner, + format_command, + split_arguments, +) + +__all__ = [ + "ExternalToolError", + "ExternalToolRunner", + "format_command", + "split_arguments", +] + diff --git a/unityscraper/domains/tools/service.py b/unityscraper/domains/tools/service.py index 9af7b71..1b186da 100644 --- a/unityscraper/domains/tools/service.py +++ b/unityscraper/domains/tools/service.py @@ -2,14 +2,20 @@ from __future__ import annotations -from external_tools import ExternalToolError, ExternalToolRunner, ToolLaunch, ToolResult -from tool_catalog import ToolCatalog +from .catalog import ToolCatalog, operation_for, platform_key +from .models import ToolDefinition, ToolLaunch, ToolOperation, ToolResult +from .runner import ExternalToolError, ExternalToolRunner, format_command, split_arguments __all__ = [ "ExternalToolError", "ExternalToolRunner", + "ToolDefinition", "ToolCatalog", "ToolLaunch", + "ToolOperation", "ToolResult", + "format_command", + "operation_for", + "platform_key", + "split_arguments", ] - From 3638583c874b4b85ddf7268ed89d8a051ca70379 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:01:56 -0400 Subject: [PATCH 7/9] Collect package modules in PyInstaller build --- UnityScraper.spec | 4 +++- tests.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/UnityScraper.spec b/UnityScraper.spec index 60deab4..d567233 100644 --- a/UnityScraper.spec +++ b/UnityScraper.spec @@ -3,6 +3,8 @@ import sys from pathlib import Path +from PyInstaller.utils.hooks import collect_submodules + icon = 'assets/UnityScraper.ico' if sys.platform == 'win32' else None tk_datas = [] @@ -24,7 +26,7 @@ a = Analysis( ('THIRD_PARTY_NOTICES.md', '.'), ('assets', 'assets'), ] + tk_datas, - hiddenimports=[ + hiddenimports=collect_submodules('unityscraper') + [ 'backup_gui', 'backup_manager', 'backup_service', diff --git a/tests.py b/tests.py index c16f642..032adb7 100644 --- a/tests.py +++ b/tests.py @@ -204,6 +204,11 @@ def test_linux_desktop_metadata_is_complete(self): "io.github.trapemall.UnityScraper", ) + def test_pyinstaller_collects_package_modules(self): + spec = Path("UnityScraper.spec").read_text(encoding="utf-8") + + self.assertIn("collect_submodules('unityscraper')", spec) + class TestModularFoundation(unittest.TestCase): """Test package-level adapters that support the modular architecture.""" From 6f6f594c18ab295c310f942af3f2ba3c10970514 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:12:36 -0400 Subject: [PATCH 8/9] Fix lazy API adapter lint --- unityscraper/app/api/__init__.py | 2 +- unityscraper/app/api/entrypoint.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/unityscraper/app/api/__init__.py b/unityscraper/app/api/__init__.py index e785e68..a279de2 100644 --- a/unityscraper/app/api/__init__.py +++ b/unityscraper/app/api/__init__.py @@ -4,7 +4,7 @@ from .entrypoint import create_api -__all__ = ["UnityScraperAPI", "create_api"] +__all__ = ["create_api"] def __getattr__(name: str): diff --git a/unityscraper/app/api/entrypoint.py b/unityscraper/app/api/entrypoint.py index a7ded13..310b24f 100644 --- a/unityscraper/app/api/entrypoint.py +++ b/unityscraper/app/api/entrypoint.py @@ -18,4 +18,4 @@ def __getattr__(name: str): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -__all__ = ["UnityScraperAPI", "create_api"] +__all__ = ["create_api"] From c0aaf4766a77849650e6a6b25afdede1be661d58 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:16:00 -0400 Subject: [PATCH 9/9] Check package version source in CI --- scripts/check_version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check_version.py b/scripts/check_version.py index 16f44b1..5eb5839 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -14,10 +14,10 @@ def read_versions() -> dict[str, str]: - app_text = (ROOT / "app_version.py").read_text(encoding="utf-8") + app_text = (ROOT / "unityscraper/core/version.py").read_text(encoding="utf-8") app_match = re.search(r'^APP_VERSION\s*=\s*"([^"]+)"', app_text, re.MULTILINE) if not app_match: - raise RuntimeError("APP_VERSION was not found in app_version.py") + raise RuntimeError("APP_VERSION was not found in unityscraper/core/version.py") version_data = json.loads((ROOT / "VERSION").read_text(encoding="utf-8")) pyproject_text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") @@ -37,7 +37,7 @@ def read_versions() -> dict[str, str]: raise RuntimeError("A release version was not found in Linux AppStream metadata") return { - "app_version.py": app_match.group(1), + "unityscraper/core/version.py": app_match.group(1), "VERSION": str(version_data["version"]), "pyproject.toml": project_match.group(1), "Linux AppStream metadata": str(release.get("version")),