diff --git a/MODULARIZATION_PLAN.md b/MODULARIZATION_PLAN.md index 40cad6c..6de4c85 100644 --- a/MODULARIZATION_PLAN.md +++ b/MODULARIZATION_PLAN.md @@ -74,6 +74,33 @@ 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.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.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 + 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. +- `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 | Domain | Owns | @@ -93,4 +120,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/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/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/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/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")), diff --git a/tests.py b/tests.py index 3182004..032adb7 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 @@ -69,10 +69,23 @@ 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 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 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): @@ -191,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.""" @@ -198,6 +216,90 @@ 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 + + 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()) + + 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_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( @@ -213,6 +315,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) @@ -220,6 +323,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/app/api/__init__.py b/unityscraper/app/api/__init__.py index f776a09..a279de2 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__ = ["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..310b24f 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__ = ["create_api"] 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/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"] 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/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", +] 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..fa8195e 100644 --- a/unityscraper/domains/backups/__init__.py +++ b/unityscraper/domains/backups/__init__.py @@ -1,2 +1,55 @@ """Owned-content backup, inventory, and transfer domain.""" +from __future__ import annotations + +__all__ = [ + "BackupError", + "BackupItem", + "BackupRepository", + "BackupService", + "ConflictError", + "FtpBackupClient", + "FtpTarget", + "InvalidPackageError", + "ScanResult", + "TransferResult", + "UnsafeArchiveError", + "ensure_backup_schema", + "install_stfs_package", + "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/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..8df19bc 100644 --- a/unityscraper/domains/backups/service.py +++ b/unityscraper/domains/backups/service.py @@ -2,22 +2,26 @@ from __future__ import annotations -from backup_manager import ( +from backup_service import BackupRepository, BackupService +from unityscraper.domains.backups.migrations import 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 +41,3 @@ "scan_local_target", "verify_backup_item", ] - 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/packages/__init__.py b/unityscraper/domains/packages/__init__.py index cb4100d..d7822bb 100644 --- a/unityscraper/domains/packages/__init__.py +++ b/unityscraper/domains/packages/__init__.py @@ -1,2 +1,27 @@ """STFS, XEX, XBE, and package inspection domain.""" +from __future__ import annotations + +from .commands import InspectStfsPackage, InventoryStfsFileTable +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", + "InspectStfsPackage", + "InventoryStfsFileTable", + "extract_stfs_files", + "inspect_stfs", + "inspect_xbe", + "inspect_xex", + "list_stfs_entries", +] 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/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..a7ad570 100644 --- a/unityscraper/domains/packages/service.py +++ b/unityscraper/domains/packages/service.py @@ -2,27 +2,31 @@ from __future__ import annotations -from backup_manager import ( - StfsEntry, - StfsPackage, - XbePackage, - XexPackage, +from .commands import InspectStfsPackage, InventoryStfsFileTable +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", + "InspectStfsPackage", + "InventoryStfsFileTable", "extract_stfs_files", "inspect_stfs", "inspect_xbe", "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", ] - 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", ] -