osintkit 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {osintkit-0.7.0 → osintkit-0.8.0}/CHANGELOG.md +7 -0
  2. {osintkit-0.7.0 → osintkit-0.8.0}/PKG-INFO +14 -1
  3. {osintkit-0.7.0 → osintkit-0.8.0}/README.md +13 -0
  4. {osintkit-0.7.0 → osintkit-0.8.0}/docs/REFERENCE.md +7 -0
  5. {osintkit-0.7.0 → osintkit-0.8.0}/pyproject.toml +1 -1
  6. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/cli.py +52 -1
  8. osintkit-0.8.0/src/osintkit/watch.py +97 -0
  9. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_cli.py +10 -0
  10. osintkit-0.8.0/tests/test_watch.py +81 -0
  11. {osintkit-0.7.0 → osintkit-0.8.0}/.editorconfig +0 -0
  12. {osintkit-0.7.0 → osintkit-0.8.0}/.github/CODEOWNERS +0 -0
  13. {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {osintkit-0.7.0 → osintkit-0.8.0}/.github/dependabot.yml +0 -0
  17. {osintkit-0.7.0 → osintkit-0.8.0}/.github/pull_request_template.md +0 -0
  18. {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/ci.yml +0 -0
  19. {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/codeql.yml +0 -0
  20. {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/publish.yml +0 -0
  21. {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/release.yml +0 -0
  22. {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/smoke.yml +0 -0
  23. {osintkit-0.7.0 → osintkit-0.8.0}/.gitignore +0 -0
  24. {osintkit-0.7.0 → osintkit-0.8.0}/.mailmap +0 -0
  25. {osintkit-0.7.0 → osintkit-0.8.0}/.pre-commit-config.yaml +0 -0
  26. {osintkit-0.7.0 → osintkit-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  27. {osintkit-0.7.0 → osintkit-0.8.0}/CONTRIBUTING.md +0 -0
  28. {osintkit-0.7.0 → osintkit-0.8.0}/LICENSE +0 -0
  29. {osintkit-0.7.0 → osintkit-0.8.0}/SECURITY.md +0 -0
  30. {osintkit-0.7.0 → osintkit-0.8.0}/docs/architecture.md +0 -0
  31. {osintkit-0.7.0 → osintkit-0.8.0}/docs/demo.svg +0 -0
  32. {osintkit-0.7.0 → osintkit-0.8.0}/docs/sample-output.md +0 -0
  33. {osintkit-0.7.0 → osintkit-0.8.0}/docs/social-preview.png +0 -0
  34. {osintkit-0.7.0 → osintkit-0.8.0}/examples/README.md +0 -0
  35. {osintkit-0.7.0 → osintkit-0.8.0}/examples/targets.csv +0 -0
  36. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/catalogue.py +0 -0
  37. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/__init__.py +0 -0
  38. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/accounts.py +0 -0
  39. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/base.py +0 -0
  40. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/breaches.py +0 -0
  41. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/ct.py +0 -0
  42. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/dns.py +0 -0
  43. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/github.py +0 -0
  44. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/infostealer.py +0 -0
  45. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/pastes.py +0 -0
  46. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/ports.py +0 -0
  47. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/subdomains.py +0 -0
  48. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  49. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/xposed_analytics.py +0 -0
  50. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/config.py +0 -0
  51. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/diff.py +0 -0
  52. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/doctor.py +0 -0
  53. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/errors.py +0 -0
  54. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/http.py +0 -0
  55. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/llm.py +0 -0
  56. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/logging_store.py +0 -0
  57. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/models.py +0 -0
  58. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/normalise.py +0 -0
  59. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/offline.py +0 -0
  60. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/passwords.py +0 -0
  61. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/pipeline.py +0 -0
  62. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/py.typed +0 -0
  63. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/render.py +0 -0
  64. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/retention.py +0 -0
  65. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/synthesis.py +0 -0
  66. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/targets.py +0 -0
  67. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/userconfig.py +0 -0
  68. {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/verify.py +0 -0
  69. {osintkit-0.7.0 → osintkit-0.8.0}/tests/conftest.py +0 -0
  70. {osintkit-0.7.0 → osintkit-0.8.0}/tests/fixtures/model_reply.json +0 -0
  71. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_accounts.py +0 -0
  72. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_breaches.py +0 -0
  73. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_catalogue.py +0 -0
  74. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_collectors.py +0 -0
  75. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_config.py +0 -0
  76. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_ct.py +0 -0
  77. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_diff.py +0 -0
  78. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_doctor.py +0 -0
  79. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_e2e.py +0 -0
  80. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_http.py +0 -0
  81. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_infostealer.py +0 -0
  82. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_llm.py +0 -0
  83. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_normalise.py +0 -0
  84. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_offline.py +0 -0
  85. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_passwords.py +0 -0
  86. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_pastes.py +0 -0
  87. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_pipeline.py +0 -0
  88. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_render.py +0 -0
  89. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_retention.py +0 -0
  90. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_synthesis.py +0 -0
  91. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_targets.py +0 -0
  92. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_userconfig.py +0 -0
  93. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_verify.py +0 -0
  94. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_whois.py +0 -0
  95. {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_xposed_analytics.py +0 -0
  96. {osintkit-0.7.0 → osintkit-0.8.0}/uv.lock +0 -0
@@ -6,6 +6,13 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.8.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `watch`: interval re-runs that stay silent unless a diff shows change, with optional desktop
14
+ notification and bounded cycles
15
+
9
16
  ## 0.7.0 — 2026-11-02
10
17
 
11
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Public-data OSINT aggregator for emails, usernames, domains, and companies
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/osintkit
6
6
  Project-URL: Issues, https://github.com/sjdsjadjajdjsa/osintkit/issues
@@ -106,6 +106,7 @@ Everything else needs no credentials.
106
106
  | `batch` | A CSV of targets, run concurrently, reported in order |
107
107
  | `diff` | What changed between two archived runs |
108
108
  | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
109
+ | `watch` | Re-check on an interval, print only changes, optionally notify |
109
110
  | `history` | Past runs, replayable by id |
110
111
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
111
112
  | `collectors` | Every collector and what it touches |
@@ -172,6 +173,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
172
173
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
173
174
  machine.
174
175
 
176
+ ## Monitoring
177
+
178
+ ```bash
179
+ osintkit watch --domain example.com --every 1d
180
+ osintkit watch --email you@example.com --every 6h --no-notify
181
+ ```
182
+
183
+ The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
184
+ breach, a new host, a source failing, or a risk change — and optionally raise a desktop
185
+ notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
186
+ is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
187
+
175
188
  ## Safety and legality
176
189
 
177
190
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -74,6 +74,7 @@ Everything else needs no credentials.
74
74
  | `batch` | A CSV of targets, run concurrently, reported in order |
75
75
  | `diff` | What changed between two archived runs |
76
76
  | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
77
+ | `watch` | Re-check on an interval, print only changes, optionally notify |
77
78
  | `history` | Past runs, replayable by id |
78
79
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
79
80
  | `collectors` | Every collector and what it touches |
@@ -140,6 +141,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
140
141
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
141
142
  machine.
142
143
 
144
+ ## Monitoring
145
+
146
+ ```bash
147
+ osintkit watch --domain example.com --every 1d
148
+ osintkit watch --email you@example.com --every 6h --no-notify
149
+ ```
150
+
151
+ The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
152
+ breach, a new host, a source failing, or a risk change — and optionally raise a desktop
153
+ notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
154
+ is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
155
+
143
156
  ## Safety and legality
144
157
 
145
158
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -62,6 +62,13 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
62
62
  Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
63
63
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
64
64
 
65
+ ## Watching
66
+
67
+ `watch` repeats a lookup on an interval (`--every`, minimum 60s), prints a baseline, and then
68
+ only reports diffs: added breaches or hosts, risk direction, source failure and recovery.
69
+ `--notify` (default) calls `notify-send` when available; `--full` prints the whole report on a
70
+ change; `--cycles N` bounds the loop. Every cycle is archived and logged like a normal run.
71
+
65
72
  ## Self-check
66
73
 
67
74
  After synthesis every breach name, date, subdomain, and account URL is compared with the supplied
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Public-data OSINT aggregator for emails, usernames, domains, and companies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
@@ -18,7 +18,7 @@ from rich.console import Console
18
18
  from rich.panel import Panel
19
19
  from rich.table import Table
20
20
 
21
- from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig
21
+ from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig, watch
22
22
  from .collectors import REGISTRY
23
23
  from .config import (
24
24
  DEFAULT_RESOLVERS,
@@ -639,6 +639,57 @@ def init(
639
639
  out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
640
640
 
641
641
 
642
+ @app.command("watch")
643
+ def watch_cmd(
644
+ target: str | None = typer.Argument(None, help="Email, username, domain, or company."),
645
+ email: str | None = typer.Option(None, "--email", help="Email target."),
646
+ username: str | None = typer.Option(None, "--username", help="Username target."),
647
+ domain: str | None = typer.Option(None, "--domain", help="Domain target."),
648
+ company: str | None = typer.Option(None, "--company", help="Company target."),
649
+ every: str = typer.Option("1d", "--every", help="Interval: 30m, 6h, 1d."),
650
+ cycles: int = typer.Option(0, "--cycles", min=0, help="Stop after N runs; 0 means forever."),
651
+ full: bool = typer.Option(False, "--full", help="Print the whole report on each change."),
652
+ notify_changes: bool = typer.Option(True, "--notify/--no-notify", help="Desktop notification."),
653
+ offline: bool = typer.Option(False, "--offline", help="Local report only; no model, no key."),
654
+ only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
655
+ skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
656
+ ) -> None:
657
+ """Re-check a target on a schedule and report only changes."""
658
+ out = _console()
659
+ try:
660
+ interval = watch.parse_duration(every)
661
+ raw, declared = _pick_target(target, email, username, domain, company)
662
+ except (ValueError, TargetParseError) as exc:
663
+ out.print(f"[red]{exc}[/red]")
664
+ raise typer.Exit(2) from exc
665
+
666
+ settings = _settings(offline=offline, output_json=False, output_markdown=False, raw=False)
667
+
668
+ async def run_once():
669
+ outcome = await pipeline.execute(
670
+ raw, settings, RunLogger(pipeline.new_run_id()), out, declared_type=declared
671
+ )
672
+ return outcome.report
673
+
674
+ def on_report(report, lines, baseline):
675
+ watch.render_event(report, lines, baseline, out, full=full)
676
+ if lines and notify_changes:
677
+ body = "; ".join(lines)[:180]
678
+ watch.notify(f"change for {report.target}", body)
679
+
680
+ out.print(
681
+ f"[dim]watching {render.escape(raw)} every {every}"
682
+ + (f", {cycles} runs" if cycles else "")
683
+ + " · ctrl-c stops[/dim]"
684
+ )
685
+ try:
686
+ changes = asyncio.run(watch.run(raw, run_once, on_report, every=interval, cycles=cycles))
687
+ except KeyboardInterrupt:
688
+ out.print("[yellow]stopped[/yellow]")
689
+ return
690
+ out.print(f"[dim]{changes} change event(s)[/dim]")
691
+
692
+
642
693
  @app.command()
643
694
  def password(
644
695
  value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
@@ -0,0 +1,97 @@
1
+ """Repeat a lookup on an interval and speak only when something changed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ from . import diff, render
12
+ from .models import Report
13
+
14
+ UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
15
+ DURATION_RE = re.compile(r"^(\d+)([smhd])$")
16
+ MIN_INTERVAL = 60
17
+
18
+
19
+ def parse_duration(text: str) -> int:
20
+ match = DURATION_RE.match(text.strip().lower())
21
+ if not match:
22
+ raise ValueError(f"{text!r} is not a duration like 30m, 6h, or 1d")
23
+ value = int(match.group(1)) * UNITS[match.group(2)]
24
+ if value < MIN_INTERVAL:
25
+ raise ValueError(f"interval must be at least {MIN_INTERVAL}s")
26
+ return value
27
+
28
+
29
+ def describe(change: dict) -> list[str]:
30
+ lines: list[str] = []
31
+ if change["risk_rose"]:
32
+ lines.append(f"risk rose {change['risk']['old']} -> {change['risk']['new']}")
33
+ if change["risk_fell"]:
34
+ lines.append(f"risk fell {change['risk']['old']} -> {change['risk']['new']}")
35
+ for name in change["breaches"]["added"]:
36
+ lines.append(f"new breach: {name}")
37
+ for name in change["subdomains"]["added"]:
38
+ lines.append(f"new host: {name}")
39
+ for name in change["collectors"]["started_failing"]:
40
+ lines.append(f"source failing: {name}")
41
+ for name in change["collectors"]["started_working"]:
42
+ lines.append(f"source recovered: {name}")
43
+ return lines
44
+
45
+
46
+ def notify(title: str, body: str) -> bool:
47
+ binary = shutil.which("notify-send")
48
+ if not binary:
49
+ return False
50
+ try:
51
+ subprocess.run([binary, "osintkit", title, body], check=False, timeout=10)
52
+ return True
53
+ except (OSError, subprocess.SubprocessError):
54
+ return False
55
+
56
+
57
+ async def run(
58
+ target: str,
59
+ run_once: Callable[[], Awaitable[Report]],
60
+ on_report: Callable[[Report, list[str], bool], None],
61
+ *,
62
+ every: int,
63
+ cycles: int = 0,
64
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
65
+ ) -> int:
66
+ """Run `run_once` on an interval. Returns the number of change events."""
67
+ previous: Report | None = None
68
+ changes = 0
69
+ done = 0
70
+ while True:
71
+ report = await run_once()
72
+ done += 1
73
+ lines: list[str] = []
74
+ if previous is not None:
75
+ comparison = diff.compare(previous, report)
76
+ if not diff.is_empty(comparison):
77
+ changes += 1
78
+ lines = describe(comparison)
79
+ if previous is None or lines:
80
+ on_report(report, lines, previous is None)
81
+ previous = report
82
+ if cycles and done >= cycles:
83
+ return changes
84
+ await sleep(every)
85
+
86
+
87
+ def render_event(report: Report, lines: list[str], baseline: bool, console, *, full: bool) -> None:
88
+ stamp = render.escape(report.generated_at)
89
+ if baseline:
90
+ console.print(f"[dim]{stamp} baseline for {render.escape(report.target)}[/dim]")
91
+ render.render_report(report, console)
92
+ return
93
+ console.print(f"[yellow]change[/yellow] {stamp} {render.escape(report.target)}")
94
+ for line in lines:
95
+ console.print(f" - {render.escape(line)}")
96
+ if full:
97
+ render.render_report(report, console)
@@ -688,3 +688,13 @@ def test_env_hibp_key_beats_stored(xdg, monkeypatch):
688
688
  monkeypatch.setattr(pipeline, "execute", spy)
689
689
  runner.invoke(app, ["lookup", "--domain", "a.com"])
690
690
  assert captured["hibp"] == "env-hibp"
691
+
692
+
693
+ def test_watch_runs_a_single_cycle(monkeypatch):
694
+ monkeypatch.setattr(pipeline, "LLMClient", None)
695
+ result = runner.invoke(
696
+ app, ["watch", "--domain", "example.com", "--every", "60s", "--cycles", "1", "--offline"]
697
+ )
698
+ assert result.exit_code == 0
699
+ assert "baseline" in result.output
700
+ assert "0 change event" in result.output
@@ -0,0 +1,81 @@
1
+ import pytest
2
+
3
+ from osintkit import watch
4
+ from osintkit.models import Analysis, Report
5
+
6
+
7
+ def report(risk="low", breaches=()):
8
+ return Report(
9
+ target="example.com",
10
+ target_type="domain",
11
+ analysis=Analysis(summary="s", risk_level=risk, breaches=[{"name": b} for b in breaches]),
12
+ )
13
+
14
+
15
+ def test_parse_duration():
16
+ assert watch.parse_duration("30m") == 1800
17
+ assert watch.parse_duration("2h") == 7200
18
+ assert watch.parse_duration("1d") == 86400
19
+
20
+
21
+ @pytest.mark.parametrize("text", ["10s", "soon", "5x", ""])
22
+ def test_parse_duration_rejects(text):
23
+ with pytest.raises(ValueError):
24
+ watch.parse_duration(text)
25
+
26
+
27
+ def test_describe_lists_changes():
28
+ change = {
29
+ "risk": {"old": "low", "new": "high"},
30
+ "risk_rose": True,
31
+ "risk_fell": False,
32
+ "breaches": {"added": ["Acme"], "removed": []},
33
+ "subdomains": {"added": ["x.example.com"], "removed": []},
34
+ "collectors": {"started_failing": ["crt_sh_subdomains"], "started_working": []},
35
+ }
36
+ lines = watch.describe(change)
37
+ assert "risk rose low -> high" in lines
38
+ assert "new breach: Acme" in lines
39
+ assert "new host: x.example.com" in lines
40
+ assert "source failing: crt_sh_subdomains" in lines
41
+
42
+
43
+ async def test_run_reports_baseline_then_only_changes():
44
+ sequence = [report(), report(), report(risk="high"), report(risk="high")]
45
+ events = []
46
+ slept = []
47
+
48
+ async def run_once():
49
+ return sequence.pop(0)
50
+
51
+ async def sleeper(seconds):
52
+ slept.append(seconds)
53
+
54
+ changes = await watch.run(
55
+ "example.com",
56
+ run_once,
57
+ lambda rep, lines, baseline: events.append((baseline, list(lines))),
58
+ every=60,
59
+ cycles=4,
60
+ sleep=sleeper,
61
+ )
62
+ assert changes == 1
63
+ assert events[0] == (True, [])
64
+ assert events[1][0] is False
65
+ assert any("risk rose" in line for line in events[1][1])
66
+
67
+
68
+ async def test_run_respects_cycles():
69
+ events = []
70
+
71
+ async def run_once():
72
+ return report()
73
+
74
+ async def sleeper(seconds):
75
+ return None
76
+
77
+ changes = await watch.run(
78
+ "example.com", run_once, lambda *a: events.append(a), every=60, cycles=3, sleep=sleeper
79
+ )
80
+ assert changes == 0
81
+ assert len(events) == 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes