From ddd373545f40a2a98634a18e97e9b7556941dae3 Mon Sep 17 00:00:00 2001 From: falbany Date: Wed, 4 Nov 2026 22:30:12 +0100 Subject: [PATCH] chore(types): configure pyright and clear the error backlog (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A [tool.pyright] section in pyproject.toml (basic mode, .venv env) makes the documented `pyright src/profiles` gate real: 81 errors → 0. - declare hatchling in both dev lists so the build-hook import resolves (also pre-completes #43's hatchling declaration) - execution.py: replace the dict[str, object] Popen kwargs with explicit per-platform calls (no behavior change) - inheritance.py: replace the untyped pick() closure with typed per-shape helpers; resolve_configs now produces the declared list-shaped schema fields (tests already pinned lists) - reader.py: explicit schema→model conversion (MatchCriteria + tuples) instead of relying on __post_init__ coercion - models.py: FailMode Literal defined here (bottom of the core dependency graph) and re-exported by workflow.py; launch_hook_failmode is typed FailMode - columns chain typed dict[str, ColumnConfiguration] across classifier/scanner/scan route; the _iter_columns workaround is deleted while its non-dict contract is kept via isinstance (pinned by test_non_dict_columns_ignored) - real bugs found by the gate: app.py run_headless accessed cfg.directory (a nonexistent field) and crashed whenever machine configurations existed — now iterates cfg.scan; handoff.py could hit an unbound `detail` when reading the HTTPError body failed; matcher.py returned tuple|bool against a bool contract - tkinter fallbacks (interactions, message_dialog) restructured into import/display try blocks (no possibly-unbound tk) - targeted pyright: ignore only where third-party stubs are absent (pyobjc, ctypes.windll, os.startfile, build-hook base union) and on the legacy HookSpec/WorkflowStep engine mismatch pending #39 - pylint pragma prose moved off pragma lines (7 spurious W0012) --- pyproject.toml | 16 ++++ src/profiles/__init__.py | 7 +- src/profiles/app.py | 6 +- src/profiles/core/config/inheritance.py | 79 ++++++++++++------- src/profiles/core/config/matcher.py | 2 +- src/profiles/core/config/models.py | 10 ++- src/profiles/core/config/reader.py | 21 +++-- src/profiles/core/config/schema.py | 10 ++- src/profiles/core/environment/execution.py | 43 ++++++---- src/profiles/core/environment/interactions.py | 27 ++++--- .../core/environment/message_dialog.py | 9 ++- src/profiles/core/environment/workflow.py | 5 +- .../core/processing/file_classifier.py | 30 +++---- src/profiles/core/processing/scanner.py | 8 +- src/profiles/core/telemetry/diagnostics.py | 6 +- src/profiles/desktop/handoff.py | 1 + src/profiles/desktop/launcher.py | 2 +- src/profiles/server/app.py | 4 +- src/profiles/server/routes/actions.py | 23 ++++-- src/profiles/utils/file_utils.py | 6 +- src/profiles/utils/shortcut.py | 2 +- src/profiles/web/build_hook.py | 8 +- uv.lock | 38 +++++++++ 23 files changed, 244 insertions(+), 119 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0b6b624..aa96837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ dev = [ "deptry>=0.22.0", "httpx>=0.27.0", "briefcase>=0.4.4", + # Declared so `pyright src/profiles` can resolve the build-hook import + # (web/build_hook.py); also what deptry needs (#43). + "hatchling>=1.26", ] [tool.hatch.build.hooks.custom] @@ -201,6 +204,16 @@ markers = [ "qt_no_exception_capture: disable pytest-qt exception capture for this test", ] +# Type checking gate (issue #69): `pyright src/profiles` must stay clean. +# Basic mode on purpose — the gate is regression insurance, not strict-mode. +[tool.pyright] +include = ["src/profiles"] +extraPaths = ["src"] +venvPath = "." +venv = ".venv" +pythonVersion = "3.11" +typeCheckingMode = "basic" + [dependency-groups] dev = [ "ruff>=0.1.0", @@ -213,4 +226,7 @@ dev = [ "deptry>=0.22.0", "httpx>=0.27.0", "briefcase>=0.4.4", + # Declared so `pyright src/profiles` can resolve the build-hook import + # (web/build_hook.py); also what deptry needs (#43). + "hatchling>=1.26", ] diff --git a/src/profiles/__init__.py b/src/profiles/__init__.py index 1c6cbbf..26c95dc 100644 --- a/src/profiles/__init__.py +++ b/src/profiles/__init__.py @@ -22,10 +22,11 @@ def _resolve_version() -> str: """ # Primary: use importlib.metadata for installed packages. The PEP 621 # distribution name is "profiles-app"; "profiles" covers legacy - # setuptools installs. - try: - from importlib.metadata import PackageNotFoundError, version + # setuptools installs. The import stays function-local (importlib.metadata + # is stdlib on every supported Python) to keep ``import profiles`` cheap. + from importlib.metadata import PackageNotFoundError, version + try: try: return version("profiles-app") except PackageNotFoundError: diff --git a/src/profiles/app.py b/src/profiles/app.py index bd2ffb0..e704ef0 100644 --- a/src/profiles/app.py +++ b/src/profiles/app.py @@ -446,11 +446,11 @@ def run_headless(self, file_path: str | Path | None = None) -> int: # If file_path is not provided, scan all configured directories if not file_path: for cfg in _config.configurations: - if cfg.directory: - ext = _config.extensions[0] if _config.extensions else "All" + ext = _config.extensions[0] if _config.extensions else "All" + for directory in cfg.scan: # Use scan_and_process to enable metrics logging scanned_files = scan_and_process( - cfg.directory, + directory, extension=ext, exclude_dirs=_config.search_exclude_dirs, exclude_files=cfg.search_exclude_files, diff --git a/src/profiles/core/config/inheritance.py b/src/profiles/core/config/inheritance.py index e42e1ab..0db9da1 100644 --- a/src/profiles/core/config/inheritance.py +++ b/src/profiles/core/config/inheritance.py @@ -56,41 +56,64 @@ def _resolve_machine( else: base = None - def pick(field: str): + def pick_str_list(field: str) -> list[str]: + """Merge a ``list[str]`` field: local (first, deduped) then inherited.""" local = getattr(cfg, field) inherited = getattr(base, field) if base is not None else None - local_set = field in cfg.model_fields_set - if field in ("extensions", "filters", "search_exclude_files", "search_exclude_dirs"): - if local is None or not local_set: - return list(inherited or []) - return _merge_str(inherited or [], local) - if field == "row_colors": - if local is None or not local_set: - return list(inherited or []) - return _merge_row(inherited or [], local) - if local_set: - return local - return inherited + if local is not None and field in cfg.model_fields_set: + return _merge_str(list(inherited or []), local) + return list(inherited or []) + + def pick_row_colors() -> list[RowColor]: + """Merge ``row_colors``: local rules first, then inherited (deduped).""" + local = cfg.row_colors + inherited = base.row_colors if base is not None else None + if local is not None and "row_colors" in cfg.model_fields_set: + return _merge_row(list(inherited or []), local) + return list(inherited or []) + + def pick_match() -> MatchCriteriaSchema: + """Return the local ``match`` block, falling back to the inherited one.""" + if "match" in cfg.model_fields_set: + return cfg.match + return base.match if base is not None else MatchCriteriaSchema() + + def pick_scan() -> list[str]: + """Return the local ``scan`` list, falling back to the inherited one.""" + if "scan" in cfg.model_fields_set: + return cfg.scan + return base.scan if base is not None else [] + + max_depth = ( + cfg.max_search_depth + if "max_search_depth" in cfg.model_fields_set + else base.max_search_depth + if base is not None + else None + ) + recursive = ( + cfg.recursive_search + if "recursive_search" in cfg.model_fields_set + else base.recursive_search + if base is not None + else None + ) return MachineConfig( name=cfg.name, extends=cfg.extends, - match=pick("match") or MatchCriteriaSchema(), - scan=list(pick("scan") or []), - extensions=tuple(_merge_str(defaults.extensions, pick("extensions"))), - filters=tuple(_merge_str(defaults.filters, pick("filters"))), - row_colors=_merge_row(defaults.row_colors, pick("row_colors")), - search_exclude_files=tuple( - _merge_str(defaults.search_exclude_files, pick("search_exclude_files")) + match=pick_match(), + scan=pick_scan(), + extensions=_merge_str(defaults.extensions, pick_str_list("extensions")), + filters=_merge_str(defaults.filters, pick_str_list("filters")), + row_colors=_merge_row(defaults.row_colors, pick_row_colors()), + search_exclude_files=_merge_str( + defaults.search_exclude_files, pick_str_list("search_exclude_files") ), - max_search_depth=pick("max_search_depth") - if pick("max_search_depth") is not None - else defaults.max_search_depth, - recursive_search=pick("recursive_search") - if pick("recursive_search") is not None - else defaults.recursive_search, - search_exclude_dirs=tuple( - _merge_str(defaults.search_exclude_dirs, pick("search_exclude_dirs")) + max_search_depth=max_depth if max_depth is not None else defaults.max_search_depth, + recursive_search=recursive if recursive is not None else defaults.recursive_search, + search_exclude_dirs=_merge_str( + defaults.search_exclude_dirs, pick_str_list("search_exclude_dirs") ), ) diff --git a/src/profiles/core/config/matcher.py b/src/profiles/core/config/matcher.py index 69b2af6..ad095b1 100644 --- a/src/profiles/core/config/matcher.py +++ b/src/profiles/core/config/matcher.py @@ -58,7 +58,7 @@ def matches_machine_config( return True if m.ip and eval_criteria_list(m.ip, ip): return True - return m.path and eval_criteria_list(m.path, path, is_path=True) + return bool(m.path) and eval_criteria_list(m.path, path, is_path=True) def select_active_configuration( diff --git a/src/profiles/core/config/models.py b/src/profiles/core/config/models.py index 6fd2704..c5d1481 100644 --- a/src/profiles/core/config/models.py +++ b/src/profiles/core/config/models.py @@ -18,6 +18,11 @@ WorkflowOutputMode = Literal["hide", "show", "fail_only"] +# Behavior when a hook/workflow step fails. Defined here (not in +# environment/workflow.py) because models is the bottom of the core +# dependency graph; workflow.py re-exports it. +FailMode = Literal["warn", "abort", "skip"] + @dataclass(frozen=True) class WorkflowStep: @@ -255,7 +260,8 @@ class AppConfig: normalised extension (e.g. ``".mttx"``). Each step is a :class:`WorkflowStep` (action, command/message, optional ask guard). - launch_hook_failmode: Behavior when a hook fails (``"warn"``, ...). + launch_hook_failmode: Behavior when a hook fails — one of + ``"warn"``, ``"abort"``, ``"skip"``. launch_hook_timeout: Hook timeout in seconds. """ @@ -284,7 +290,7 @@ class AppConfig: config_path: Path = field(default_factory=lambda: Path.cwd() / PRIMARY_CONFIG_NAME) skip_config_prompt: bool = False launch_hooks: dict[str, tuple[WorkflowStep, ...]] = field(default_factory=dict) - launch_hook_failmode: str = "warn" + launch_hook_failmode: FailMode = "warn" launch_hook_timeout: int = 30 diff --git a/src/profiles/core/config/reader.py b/src/profiles/core/config/reader.py index 7fd7380..800732f 100644 --- a/src/profiles/core/config/reader.py +++ b/src/profiles/core/config/reader.py @@ -16,6 +16,7 @@ AppConfig, ColumnConfiguration, MachineConfiguration, + MatchCriteria, WorkflowStep, ) from profiles.core.config.schema import AppConfigYaml @@ -238,15 +239,21 @@ def _build_configurations(self, schema: AppConfigYaml) -> list[MachineConfigurat return [ MachineConfiguration( name=m.name or name, - match=m.match, - scan=m.scan, - extensions=m.extensions, - filters=m.filters, - row_colors=tuple((rc.pattern, rc.color) for rc in m.row_colors), - search_exclude_files=m.search_exclude_files, + match=MatchCriteria( + hostname=tuple(m.match.hostname), + ip=tuple(m.match.ip), + path=tuple(m.match.path), + ), + scan=tuple(m.scan), + # resolve_configs fills these optional schema fields; the + # ``or ()`` keeps the declared Optional honest at the seam. + extensions=tuple(m.extensions or ()), + filters=tuple(m.filters or ()), + row_colors=tuple((rc.pattern, rc.color) for rc in m.row_colors or ()), + search_exclude_files=tuple(m.search_exclude_files or ()), max_search_depth=m.max_search_depth, recursive_search=m.recursive_search, - search_exclude_dirs=m.search_exclude_dirs, + search_exclude_dirs=tuple(m.search_exclude_dirs or ()), ) for name, m in resolved.items() ] diff --git a/src/profiles/core/config/schema.py b/src/profiles/core/config/schema.py index 9d17d91..1c330cf 100644 --- a/src/profiles/core/config/schema.py +++ b/src/profiles/core/config/schema.py @@ -11,7 +11,7 @@ from __future__ import annotations import re -from typing import Literal +from typing import Literal, cast from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -105,12 +105,16 @@ class HooksConfig(BaseModel): @field_validator("entries", mode="before") @classmethod def _none_to_dict(cls, value: object) -> dict[str, list[WorkflowStepSchema]]: - return value or {} + # Pydantic validates the item types after this "before" hook runs. + if value is None: + return {} + return cast("dict[str, list[WorkflowStepSchema]]", value) @model_validator(mode="after") def _validate_step_required_fields(self) -> HooksConfig: """Enforce per-action required keys, reporting the entry pattern.""" - # pylint: disable-next=no-member (pylint infers FieldInfo, not the dict) + # (pylint infers FieldInfo, not the dict, for the pydantic field) + # pylint: disable-next=no-member for pattern, steps in self.entries.items(): for pos, step in enumerate(steps): if step.action == "notify" and not step.message: diff --git a/src/profiles/core/environment/execution.py b/src/profiles/core/environment/execution.py index c7e2e5e..54503dd 100644 --- a/src/profiles/core/environment/execution.py +++ b/src/profiles/core/environment/execution.py @@ -208,19 +208,21 @@ def spawn_background_hook(template: str, file_path: Path) -> None: ``None``. Output is intentionally discarded. """ args = _build_argv(template, file_path) - popen_kwargs: dict[str, object] = { - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = ( - subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - ) - else: - popen_kwargs["start_new_session"] = True - try: - subprocess.Popen(args, **popen_kwargs) # pylint: disable=consider-using-with + if sys.platform == "win32": + subprocess.Popen( # pylint: disable=consider-using-with + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS, + ) + else: + subprocess.Popen( # pylint: disable=consider-using-with + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) except FileNotFoundError: # Best-effort: don't crash the caller if the hook binary is missing. return @@ -263,8 +265,21 @@ def run_hooks_for_file( if not hooks: return HookOutcome.CONTINUE - synchronous: list[HookSpec] = [hook for hook in hooks if hook.when != "after"] - after_hooks: list[HookSpec] = [hook for hook in hooks if hook.when == "after"] + # NOTE: launch_hooks carries WorkflowStep objects, but this legacy + # HookSpec engine reads the ``when`` field off them — a mismatch left + # by the workflow-model migration. The engine's fate (fix, port or + # delete) is the dead-code sweep's call (#39); pyright is suppressed + # on the mismatch until then. + synchronous: list[HookSpec] = [ # pyright: ignore[reportAssignmentType] + hook + for hook in hooks + if hook.when != "after" # pyright: ignore[reportAttributeAccessIssue] + ] + after_hooks: list[HookSpec] = [ # pyright: ignore[reportAssignmentType] + hook + for hook in hooks + if hook.when == "after" # pyright: ignore[reportAttributeAccessIssue] + ] for hook in synchronous: if hook.when == "confirm": diff --git a/src/profiles/core/environment/interactions.py b/src/profiles/core/environment/interactions.py index 906aad6..b59b78e 100644 --- a/src/profiles/core/environment/interactions.py +++ b/src/profiles/core/environment/interactions.py @@ -12,7 +12,7 @@ def confirm_dialog( message: str, title: str = "Confirmation", - timeout: int | None = None, # pylint: disable=unused-argument (reserved) + timeout: int | None = None, # pylint: disable=unused-argument ) -> bool: """Show a yes/no confirmation dialog. @@ -34,16 +34,17 @@ def confirm_dialog( try: import tkinter as tk from tkinter import messagebox - - root = tk.Tk() - root.withdraw() # Hide main window - result = bool(messagebox.askyesno(title, message)) - root.destroy() - return result except ImportError: pass # No Tkinter — fall back to headless - except tk.TclError: - pass # No display available — fall back to headless + else: + try: + root = tk.Tk() + root.withdraw() # Hide main window + result = bool(messagebox.askyesno(title, message)) + root.destroy() + return result + except tk.TclError: + pass # No display available — fall back to headless # Headless mode response = input(f"{title}: {message} [y/N]: ").strip().lower() @@ -94,7 +95,11 @@ def confirm_dialog_3way( try: import tkinter as tk + except ImportError: + # Fallback to headless if Tkinter is unavailable + return _headless_3way_choice(message, title, pre_message) + try: root = tk.Tk() root.withdraw() @@ -137,8 +142,8 @@ def on_no(): root.wait_window(dialog) root.destroy() return result[0] - except (ImportError, tk.TclError): - # Fallback to headless if Tkinter unavailable or no display + except tk.TclError: + # Fallback to headless if no display is available return _headless_3way_choice(message, title, pre_message) diff --git a/src/profiles/core/environment/message_dialog.py b/src/profiles/core/environment/message_dialog.py index d41e638..7fdbf9d 100644 --- a/src/profiles/core/environment/message_dialog.py +++ b/src/profiles/core/environment/message_dialog.py @@ -56,7 +56,12 @@ def show_notify_dialog( try: import tkinter as tk + except ImportError: + # No Tkinter — fall back to headless + _print_headless(content, title) + return + try: root = tk.Tk() root.title(title) @@ -71,8 +76,8 @@ def show_notify_dialog( else: root.attributes("-topmost", True) root.after(3000, root.destroy) # Auto-close after 3 seconds in non-blocking - except (ImportError, tk.TclError): - # Fallback to headless + except tk.TclError: + # No display available — fall back to headless _print_headless(content, title) diff --git a/src/profiles/core/environment/workflow.py b/src/profiles/core/environment/workflow.py index 4d3d2d6..4b35eaf 100644 --- a/src/profiles/core/environment/workflow.py +++ b/src/profiles/core/environment/workflow.py @@ -15,7 +15,7 @@ from typing import Literal from profiles.core.config.conditions import evaluate_hook_condition -from profiles.core.config.models import WorkflowStep +from profiles.core.config.models import FailMode, WorkflowStep from profiles.core.environment.interactions import confirm_dialog_3way from profiles.core.environment.message_dialog import show_notify_dialog from profiles.core.telemetry import events @@ -32,7 +32,8 @@ class WorkflowOutcome(Enum): AskCallback = Callable[[str, str | None], Literal["yes", "skip", "no"]] NotifyCallback = Callable[[str, bool], None] -FailMode = Literal["warn", "abort", "skip"] +# FailMode lives in core.config.models (bottom of the dependency graph) and +# is re-exported here for backward compatibility with existing imports. # Bound for the *combined* stdout+stderr text kept per blocking command step. diff --git a/src/profiles/core/processing/file_classifier.py b/src/profiles/core/processing/file_classifier.py index 1f5b023..617b058 100644 --- a/src/profiles/core/processing/file_classifier.py +++ b/src/profiles/core/processing/file_classifier.py @@ -10,6 +10,7 @@ import re from pathlib import Path +from profiles.core.config.models import ColumnConfiguration from profiles.core.processing.column_extractor import ColumnExtractor @@ -99,7 +100,7 @@ def get_file_info(file_path: str | Path, extension: str = "") -> tuple[str, str] def build_column_extractor( - columns: object | None, + columns: dict[str, ColumnConfiguration] | None, column_names: tuple[str, ...], ) -> ColumnExtractor: """Build a :class:`ColumnExtractor` from config column definitions. @@ -114,8 +115,8 @@ def build_column_extractor( """ extractor = ColumnExtractor() - if columns: - for col_name, col_config in _iter_columns(columns): + if isinstance(columns, dict) and columns: + for col_name, col_config in columns.items(): if col_config.match: # Only add if a match pattern is defined extractor.add_rule( col_name, @@ -124,10 +125,11 @@ def build_column_extractor( col_config.priority, col_config.default, ) - else: - # Legacy mode: add default Version rule if Version is in column_names - if "Version" in column_names: - extractor.add_rule("Version", r"_V(.+)", group=1, priority=10) + elif not columns and "Version" in column_names: + # Legacy mode (falsy columns: ``None``, ``""``): add the default + # Version rule. A truthy non-dict — legacy callers passing garbage — + # adds no rule at all (pinned by test_non_dict_columns_ignored). + extractor.add_rule("Version", r"_V(.+)", group=1, priority=10) return extractor @@ -136,18 +138,6 @@ def build_column_extractor( _build_column_extractor = build_column_extractor -def _iter_columns(columns: object) -> list[tuple[str, object]]: - """Safely iterate over a columns dict, returning ``(name, config)`` pairs. - - Works around the weak ``dict | None`` type by accepting a generic - object and extracting items when possible. Returns an empty list - for non-dict values (legacy callers that pass ``""``). - """ - if isinstance(columns, dict): - return list(columns.items()) - return [] - - def _extract_columns_with_defaults( full_path: str, extractor: ColumnExtractor, @@ -177,7 +167,7 @@ def get_file_info_dynamic( file_path: str | Path, extension: str = "", column_names: tuple[str, ...] = ("File", "Version"), - columns: object | None = None, + columns: dict[str, ColumnConfiguration] | None = None, ) -> dict[str, str]: """Get file metadata using dynamic column extraction rules. diff --git a/src/profiles/core/processing/scanner.py b/src/profiles/core/processing/scanner.py index 612211a..0ff2ef8 100644 --- a/src/profiles/core/processing/scanner.py +++ b/src/profiles/core/processing/scanner.py @@ -15,7 +15,7 @@ from typing import NamedTuple, TypeVar from profiles.core.config import service as config_service -from profiles.core.config.models import AppConfig +from profiles.core.config.models import AppConfig, ColumnConfiguration from profiles.core.processing.column_extractor import ColumnExtractor from profiles.core.processing.file_classifier import ( _build_column_extractor, @@ -195,8 +195,8 @@ def _iter_directory_files( exclude_files: Sequence[str] | None, config: AppConfig | None, recursive_override: bool | None = None, -) -> Iterator[Path]: - """Scan one root directory and yield file paths passing the scan config. +) -> list[Path]: + """Scan one root directory and return file paths passing the scan config. The extension filter is pushed into the scan: names that cannot pass the extension never become Path objects (Task 7). Scan-level errors @@ -544,7 +544,7 @@ def scan_and_process_dynamic( exclude_dirs: Sequence[str] | None = None, exclude_files: Sequence[str] | None = None, column_names: tuple[str, ...] = ("File",), - columns: dict[str, object] | None = None, + columns: dict[str, ColumnConfiguration] | None = None, log_metrics: bool = False, config: AppConfig | None = None, recursive_override: bool | None = None, diff --git a/src/profiles/core/telemetry/diagnostics.py b/src/profiles/core/telemetry/diagnostics.py index 0c5e990..e2638ce 100644 --- a/src/profiles/core/telemetry/diagnostics.py +++ b/src/profiles/core/telemetry/diagnostics.py @@ -130,15 +130,15 @@ def install_crash_hook(logger: logging.Logger) -> None: previous_hook = sys.excepthook def _log_crash( - exc_type: type[BaseException] | None, - exc_value: BaseException | None, + exc_type: type[BaseException], + exc_value: BaseException, traceback: types.TracebackType | None, ) -> None: type_name = getattr(exc_type, "__name__", "Unknown") uncaught_exception(logger, type_name=str(type_name), error=str(exc_value)) previous_hook(exc_type, exc_value, traceback) - sys.excepthook = _log_crash # type: ignore[assignment] + sys.excepthook = _log_crash class LoggerFactory: diff --git a/src/profiles/desktop/handoff.py b/src/profiles/desktop/handoff.py index fa3da16..cb42d61 100644 --- a/src/profiles/desktop/handoff.py +++ b/src/profiles/desktop/handoff.py @@ -116,6 +116,7 @@ def forward_config_to_running_instance( return HandoffResult("handed_off") except urllib.error.HTTPError as exc: # Server rejected the config (invalid/missing file, bad request). + detail = "" with contextlib.suppress(Exception): detail = exc.read().decode("utf-8", errors="replace")[:300] return HandoffResult("handoff_failed", f"running instance rejected the config: {detail}") diff --git a/src/profiles/desktop/launcher.py b/src/profiles/desktop/launcher.py index 219ff7c..88e0611 100644 --- a/src/profiles/desktop/launcher.py +++ b/src/profiles/desktop/launcher.py @@ -47,7 +47,7 @@ def request_media_permission( # pylint: disable=unused-argument objc.classAddMethod( delegate_cls, _MEDIA_CAPTURE_SELECTOR, - objc.selector( + objc.selector( # pyright: ignore[reportAttributeAccessIssue] — untyped pyobjc request_media_permission, selector=_MEDIA_CAPTURE_SELECTOR, signature=_MEDIA_CAPTURE_TYPESTR, diff --git a/src/profiles/server/app.py b/src/profiles/server/app.py index 069a11b..bdbb984 100644 --- a/src/profiles/server/app.py +++ b/src/profiles/server/app.py @@ -93,7 +93,9 @@ def create_app(state: ServerState, static_dir: Path | None = None) -> FastAPI: """ app = FastAPI(title="ProFiles API", version="1.0.0", lifespan=_client_watchdog_lifespan) app.state.server_state = state - app.state.client_last_seen: dict[str, float] = {} + # client_id -> monotonic last-seen timestamp (dict[str, float]); attribute + # annotations are not a supported statement form, so it's documented here. + app.state.client_last_seen = {} app.add_middleware( CORSMiddleware, diff --git a/src/profiles/server/routes/actions.py b/src/profiles/server/routes/actions.py index f6be7a8..a4abf5c 100644 --- a/src/profiles/server/routes/actions.py +++ b/src/profiles/server/routes/actions.py @@ -8,20 +8,23 @@ import logging from io import BytesIO from types import ModuleType -from typing import Any +from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException, Request from PIL import Image from pydantic import BaseModel, ConfigDict, Field from profiles.core import actions -from profiles.core.processing.file_metadata import get_file_metadata +from profiles.core.processing.file_metadata import FileMetadata, get_file_metadata from profiles.core.telemetry.diagnostics import default_log_path from profiles.server.state import ServerState from profiles.utils.file_utils import open_file_explorer from profiles.utils.network import get_username from profiles.utils.path_utils import normalize_user_path +if TYPE_CHECKING: + from zxingcpp import BarcodeFormat, BarcodeFormats + logger = logging.getLogger("profiles.server.actions") router = APIRouter(prefix="/api/actions", tags=["actions"]) @@ -150,7 +153,7 @@ async def reveal_file(payload: PathRequest) -> dict: @router.post("/metadata") -async def file_metadata(payload: PathRequest) -> dict: +async def file_metadata(payload: PathRequest) -> FileMetadata: """Return basic metadata for a file.""" path = normalize_user_path(payload.file_path) if not path.exists(): @@ -219,6 +222,7 @@ async def generate_barcode(payload: BarcodeRequest): **Payload format (default):** ``dir=;filter=;scan=1`` """ import qrcode + from qrcode import constants if payload.content: qr_content = payload.content @@ -250,7 +254,7 @@ async def generate_barcode(payload: BarcodeRequest): qr = qrcode.QRCode( version=2, - error_correction=qrcode.constants.ERROR_CORRECT_M, + error_correction=constants.ERROR_CORRECT_M, box_size=10, border=4, ) @@ -259,7 +263,8 @@ async def generate_barcode(payload: BarcodeRequest): img = qr.make_image(fill_color="#0f172a", back_color="#ffffff") buf = BytesIO() - img.save(buf, format="PNG") + # Positional: BaseImage.save names it ``kind``, PilImage.save ``format``. + img.save(buf, "PNG") png_b64 = base64.b64encode(buf.getvalue()).decode("ascii") return { @@ -295,7 +300,9 @@ def _load_frame_image(image_bytes: bytes) -> Image.Image: return frame if frame.mode == "RGB" else frame.convert("RGB") -def _resolve_decode_formats(zxingcpp: ModuleType, formats: list[str] | None) -> list[Any] | None: +def _resolve_decode_formats( + zxingcpp: ModuleType, formats: list[str] | None +) -> BarcodeFormats | None: """Map client format names (BarcodeDetector style) to zxing-cpp formats. Unknown names are skipped; a request naming only unknown formats is @@ -303,7 +310,7 @@ def _resolve_decode_formats(zxingcpp: ModuleType, formats: list[str] | None) -> """ if not formats: return None - parsed = [] + parsed: list[BarcodeFormat] = [] for name in formats: try: parsed.append(zxingcpp.barcode_format_from_str(name)) @@ -311,7 +318,7 @@ def _resolve_decode_formats(zxingcpp: ModuleType, formats: list[str] | None) -> logger.warning("Ignoring unknown barcode format requested: %s", name) if not parsed: raise HTTPException(status_code=400, detail="No valid barcode formats in request") - return parsed + return zxingcpp.BarcodeFormats(parsed) @router.post("/barcode/decode") diff --git a/src/profiles/utils/file_utils.py b/src/profiles/utils/file_utils.py index effe586..c3d7037 100644 --- a/src/profiles/utils/file_utils.py +++ b/src/profiles/utils/file_utils.py @@ -378,7 +378,7 @@ def open_file_explorer(directory: str | Path) -> bool: path = Path(directory) if path.is_dir(): if _IS_WINDOWS: - os.startfile(str(path)) + os.startfile(str(path)) # pyright: ignore[reportAttributeAccessIssue] — Windows-only elif _IS_MACOS: subprocess.run(["open", str(path)], check=True) else: @@ -404,7 +404,7 @@ def launch_file(file_path: str | Path) -> bool: return False if _IS_WINDOWS: - os.startfile(str(path)) + os.startfile(str(path)) # pyright: ignore[reportAttributeAccessIssue] — Windows-only elif _IS_MACOS: subprocess.run(["open", str(path)], check=True) else: @@ -432,7 +432,7 @@ def open_with_default_app(file_path: str | Path) -> bool: if not path.exists(): return False if _IS_WINDOWS: - os.startfile(str(path)) + os.startfile(str(path)) # pyright: ignore[reportAttributeAccessIssue] — Windows-only elif _IS_MACOS: subprocess.run(["open", str(path)], check=True) else: diff --git a/src/profiles/utils/shortcut.py b/src/profiles/utils/shortcut.py index b1bac87..4c6fffc 100644 --- a/src/profiles/utils/shortcut.py +++ b/src/profiles/utils/shortcut.py @@ -58,7 +58,7 @@ def _get_windows_desktop() -> Path | None: shgfp_type_current = 0 buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH) - result = ctypes.windll.shell32.SHGetFolderPathW( + result = ctypes.windll.shell32.SHGetFolderPathW( # pyright: ignore[reportAttributeAccessIssue] — Windows-only None, csidl_desktop, None, shgfp_type_current, buf ) diff --git a/src/profiles/web/build_hook.py b/src/profiles/web/build_hook.py index 91cba6a..82df78b 100644 --- a/src/profiles/web/build_hook.py +++ b/src/profiles/web/build_hook.py @@ -11,7 +11,7 @@ HAS_HATCHLING = True except ImportError: HAS_HATCHLING = False - BuildHookInterface = object # type: ignore + BuildHookInterface = object # type: ignore[assignment, misc] def _needs_rebuild(web_dir: Path, marker_file: Path) -> bool: @@ -76,7 +76,11 @@ def _run_npm_build(web_dir: Path, npm_path: str) -> bool: return False -class WebBuildHook(BuildHookInterface): +# The runtime fallback above unions ``object`` into the base-class binding, +# so pyright's base-class check is suppressed here. Every environment that +# actually loads this hook (hatchling build, dev venv since hatchling is a +# dev dependency) provides the real BuildHookInterface. +class WebBuildHook(BuildHookInterface): # pyright: ignore[reportGeneralTypeIssues] """Build hook to compile React frontend during installation.""" def initialize(self, version: str, build_data: dict) -> None: diff --git a/uv.lock b/uv.lock index 70390a1..449de23 100644 --- a/uv.lock +++ b/uv.lock @@ -681,6 +681,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hatchling" +version = "1.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/f1/e47b937226ae3b093bbd0132eb0b7ee5bfad12c2b5324afd10ae49946b05/hatchling-1.33.0.tar.gz", hash = "sha256:4c422bd4d554fcff5de5b73e9596f85e41e9f97f7e553d8f7595ecb31a627bef", size = 58079, upload-time = "2026-10-28T02:27:30.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/64/4afe5dd77b2939a93e8c9513f8082dbd049ecba9c1484a379a0f6ae29d6b/hatchling-1.33.0-py3-none-any.whl", hash = "sha256:415290b84f464d2884303fca8974d38e7b85579069fab02a15db0828fbb1446d", size = 78750, upload-time = "2026-10-28T02:27:29.033Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1135,6 +1151,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1282,6 +1307,7 @@ dependencies = [ dev = [ { name = "briefcase" }, { name = "deptry" }, + { name = "hatchling" }, { name = "httpx" }, { name = "pre-commit" }, { name = "pylint" }, @@ -1296,6 +1322,7 @@ dev = [ dev = [ { name = "briefcase" }, { name = "deptry" }, + { name = "hatchling" }, { name = "httpx" }, { name = "pre-commit" }, { name = "pylint" }, @@ -1311,6 +1338,7 @@ requires-dist = [ { name = "briefcase", marker = "extra == 'dev'", specifier = ">=0.4.4" }, { name = "deptry", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.26" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -1333,6 +1361,7 @@ provides-extras = ["dev"] dev = [ { name = "briefcase", specifier = ">=0.4.4" }, { name = "deptry", specifier = ">=0.22.0" }, + { name = "hatchling", specifier = ">=1.26" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pre-commit", specifier = ">=3.0.0" }, { name = "pylint", specifier = ">=3.0.0" }, @@ -2083,6 +2112,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] +[[package]] +name = "trove-classifiers" +version = "2026.9.21.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz", hash = "sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71", size = 17085, upload-time = "2026-09-21T13:29:07.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl", hash = "sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17", size = 14232, upload-time = "2026-09-21T13:29:06.123Z" }, +] + [[package]] name = "truststore" version = "0.10.5"