boardwatch 0.1.0__py3-none-any.whl
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.
- boardwatch/__init__.py +0 -0
- boardwatch/cli/__init__.py +0 -0
- boardwatch/cli/app.py +56 -0
- boardwatch/cli/companies_cmd.py +104 -0
- boardwatch/cli/config_cmd.py +138 -0
- boardwatch/cli/context.py +25 -0
- boardwatch/cli/digest_cmd.py +76 -0
- boardwatch/cli/doctor_cmd.py +106 -0
- boardwatch/cli/eligibility_cmd.py +395 -0
- boardwatch/cli/export_cmd.py +75 -0
- boardwatch/cli/init_cmd.py +126 -0
- boardwatch/cli/profile_cmd.py +185 -0
- boardwatch/cli/scan_cmd.py +39 -0
- boardwatch/cli/show_cmd.py +148 -0
- boardwatch/cli/top_cmd.py +289 -0
- boardwatch/cli/track_cmd.py +163 -0
- boardwatch/core/__init__.py +0 -0
- boardwatch/core/board_urls.py +46 -0
- boardwatch/core/clock.py +17 -0
- boardwatch/core/html_text.py +48 -0
- boardwatch/core/identity.py +18 -0
- boardwatch/core/models.py +67 -0
- boardwatch/core/normalize.py +39 -0
- boardwatch/core/politeness.py +158 -0
- boardwatch/core/secrets.py +33 -0
- boardwatch/core/settings.py +91 -0
- boardwatch/eligibility/__init__.py +2 -0
- boardwatch/eligibility/audit.py +286 -0
- boardwatch/eligibility/catalog.py +526 -0
- boardwatch/eligibility/detect.py +289 -0
- boardwatch/eligibility/engine.py +311 -0
- boardwatch/eligibility/extract_llm.py +162 -0
- boardwatch/eligibility/facts.py +98 -0
- boardwatch/eligibility/ground.py +73 -0
- boardwatch/eligibility/hashing.py +138 -0
- boardwatch/eligibility/preflight.py +177 -0
- boardwatch/eligibility/read.py +48 -0
- boardwatch/eligibility/resolve.py +346 -0
- boardwatch/eligibility/rules.yaml +828 -0
- boardwatch/extract/__init__.py +0 -0
- boardwatch/extract/preflight.py +82 -0
- boardwatch/extract/role_family.py +47 -0
- boardwatch/extract/taxonomy.py +144 -0
- boardwatch/extract/taxonomy.yaml +138 -0
- boardwatch/llm/__init__.py +0 -0
- boardwatch/llm/anthropic.py +104 -0
- boardwatch/llm/cache.py +75 -0
- boardwatch/llm/client.py +31 -0
- boardwatch/llm/factory.py +46 -0
- boardwatch/llm/openai_compat.py +101 -0
- boardwatch/llm/payload.py +75 -0
- boardwatch/llm/prompt.py +49 -0
- boardwatch/providers/__init__.py +0 -0
- boardwatch/providers/ashby.py +181 -0
- boardwatch/providers/base.py +42 -0
- boardwatch/providers/greenhouse.py +142 -0
- boardwatch/providers/lever.py +130 -0
- boardwatch/providers/registry.py +70 -0
- boardwatch/py.typed +0 -0
- boardwatch/rank/__init__.py +0 -0
- boardwatch/rank/explain.py +44 -0
- boardwatch/rank/heuristic.py +163 -0
- boardwatch/registry/README.md +71 -0
- boardwatch/registry/__init__.py +4 -0
- boardwatch/registry/companies.yaml +153 -0
- boardwatch/registry/health_report.py +65 -0
- boardwatch/registry/loader.py +37 -0
- boardwatch/registry/validate.py +43 -0
- boardwatch/reports/__init__.py +0 -0
- boardwatch/reports/digest.py +78 -0
- boardwatch/reports/export.py +135 -0
- boardwatch/scan/__init__.py +0 -0
- boardwatch/scan/apply.py +243 -0
- boardwatch/scan/coordinator.py +171 -0
- boardwatch/scan/health.py +101 -0
- boardwatch/scan/workers.py +16 -0
- boardwatch/store/__init__.py +0 -0
- boardwatch/store/app_state.py +44 -0
- boardwatch/store/applications.py +131 -0
- boardwatch/store/artifacts.py +69 -0
- boardwatch/store/db.py +51 -0
- boardwatch/store/eligibility.py +253 -0
- boardwatch/store/events.py +26 -0
- boardwatch/store/funnel_queries.py +95 -0
- boardwatch/store/migrations/env.py +35 -0
- boardwatch/store/migrations/script.py.mako +22 -0
- boardwatch/store/migrations/versions/8df3b3809bba_schema_v1.py +159 -0
- boardwatch/store/migrations/versions/b7e41c0a9f23_widen_last_health_enum.py +75 -0
- boardwatch/store/migrations/versions/p0_applications.py +66 -0
- boardwatch/store/migrations/versions/p0_artifacts.py +65 -0
- boardwatch/store/migrations/versions/p0_eligibility.py +123 -0
- boardwatch/store/migrations/versions/p0_job_grouping.py +48 -0
- boardwatch/store/migrations/versions/p0_jobs_anchor.py +62 -0
- boardwatch/store/migrations/versions/p0_posting_versions.py +65 -0
- boardwatch/store/migrations/versions/p0_version_sources.py +41 -0
- boardwatch/store/migrations/versions/p2_profile_eligibility.py +23 -0
- boardwatch/store/queries.py +266 -0
- boardwatch/store/sources.py +46 -0
- boardwatch/store/tables.py +372 -0
- boardwatch-0.1.0.dist-info/METADATA +455 -0
- boardwatch-0.1.0.dist-info/RECORD +104 -0
- boardwatch-0.1.0.dist-info/WHEEL +4 -0
- boardwatch-0.1.0.dist-info/entry_points.txt +2 -0
- boardwatch-0.1.0.dist-info/licenses/LICENSE +21 -0
boardwatch/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
boardwatch/cli/app.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""boardwatch CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as package_version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from boardwatch.cli.companies_cmd import companies_app
|
|
10
|
+
from boardwatch.cli.config_cmd import config_app
|
|
11
|
+
from boardwatch.cli.digest_cmd import digest as _digest
|
|
12
|
+
from boardwatch.cli.doctor_cmd import doctor as _doctor
|
|
13
|
+
from boardwatch.cli.eligibility_cmd import eligibility_app
|
|
14
|
+
from boardwatch.cli.export_cmd import export as _export
|
|
15
|
+
from boardwatch.cli.init_cmd import init as _init
|
|
16
|
+
from boardwatch.cli.profile_cmd import profile_app
|
|
17
|
+
from boardwatch.cli.scan_cmd import scan as _scan
|
|
18
|
+
from boardwatch.cli.show_cmd import show as _show
|
|
19
|
+
from boardwatch.cli.top_cmd import top as _top
|
|
20
|
+
from boardwatch.cli.track_cmd import track_app
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
data_dir: Path | None = typer.Option( # noqa: B008
|
|
30
|
+
None, "--data-dir", help="Override the data directory (default: platform data dir)."
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""boardwatch — self-hosted job radar over official ATS APIs."""
|
|
34
|
+
ctx.obj = data_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def version() -> None:
|
|
39
|
+
"""Print the boardwatch version and schema revision."""
|
|
40
|
+
from boardwatch.store.db import schema_revision
|
|
41
|
+
|
|
42
|
+
console.print(f"boardwatch {package_version('boardwatch')} · schema {schema_revision()}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
app.add_typer(companies_app, name="companies")
|
|
46
|
+
app.command("scan")(_scan)
|
|
47
|
+
app.command("init")(_init)
|
|
48
|
+
app.add_typer(profile_app, name="profile")
|
|
49
|
+
app.command("top")(_top)
|
|
50
|
+
app.command("show")(_show)
|
|
51
|
+
app.add_typer(config_app, name="config")
|
|
52
|
+
app.command("doctor")(_doctor)
|
|
53
|
+
app.command("digest")(_digest)
|
|
54
|
+
app.add_typer(eligibility_app, name="eligibility")
|
|
55
|
+
app.add_typer(track_app, name="track")
|
|
56
|
+
app.command("export")(_export)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""boardwatch companies — search / add / remove / list / import / export (§2.3).
|
|
2
|
+
`export` is the registry-format contribution funnel; data-portability export
|
|
3
|
+
(--format jsonl|csv) is P2 and intentionally absent."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import yaml
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from boardwatch.cli.context import build_context
|
|
13
|
+
from boardwatch.core.board_urls import UnknownBoardURL, parse_board_target
|
|
14
|
+
from boardwatch.registry.loader import load_catalog
|
|
15
|
+
from boardwatch.registry.validate import CatalogError, CompanyEntry
|
|
16
|
+
from boardwatch.store.queries import list_watches, unwatch, upsert_watch
|
|
17
|
+
|
|
18
|
+
companies_app = typer.Typer(no_args_is_help=True, help="Manage watched company boards.")
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _catalog_index() -> dict[tuple[str, str], CompanyEntry]:
|
|
23
|
+
return {(e.provider, e.slug): e for e in load_catalog()}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@companies_app.command("add")
|
|
27
|
+
def add(ctx: typer.Context, target: str) -> None:
|
|
28
|
+
"""Watch a board by provider:slug or board URL."""
|
|
29
|
+
try:
|
|
30
|
+
provider, slug = parse_board_target(target)
|
|
31
|
+
except UnknownBoardURL as exc:
|
|
32
|
+
console.print(f"[red]{exc}[/red]")
|
|
33
|
+
raise typer.Exit(code=1) from exc # no DB write on the failed-validation path
|
|
34
|
+
entry = _catalog_index().get((provider, slug))
|
|
35
|
+
source = "registry" if entry else "user"
|
|
36
|
+
name = entry.name if entry else slug
|
|
37
|
+
app_ctx = build_context(ctx.obj)
|
|
38
|
+
with app_ctx.engine.begin() as conn:
|
|
39
|
+
upsert_watch(conn, provider=provider, slug=slug, name=name, source=source)
|
|
40
|
+
console.print(f"Watching {provider}:{slug} (source={source}).")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@companies_app.command("remove")
|
|
44
|
+
def remove(ctx: typer.Context, target: str) -> None:
|
|
45
|
+
provider, slug = parse_board_target(target)
|
|
46
|
+
app_ctx = build_context(ctx.obj)
|
|
47
|
+
with app_ctx.engine.begin() as conn:
|
|
48
|
+
changed = unwatch(conn, provider=provider, slug=slug)
|
|
49
|
+
console.print(f"Unwatched {provider}:{slug}." if changed else "No such watch.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@companies_app.command("search")
|
|
53
|
+
def search(ctx: typer.Context, query: str) -> None:
|
|
54
|
+
"""Case-insensitive substring search over the bundled catalog (offline)."""
|
|
55
|
+
q = query.casefold()
|
|
56
|
+
hits = [e for e in load_catalog() if q in e.name.casefold() or q in e.slug.casefold()]
|
|
57
|
+
table = Table("name", "provider", "slug", "starter")
|
|
58
|
+
for e in hits:
|
|
59
|
+
table.add_row(e.name, e.provider, e.slug, "★" if "starter" in e.tags else "")
|
|
60
|
+
console.print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@companies_app.command("list")
|
|
64
|
+
def list_(ctx: typer.Context) -> None:
|
|
65
|
+
app_ctx = build_context(ctx.obj)
|
|
66
|
+
with app_ctx.engine.connect() as conn:
|
|
67
|
+
rows = list_watches(conn)
|
|
68
|
+
table = Table("provider", "slug", "source", "watched", "last_health", "last_ok_at")
|
|
69
|
+
for r in rows:
|
|
70
|
+
table.add_row(
|
|
71
|
+
r.provider, r.slug, r.source, "yes" if r.watched else "no",
|
|
72
|
+
r.last_health or "—", str(r.last_ok_at or "—"),
|
|
73
|
+
)
|
|
74
|
+
console.print(table)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@companies_app.command("export")
|
|
78
|
+
def export(ctx: typer.Context) -> None:
|
|
79
|
+
"""Emit the user's watches as registry-format YAML (the §3.2 contribution funnel)."""
|
|
80
|
+
app_ctx = build_context(ctx.obj)
|
|
81
|
+
with app_ctx.engine.connect() as conn:
|
|
82
|
+
rows = list_watches(conn)
|
|
83
|
+
payload = {"companies": [
|
|
84
|
+
{"name": r.slug, "provider": r.provider, "slug": r.slug, "tags": []} for r in rows
|
|
85
|
+
]}
|
|
86
|
+
console.print(yaml.safe_dump(payload, sort_keys=False))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@companies_app.command("import")
|
|
90
|
+
def import_(ctx: typer.Context, path: typer.FileText) -> None:
|
|
91
|
+
"""Validate registry-format YAML, then watch each entry."""
|
|
92
|
+
try:
|
|
93
|
+
raw = yaml.safe_load(path.read()) or {}
|
|
94
|
+
entries = [CompanyEntry.model_validate(row) for row in (raw.get("companies") or [])]
|
|
95
|
+
except (CatalogError, ValueError) as exc:
|
|
96
|
+
console.print(f"[red]invalid import file: {exc}[/red]")
|
|
97
|
+
raise typer.Exit(code=1) from exc
|
|
98
|
+
app_ctx = build_context(ctx.obj)
|
|
99
|
+
with app_ctx.engine.begin() as conn:
|
|
100
|
+
for e in entries:
|
|
101
|
+
in_catalog = (e.provider, e.slug) in _catalog_index()
|
|
102
|
+
upsert_watch(conn, provider=e.provider, slug=e.slug, name=e.name,
|
|
103
|
+
source="registry" if in_catalog else "user")
|
|
104
|
+
console.print(f"Imported {len(entries)} watches.")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""boardwatch config show|set (§2.3, D17). Validates against the supported-key table;
|
|
2
|
+
writes config.toml via tomli-w (stdlib has no writer), round-tripping the raw dict so a
|
|
3
|
+
user's unknown-but-harmless keys survive a set."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tomli_w
|
|
11
|
+
import typer
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from boardwatch.cli.context import build_context
|
|
16
|
+
from boardwatch.core.secrets import LLM_API_KEY_ENV, resolve_secret
|
|
17
|
+
from boardwatch.core.settings import Settings, load_settings
|
|
18
|
+
|
|
19
|
+
config_app = typer.Typer(no_args_is_help=True, help="Show or change settings.")
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
# key → (caster, "takes effect", units note). weights.* are nested under [weights].
|
|
23
|
+
_SCALAR_KEYS = {
|
|
24
|
+
"per_host_delay_seconds": (float, "next scan", "seconds, floor 0.25"),
|
|
25
|
+
"retry_attempts": (int, "next scan", "total attempts 1–10 (1 = no retry)"),
|
|
26
|
+
"scan_workers": (int, "next scan", "1–8"),
|
|
27
|
+
}
|
|
28
|
+
_WEIGHT_KEYS = {"skill_coverage", "title_match", "recency", "location_fit"}
|
|
29
|
+
|
|
30
|
+
_SECRET_LEAF_NAMES = frozenset({"api_key", "token", "secret", "password"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_secret_key(name: str) -> bool:
|
|
34
|
+
return name in _SECRET_LEAF_NAMES or name.endswith("_api_key")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_secret_key(raw: dict[str, Any], prefix: str = "") -> str | None:
|
|
38
|
+
"""Dotted path of the first reserved secret key at any depth in raw, or None.
|
|
39
|
+
Recurses through nested tables (dict) and arrays-of-tables (list). Returns a PATH
|
|
40
|
+
only, never a value (secrets contract, P0-3 D-P0-3-5)."""
|
|
41
|
+
for key, value in raw.items():
|
|
42
|
+
path = f"{prefix}{key}"
|
|
43
|
+
if _is_secret_key(key):
|
|
44
|
+
return path
|
|
45
|
+
found = _find_secret_in_value(value, path)
|
|
46
|
+
if found is not None:
|
|
47
|
+
return found
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _find_secret_in_value(value: Any, path: str) -> str | None:
|
|
52
|
+
"""Walk a config value for a reserved secret key; returns a PATH or None, never a value."""
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return _find_secret_key(value, f"{path}.")
|
|
55
|
+
if isinstance(value, list):
|
|
56
|
+
for index, item in enumerate(value):
|
|
57
|
+
found = _find_secret_in_value(item, f"{path}[{index}]")
|
|
58
|
+
if found is not None:
|
|
59
|
+
return found
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_app.command("show")
|
|
64
|
+
def show(ctx: typer.Context) -> None:
|
|
65
|
+
settings = load_settings(data_dir=ctx.obj)
|
|
66
|
+
defaults = Settings(data_dir=settings.data_dir, config_dir=settings.config_dir)
|
|
67
|
+
for key, (_caster, effect, units) in _SCALAR_KEYS.items():
|
|
68
|
+
cur, dflt = getattr(settings, key), getattr(defaults, key)
|
|
69
|
+
console.print(f"{key} = {cur} (default {dflt}; {units}; {effect})")
|
|
70
|
+
for key in sorted(_WEIGHT_KEYS):
|
|
71
|
+
cur, dflt = getattr(settings.weights, key), getattr(defaults.weights, key)
|
|
72
|
+
console.print(f"weights.{key} = {cur} (default {dflt}; [0,1]; next top)")
|
|
73
|
+
llm = settings.llm
|
|
74
|
+
console.print(
|
|
75
|
+
f"llm: reserved (opt-in; ships v1.1); "
|
|
76
|
+
f"enabled={llm.enabled}, provider={llm.provider}, model={llm.model}"
|
|
77
|
+
)
|
|
78
|
+
present = "set" if resolve_secret(LLM_API_KEY_ENV) is not None else "unset"
|
|
79
|
+
console.print(f"llm.api_key: {present} (via {LLM_API_KEY_ENV})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@config_app.command("set")
|
|
83
|
+
def set_(ctx: typer.Context, key: str, value: str) -> None:
|
|
84
|
+
settings = load_settings(data_dir=ctx.obj)
|
|
85
|
+
config_file = settings.config_dir / "config.toml"
|
|
86
|
+
raw = tomllib.loads(config_file.read_text(encoding="utf-8")) if config_file.is_file() else {}
|
|
87
|
+
|
|
88
|
+
existing_secret = _find_secret_key(raw)
|
|
89
|
+
if existing_secret is not None:
|
|
90
|
+
console.print(
|
|
91
|
+
f"[red]refusing to write: config.toml must not contain secrets; found "
|
|
92
|
+
f"reserved key {existing_secret!r}. Remove it and put credentials in the "
|
|
93
|
+
f"environment ({LLM_API_KEY_ENV}).[/red]"
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(code=1)
|
|
96
|
+
if _is_secret_key(key.rsplit(".", 1)[-1]):
|
|
97
|
+
console.print(
|
|
98
|
+
f"[red]secrets do not belong in config.toml; set {LLM_API_KEY_ENV} in the "
|
|
99
|
+
f"environment instead of {key!r}.[/red]"
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
if key == "llm" or key.startswith("llm."):
|
|
103
|
+
console.print(
|
|
104
|
+
f"[red]{key!r} is reserved for the v1.1 LLM tier and is not settable yet.[/red]"
|
|
105
|
+
)
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
if key in _SCALAR_KEYS:
|
|
109
|
+
caster, _e, _u = _SCALAR_KEYS[key]
|
|
110
|
+
old = getattr(settings, key)
|
|
111
|
+
try:
|
|
112
|
+
new = caster(value)
|
|
113
|
+
# construct Settings with the new value → the Field(ge/le) range check fires
|
|
114
|
+
Settings(data_dir=settings.data_dir, config_dir=settings.config_dir, **{key: new})
|
|
115
|
+
except (ValueError, ValidationError) as exc:
|
|
116
|
+
console.print(f"[red]invalid value for {key}: {exc}[/red]")
|
|
117
|
+
raise typer.Exit(code=1) from exc
|
|
118
|
+
raw[key] = new
|
|
119
|
+
elif key.startswith("weights.") and key.split(".", 1)[1] in _WEIGHT_KEYS:
|
|
120
|
+
from boardwatch.core.settings import RankWeights
|
|
121
|
+
|
|
122
|
+
leaf = key.split(".", 1)[1]
|
|
123
|
+
old = getattr(settings.weights, leaf)
|
|
124
|
+
try:
|
|
125
|
+
new = float(value)
|
|
126
|
+
RankWeights(**{**settings.weights.model_dump(), leaf: new}) # [0,1] range check
|
|
127
|
+
except (ValueError, ValidationError) as exc:
|
|
128
|
+
console.print(f"[red]invalid value for {key}: {exc}[/red]")
|
|
129
|
+
raise typer.Exit(code=1) from exc
|
|
130
|
+
raw.setdefault("weights", {})[leaf] = new
|
|
131
|
+
else:
|
|
132
|
+
console.print(f"[red]unknown key {key!r}[/red]")
|
|
133
|
+
raise typer.Exit(code=1)
|
|
134
|
+
|
|
135
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
config_file.write_bytes(tomli_w.dumps(raw).encode("utf-8")) # round-trips unknown keys
|
|
137
|
+
console.print(f"{key}: {old} → {new}")
|
|
138
|
+
build_context(ctx.obj) # ensure the data dir/schema exist (parity with other commands)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Per-invocation app context: settings + engine, built lazily by DB-touching commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Engine
|
|
9
|
+
|
|
10
|
+
from boardwatch.core.settings import Settings, load_settings
|
|
11
|
+
from boardwatch.store.db import ensure_schema, get_engine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AppContext:
|
|
16
|
+
settings: Settings
|
|
17
|
+
engine: Engine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_context(data_dir: Path | None, *, ensure: bool = True) -> AppContext:
|
|
21
|
+
settings = load_settings(data_dir=data_dir)
|
|
22
|
+
engine = get_engine(settings.data_dir, busy_timeout_ms=settings.busy_timeout_ms)
|
|
23
|
+
if ensure:
|
|
24
|
+
ensure_schema(engine)
|
|
25
|
+
return AppContext(settings=settings, engine=engine)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""digest: what changed since you last looked (D18).
|
|
2
|
+
|
|
3
|
+
Reads the event window past the stored cursor, renders it, and advances the cursor.
|
|
4
|
+
Issues BEGIN IMMEDIATE before the first cursor read so two concurrent digests cannot
|
|
5
|
+
both read the same window (A1). --peek renders without advancing.
|
|
6
|
+
|
|
7
|
+
A crash after terminal output can still re-render a window. Exactly-once terminal
|
|
8
|
+
rendering is not achievable transactionally, and claiming it would be false.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from sqlalchemy import text
|
|
19
|
+
|
|
20
|
+
from boardwatch.cli.context import build_context
|
|
21
|
+
from boardwatch.reports.digest import DigestEntry, DigestSummary, summarize_events
|
|
22
|
+
from boardwatch.store.app_state import get_digest_cursor, set_digest_cursor
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
# Explicit accessors rather than getattr: mypy --strict cannot type a getattr lookup, and
|
|
27
|
+
# a typo in a section name would then only surface at runtime.
|
|
28
|
+
_SECTIONS: tuple[tuple[str, Callable[[DigestSummary], tuple[DigestEntry, ...]]], ...] = (
|
|
29
|
+
("New", lambda s: s.new),
|
|
30
|
+
("Reopened", lambda s: s.reopened),
|
|
31
|
+
("Updated", lambda s: s.revised),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render(summary: DigestSummary) -> None:
|
|
36
|
+
for heading, accessor in _SECTIONS:
|
|
37
|
+
entries = accessor(summary)
|
|
38
|
+
if not entries:
|
|
39
|
+
continue
|
|
40
|
+
table = Table(title=heading, show_header=True, header_style="bold", title_justify="left")
|
|
41
|
+
table.add_column("#", style="dim")
|
|
42
|
+
table.add_column("Title")
|
|
43
|
+
table.add_column("Company")
|
|
44
|
+
for entry in entries:
|
|
45
|
+
table.add_row(str(entry.posting_id), entry.title, entry.company)
|
|
46
|
+
console.print(table)
|
|
47
|
+
if summary.closed_count:
|
|
48
|
+
console.print(f"{summary.closed_count} closed since your last digest.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def digest(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
peek: bool = typer.Option(
|
|
54
|
+
False, "--peek", help="Show the same digest without advancing the cursor."
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""New, reopened, updated and closed postings since your last digest."""
|
|
58
|
+
app_ctx = build_context(ctx.obj)
|
|
59
|
+
with app_ctx.engine.connect() as conn:
|
|
60
|
+
# A1: BEGIN IMMEDIATE serializes before the first cursor read so two
|
|
61
|
+
# overlapping digests cannot read the same window.
|
|
62
|
+
conn.execute(text("BEGIN IMMEDIATE"))
|
|
63
|
+
try:
|
|
64
|
+
summary = summarize_events(conn, get_digest_cursor(conn))
|
|
65
|
+
if summary.is_empty:
|
|
66
|
+
console.print("nothing new since your last digest")
|
|
67
|
+
return
|
|
68
|
+
render(summary)
|
|
69
|
+
if not peek:
|
|
70
|
+
set_digest_cursor(conn, summary.max_event_id)
|
|
71
|
+
conn.commit()
|
|
72
|
+
except Exception:
|
|
73
|
+
conn.rollback()
|
|
74
|
+
raise
|
|
75
|
+
if peek:
|
|
76
|
+
console.print("peeked, so the cursor did not move")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""boardwatch doctor — connectivity, per-board health + freshness, DB integrity (§2.3).
|
|
2
|
+
|
|
3
|
+
Runtime is healthy-path-only: ~15 boards ≈ seconds when healthy; DEAD/ERROR/
|
|
4
|
+
UNREACHABLE paths can take minutes (tenacity retries + timeouts)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import version as package_version
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from sqlalchemy import Connection, select, text
|
|
14
|
+
from sqlalchemy.exc import OperationalError
|
|
15
|
+
|
|
16
|
+
from boardwatch.cli.context import build_context
|
|
17
|
+
from boardwatch.core.clock import utcnow
|
|
18
|
+
from boardwatch.scan.coordinator import default_providers
|
|
19
|
+
from boardwatch.scan.health import probe_health
|
|
20
|
+
from boardwatch.store import tables
|
|
21
|
+
from boardwatch.store.db import schema_revision
|
|
22
|
+
from boardwatch.store.queries import last_complete_scan_ages
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _db_revision(conn: Connection) -> str | None:
|
|
28
|
+
"""The DB's applied Alembic revision, or None if the DB is unversioned/uninitialized."""
|
|
29
|
+
try:
|
|
30
|
+
result = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one_or_none()
|
|
31
|
+
except OperationalError: # alembic_version table absent → schema never applied
|
|
32
|
+
return None
|
|
33
|
+
return str(result) if result is not None else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _integrity_check(conn: Connection) -> str:
|
|
37
|
+
"""PRAGMA integrity_check result ('ok' on a healthy DB). A module-level seam so tests
|
|
38
|
+
can force a corruption result without writing bad SQLite pages."""
|
|
39
|
+
return str(conn.execute(text("PRAGMA integrity_check")).scalar_one())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def doctor(ctx: typer.Context, offline: bool = typer.Option(False, "--offline")) -> None:
|
|
43
|
+
"""Connectivity, board health and freshness, and database integrity checks."""
|
|
44
|
+
# ensure=False (context.py supports it): doctor must INSPECT the schema, never migrate it —
|
|
45
|
+
# otherwise a corrupted/absent revision would be silently upgraded before we could report it
|
|
46
|
+
app_ctx = build_context(ctx.obj, ensure=False)
|
|
47
|
+
|
|
48
|
+
with app_ctx.engine.connect() as conn:
|
|
49
|
+
db_revision = _db_revision(conn)
|
|
50
|
+
schema_ok = db_revision == schema_revision()
|
|
51
|
+
if db_revision is None: # absent/unversioned schema — report and stop before probing
|
|
52
|
+
console.print(f"boardwatch {package_version('boardwatch')}")
|
|
53
|
+
console.print("schema: ABSENT (run a boardwatch command that initializes the database)")
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
|
|
56
|
+
report = probe_health(app_ctx.engine, app_ctx.settings, offline=offline)
|
|
57
|
+
|
|
58
|
+
with app_ctx.engine.connect() as conn:
|
|
59
|
+
ages = last_complete_scan_ages(conn)
|
|
60
|
+
watches = conn.execute(
|
|
61
|
+
select(tables.companies).where(tables.companies.c.watched.is_(True))
|
|
62
|
+
).all()
|
|
63
|
+
running = conn.execute(
|
|
64
|
+
select(tables.runs.c.id).where(tables.runs.c.finished_at.is_(None))
|
|
65
|
+
).first()
|
|
66
|
+
integrity = _integrity_check(conn)
|
|
67
|
+
|
|
68
|
+
# connectivity: offline renders "not checked" for EVERY registered provider (not just those
|
|
69
|
+
# with watches — the offline contract); online renders the probed result
|
|
70
|
+
conn_table = Table("provider", "reachable")
|
|
71
|
+
if offline:
|
|
72
|
+
for provider in sorted(default_providers()):
|
|
73
|
+
conn_table.add_row(provider, "not checked")
|
|
74
|
+
else:
|
|
75
|
+
for c in report.connectivity:
|
|
76
|
+
label = "yes" if c.reachable else "NO"
|
|
77
|
+
conn_table.add_row(c.provider, label + (" (fallback)" if c.from_fallback else ""))
|
|
78
|
+
console.print(conn_table)
|
|
79
|
+
|
|
80
|
+
# per-board health + freshness; freshness renders an AGE (duration), not a raw timestamp;
|
|
81
|
+
# offline renders the STORED columns (last_health + last_ok_at)
|
|
82
|
+
now = utcnow()
|
|
83
|
+
health_table = Table("board", "last_health", "last_ok_at", "last_complete_scan_age")
|
|
84
|
+
for w in watches:
|
|
85
|
+
stored = " (stored)" if offline else ""
|
|
86
|
+
ts = ages.get(w.id)
|
|
87
|
+
age = "never" if ts is None else f"{(now - ts).days}d ago"
|
|
88
|
+
health_table.add_row(
|
|
89
|
+
f"{w.provider}:{w.slug}", (w.last_health or "—") + stored,
|
|
90
|
+
str(w.last_ok_at or "—"), age,
|
|
91
|
+
)
|
|
92
|
+
console.print(health_table)
|
|
93
|
+
if running:
|
|
94
|
+
console.print(f"[yellow]a scan is in progress (run {running.id})[/yellow]")
|
|
95
|
+
|
|
96
|
+
# schema check compares the DB's applied revision against the code's expected script head
|
|
97
|
+
schema_ok = db_revision == schema_revision()
|
|
98
|
+
integrity_ok = integrity == "ok"
|
|
99
|
+
console.print(f"boardwatch {package_version('boardwatch')}")
|
|
100
|
+
console.print(
|
|
101
|
+
f"integrity: {integrity} · schema: "
|
|
102
|
+
f"{'ok' if schema_ok else f'MISMATCH (db={db_revision}, code={schema_revision()})'}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
failed = report.actionable or not integrity_ok or not schema_ok
|
|
106
|
+
raise typer.Exit(code=1 if failed else 0)
|