Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand All @@ -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",
]
7 changes: 4 additions & 3 deletions src/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions src/profiles/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 51 additions & 28 deletions src/profiles/core/config/inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
)

Expand Down
2 changes: 1 addition & 1 deletion src/profiles/core/config/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions src/profiles/core/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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


Expand Down
21 changes: 14 additions & 7 deletions src/profiles/core/config/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AppConfig,
ColumnConfiguration,
MachineConfiguration,
MatchCriteria,
WorkflowStep,
)
from profiles.core.config.schema import AppConfigYaml
Expand Down Expand Up @@ -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()
]
Expand Down
10 changes: 7 additions & 3 deletions src/profiles/core/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
43 changes: 29 additions & 14 deletions src/profiles/core/environment/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
27 changes: 16 additions & 11 deletions src/profiles/core/environment/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)


Expand Down
Loading
Loading