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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dev = [

[project.scripts]
noirdoc = "noirdoc.cli:main"
noirdoc-daemon = "noirdoc.daemon.server:main"

[project.urls]
Homepage = "https://noirdoc.de"
Expand Down
231 changes: 230 additions & 1 deletion src/noirdoc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

Expand All @@ -13,6 +14,10 @@
from noirdoc.sdk import Redactor


def _daemon_disabled() -> bool:
return os.environ.get("NOIRDOC_NO_DAEMON", "").strip().lower() in {"1", "true", "yes"}


@click.group()
@click.version_option(__version__, prog_name="noirdoc")
def main() -> None:
Expand All @@ -39,6 +44,11 @@ def main() -> None:
)
@click.option("--score-threshold", type=float, default=0.5, show_default=True)
@click.option("-v", "--verbose", is_flag=True, help="Print per-file details.")
@click.option(
"--no-daemon",
is_flag=True,
help="Skip the daemon and run redaction in-process (each call reloads models).",
)
def redact(
inputs: tuple[Path, ...],
output: Path | None,
Expand All @@ -48,6 +58,7 @@ def redact(
detector: str,
score_threshold: float,
verbose: bool,
no_daemon: bool,
) -> None:
"""Redact PII in one or more files."""
files = _expand_inputs(inputs)
Expand All @@ -59,6 +70,145 @@ def redact(
click.echo("--output only works with a single input; use --output-dir.", err=True)
sys.exit(2)

use_daemon = not (no_daemon or _daemon_disabled())

if use_daemon:
success, last_namespace_size, switched = _redact_via_daemon(
files,
output=output,
output_dir=output_dir,
namespace=namespace,
language=language,
detector=detector,
score_threshold=score_threshold,
verbose=verbose,
)
if switched is None:
# Daemon handled every file successfully.
if namespace and last_namespace_size is not None:
click.echo(
f"Namespace {namespace!r}: {last_namespace_size} unique entities total",
)
sys.exit(0 if success else 1)
# Fall through with the remaining files handled in-process.
files = switched
else:
success = 0

success += _redact_in_process(
files,
output=output,
output_dir=output_dir,
namespace=namespace,
language=language,
detector=detector,
score_threshold=score_threshold,
verbose=verbose,
)
sys.exit(0 if success else 1)


def _redact_via_daemon(
files: list[Path],
*,
output: Path | None,
output_dir: Path | None,
namespace: str | None,
language: str,
detector: str,
score_threshold: float,
verbose: bool,
) -> tuple[int, int | None, list[Path] | None]:
"""Redact via the daemon. Returns (successes, last_namespace_size, fallback_remaining).

``fallback_remaining`` is ``None`` on full success, or a list of files
not yet processed when the daemon failed and the caller should fall
back to in-process redaction.
"""
from noirdoc.daemon.client import DaemonError, DaemonUnavailable, call_sync

success = 0
last_namespace_size: int | None = None

for i, path in enumerate(files):
out_path = _choose_output_path(
path,
output=output,
output_dir=output_dir,
reconstructed=True, # daemon decides; we'll rename if it says reconstructed=False
)
out_path.parent.mkdir(parents=True, exist_ok=True)

params = {
"namespace": namespace,
"language": language,
"detector": detector,
"score_threshold": score_threshold,
"input": {"type": "file", "path": str(path.resolve())},
"output_path": str(out_path.resolve()),
}
try:
result = call_sync("redact", params)
except DaemonUnavailable as exc:
click.echo(
f"noirdoc: daemon unavailable ({exc}); running locally for the rest.",
err=True,
)
return success, last_namespace_size, list(files[i:])
except DaemonError as exc:
click.echo(
f"noirdoc: daemon error ({exc}); running locally for the rest.",
err=True,
)
return success, last_namespace_size, list(files[i:])
except Exception as exc: # connection died, etc.
click.echo(
f"noirdoc: daemon failed mid-request ({exc}); running locally for the rest.",
err=True,
)
return success, last_namespace_size, list(files[i:])

# If the daemon couldn't reconstruct the file, the output it wrote
# is plain text — rename to .txt to match in-process behaviour.
if not result.get("reconstructed", False):
corrected = _choose_output_path(
path,
output=output,
output_dir=output_dir,
reconstructed=False,
)
if corrected != out_path:
Path(out_path).rename(corrected)
out_path = corrected

last_namespace_size = result.get("namespace_size") or last_namespace_size

if verbose:
types = ", ".join(f"{k}={v}" for k, v in sorted(result.get("entity_types", {}).items()))
click.echo(
f"{path.name}: {result['entity_count']} entities [{types}] -> {out_path}",
)
else:
click.echo(f"{path.name}: {result['entity_count']} entities -> {out_path}")
success += 1

return success, last_namespace_size, None


def _redact_in_process(
files: list[Path],
*,
output: Path | None,
output_dir: Path | None,
namespace: str | None,
language: str,
detector: str,
score_threshold: float,
verbose: bool,
) -> int:
if not files:
return 0

r = Redactor(
namespace=namespace,
language=language,
Expand Down Expand Up @@ -93,7 +243,7 @@ def redact(
if namespace:
click.echo(f"Namespace {namespace!r}: {r.mapper.entity_count} unique entities total")

sys.exit(0 if success else 1)
return success


@main.command()
Expand Down Expand Up @@ -187,6 +337,85 @@ def ns_delete(namespace: str) -> None:
click.echo(f"Deleted {namespace!r}")


@main.group("daemon")
def daemon_group() -> None:
"""Inspect and control the long-lived noirdoc daemon."""


@daemon_group.command("status")
def daemon_status() -> None:
"""Print whether a daemon is running and its current stats."""
from noirdoc.daemon import paths as daemon_paths
from noirdoc.daemon import spawn as daemon_spawn
from noirdoc.daemon.client import DaemonError, call_sync

pid = daemon_spawn.read_pidfile()
if pid is None or not daemon_spawn.is_pid_alive(pid):
click.echo(json.dumps({"running": False}, indent=2))
return

try:
status = call_sync("status")
except DaemonError as exc:
click.echo(
json.dumps({"running": True, "pid": pid, "error": str(exc)}, indent=2),
err=True,
)
sys.exit(1)

click.echo(
json.dumps(
{
"running": True,
"pid": pid,
"socket": str(daemon_paths.socket_path()),
**status,
},
indent=2,
),
)


@daemon_group.command("stop")
def daemon_stop() -> None:
"""Send SIGTERM to the daemon and wait for it to exit."""
from noirdoc.daemon import spawn as daemon_spawn

pid = daemon_spawn.read_pidfile()
if pid is None or not daemon_spawn.is_pid_alive(pid):
click.echo("daemon is not running")
return

if daemon_spawn.stop_daemon():
click.echo(f"daemon (pid={pid}) stopped")
else:
click.echo(f"daemon (pid={pid}) did not stop in time", err=True)
sys.exit(1)


@daemon_group.command("restart")
@click.pass_context
def daemon_restart(ctx: click.Context) -> None:
"""Stop the daemon if running. Next ``redact`` will auto-spawn a fresh one."""
ctx.invoke(daemon_stop)
click.echo("(daemon will auto-spawn on next redact)")


@daemon_group.command("logs")
@click.option("-n", "--lines", type=int, default=50, show_default=True)
def daemon_logs(lines: int) -> None:
"""Print the tail of the daemon log file."""
from noirdoc.daemon import paths as daemon_paths

log = daemon_paths.logfile_path()
if not log.exists():
click.echo("no log file yet", err=True)
sys.exit(1)
text = log.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines()[-lines:]:
click.echo(line)


@main.group()
def models() -> None:
"""Manage local detection models."""
Expand Down
6 changes: 6 additions & 0 deletions src/noirdoc/daemon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Long-lived daemon that holds detection models in memory.

The CLI's ``redact`` command transparently spawns and talks to this daemon
over a Unix domain socket, so model loading (~10 s on cold start) is paid
once per session instead of once per invocation.
"""
8 changes: 8 additions & 0 deletions src/noirdoc/daemon/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""``python -m noirdoc.daemon`` entry point."""

from __future__ import annotations

from noirdoc.daemon.server import main

if __name__ == "__main__":
main()
Loading
Loading