boarddata 6.6.3__tar.gz → 6.7.2__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 (88) hide show
  1. {boarddata-6.6.3 → boarddata-6.7.2}/PKG-INFO +4 -1
  2. {boarddata-6.6.3 → boarddata-6.7.2}/__init__.py +1 -1
  3. {boarddata-6.6.3 → boarddata-6.7.2}/_assemblies.py +19 -0
  4. {boarddata-6.6.3 → boarddata-6.7.2}/boarddata.egg-info/PKG-INFO +4 -1
  5. {boarddata-6.6.3 → boarddata-6.7.2}/boarddata.egg-info/SOURCES.txt +44 -0
  6. {boarddata-6.6.3 → boarddata-6.7.2}/boarddata.egg-info/requires.txt +4 -0
  7. boarddata-6.7.2/dq/__init__.py +1 -0
  8. boarddata-6.7.2/dq/adjudicate.py +70 -0
  9. boarddata-6.7.2/dq/apply.py +186 -0
  10. boarddata-6.7.2/dq/context.py +39 -0
  11. boarddata-6.7.2/dq/db.py +155 -0
  12. boarddata-6.7.2/dq/domains.py +90 -0
  13. boarddata-6.7.2/dq/models.py +81 -0
  14. boarddata-6.7.2/dq/oracle.py +45 -0
  15. boarddata-6.7.2/dq/registry.py +32 -0
  16. boarddata-6.7.2/dq/report.py +69 -0
  17. boarddata-6.7.2/dq/rules/__init__.py +6 -0
  18. boarddata-6.7.2/dq/rules/cross_company.py +170 -0
  19. boarddata-6.7.2/dq/rules/lexical.py +70 -0
  20. boarddata-6.7.2/dq/s3.py +52 -0
  21. boarddata-6.7.2/dq/verify_s3.py +104 -0
  22. {boarddata-6.6.3 → boarddata-6.7.2}/pyproject.toml +6 -2
  23. boarddata-6.7.2/scripts/dq_apply.py +112 -0
  24. boarddata-6.7.2/scripts/dq_batch.py +192 -0
  25. boarddata-6.7.2/scripts/dq_plan.py +48 -0
  26. boarddata-6.7.2/scripts/dq_revert.py +36 -0
  27. boarddata-6.7.2/scripts/dq_scan.py +44 -0
  28. boarddata-6.7.2/scripts/dq_verify_s3.py +97 -0
  29. boarddata-6.7.2/tests/__init__.py +0 -0
  30. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_assemblies.py +26 -0
  31. {boarddata-6.6.3 → boarddata-6.7.2}/types/assemblies.py +12 -0
  32. {boarddata-6.6.3 → boarddata-6.7.2}/CLAUDE.md +0 -0
  33. {boarddata-6.6.3 → boarddata-6.7.2}/README.md +0 -0
  34. {boarddata-6.6.3 → boarddata-6.7.2}/_auditors.py +0 -0
  35. {boarddata-6.6.3 → boarddata-6.7.2}/_base.py +0 -0
  36. {boarddata-6.6.3 → boarddata-6.7.2}/_comex.py +0 -0
  37. {boarddata-6.6.3 → boarddata-6.7.2}/_committees.py +0 -0
  38. {boarddata-6.6.3 → boarddata-6.7.2}/_companies.py +0 -0
  39. {boarddata-6.6.3 → boarddata-6.7.2}/_criteria.py +0 -0
  40. {boarddata-6.6.3 → boarddata-6.7.2}/_directors.py +0 -0
  41. {boarddata-6.6.3 → boarddata-6.7.2}/_documents.py +0 -0
  42. {boarddata-6.6.3 → boarddata-6.7.2}/_esg.py +0 -0
  43. {boarddata-6.6.3 → boarddata-6.7.2}/_indexes.py +0 -0
  44. {boarddata-6.6.3 → boarddata-6.7.2}/_persons.py +0 -0
  45. {boarddata-6.6.3 → boarddata-6.7.2}/_risks.py +0 -0
  46. {boarddata-6.6.3 → boarddata-6.7.2}/_sentinel.py +0 -0
  47. {boarddata-6.6.3 → boarddata-6.7.2}/_utilities.py +0 -0
  48. {boarddata-6.6.3 → boarddata-6.7.2}/boarddata.egg-info/dependency_links.txt +0 -0
  49. {boarddata-6.6.3 → boarddata-6.7.2}/boarddata.egg-info/top_level.txt +0 -0
  50. {boarddata-6.6.3 → boarddata-6.7.2}/cache.py +0 -0
  51. {boarddata-6.6.3 → boarddata-6.7.2}/client.py +0 -0
  52. {boarddata-6.6.3 → boarddata-6.7.2}/errors.py +0 -0
  53. {boarddata-6.6.3 → boarddata-6.7.2}/py.typed +0 -0
  54. {boarddata-6.6.3/tests → boarddata-6.7.2/scripts}/__init__.py +0 -0
  55. {boarddata-6.6.3 → boarddata-6.7.2}/setup.cfg +0 -0
  56. {boarddata-6.6.3 → boarddata-6.7.2}/tests/conftest.py +0 -0
  57. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_auditors.py +0 -0
  58. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_base.py +0 -0
  59. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_build_payload.py +0 -0
  60. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_cache.py +0 -0
  61. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_comex.py +0 -0
  62. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_committees.py +0 -0
  63. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_companies.py +0 -0
  64. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_config.py +0 -0
  65. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_criteria.py +0 -0
  66. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_directors.py +0 -0
  67. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_documents.py +0 -0
  68. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_esg.py +0 -0
  69. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_exports.py +0 -0
  70. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_indexes.py +0 -0
  71. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_persons.py +0 -0
  72. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_risks.py +0 -0
  73. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_sentinel.py +0 -0
  74. {boarddata-6.6.3 → boarddata-6.7.2}/tests/test_utilities.py +0 -0
  75. {boarddata-6.6.3 → boarddata-6.7.2}/types/__init__.py +0 -0
  76. {boarddata-6.6.3 → boarddata-6.7.2}/types/auditors.py +0 -0
  77. {boarddata-6.6.3 → boarddata-6.7.2}/types/comex.py +0 -0
  78. {boarddata-6.6.3 → boarddata-6.7.2}/types/committees.py +0 -0
  79. {boarddata-6.6.3 → boarddata-6.7.2}/types/companies.py +0 -0
  80. {boarddata-6.6.3 → boarddata-6.7.2}/types/core.py +0 -0
  81. {boarddata-6.6.3 → boarddata-6.7.2}/types/criteria.py +0 -0
  82. {boarddata-6.6.3 → boarddata-6.7.2}/types/directors.py +0 -0
  83. {boarddata-6.6.3 → boarddata-6.7.2}/types/documents.py +0 -0
  84. {boarddata-6.6.3 → boarddata-6.7.2}/types/esg.py +0 -0
  85. {boarddata-6.6.3 → boarddata-6.7.2}/types/indexes.py +0 -0
  86. {boarddata-6.6.3 → boarddata-6.7.2}/types/persons.py +0 -0
  87. {boarddata-6.6.3 → boarddata-6.7.2}/types/risks.py +0 -0
  88. {boarddata-6.6.3 → boarddata-6.7.2}/types/sentinel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boarddata
3
- Version: 6.6.3
3
+ Version: 6.7.2
4
4
  Summary: Python SDK for the BoardData V2 REST API
5
5
  License: MIT
6
6
  Classifier: Development Status :: 4 - Beta
@@ -21,6 +21,9 @@ Provides-Extra: dev
21
21
  Requires-Dist: pytest>=7.0; extra == "dev"
22
22
  Requires-Dist: ruff; extra == "dev"
23
23
  Requires-Dist: mypy; extra == "dev"
24
+ Provides-Extra: dq
25
+ Requires-Dist: psycopg[binary]>=3; extra == "dq"
26
+ Requires-Dist: boto3; extra == "dq"
24
27
 
25
28
  # boarddata
26
29
 
@@ -6,7 +6,7 @@ from .cache import FileTokenCache, ScalewaySecretTokenCache
6
6
  from .client import BoardDataClient
7
7
  from .errors import BoardDataError, ExportTooLargeError
8
8
 
9
- __version__ = "6.6.3"
9
+ __version__ = "6.7.2"
10
10
  __all__ = [
11
11
  "Base",
12
12
  "BoardDataClient",
@@ -6,6 +6,7 @@ from typing import Any
6
6
 
7
7
  from .types.assemblies import (
8
8
  AssemblyDetail,
9
+ AssemblyDocumentLink,
9
10
  AssemblyExportItem,
10
11
  CreatePresentationPayload,
11
12
  CreateQuestionPayload,
@@ -456,6 +457,24 @@ class AssemblyMixin:
456
457
  """Delete a question."""
457
458
  self._delete(f"assemblies/{assembly_uid}/questions/{pk}/") # type: ignore[attr-defined]
458
459
 
460
+ # --- Assembly document links ---
461
+ def link_assembly_document(
462
+ self, assembly_uid: str, document_id: str, category: str, doc_type: str
463
+ ) -> AssemblyDocumentLink:
464
+ """Link a document to an assembly (idempotent; returns the link row).
465
+
466
+ The backend refuses cross-company links: ``document`` must belong to the
467
+ assembly's company.
468
+ """
469
+ return self._post( # type: ignore[attr-defined]
470
+ f"assemblies/{assembly_uid}/documents/",
471
+ {"document_id": document_id, "category": category, "doc_type": doc_type},
472
+ )
473
+
474
+ def unlink_assembly_document(self, assembly_uid: str, link_id: str) -> None:
475
+ """Delete an assembly-document link (the Document itself is untouched)."""
476
+ self._delete(f"assemblies/{assembly_uid}/documents/{link_id}/") # type: ignore[attr-defined]
477
+
459
478
  # -- Resolutions --
460
479
 
461
480
  def list_resolutions(self, **params: Any) -> PaginatedResponse:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boarddata
3
- Version: 6.6.3
3
+ Version: 6.7.2
4
4
  Summary: Python SDK for the BoardData V2 REST API
5
5
  License: MIT
6
6
  Classifier: Development Status :: 4 - Beta
@@ -21,6 +21,9 @@ Provides-Extra: dev
21
21
  Requires-Dist: pytest>=7.0; extra == "dev"
22
22
  Requires-Dist: ruff; extra == "dev"
23
23
  Requires-Dist: mypy; extra == "dev"
24
+ Provides-Extra: dq
25
+ Requires-Dist: psycopg[binary]>=3; extra == "dq"
26
+ Requires-Dist: boto3; extra == "dq"
24
27
 
25
28
  # boarddata
26
29
 
@@ -42,11 +42,55 @@ pyproject.toml
42
42
  ./client.py
43
43
  ./errors.py
44
44
  ./py.typed
45
+ ./dq/__init__.py
46
+ ./dq/adjudicate.py
47
+ ./dq/apply.py
48
+ ./dq/context.py
49
+ ./dq/db.py
50
+ ./dq/domains.py
51
+ ./dq/models.py
52
+ ./dq/oracle.py
53
+ ./dq/registry.py
54
+ ./dq/report.py
55
+ ./dq/s3.py
56
+ ./dq/verify_s3.py
57
+ ./dq/rules/__init__.py
58
+ ./dq/rules/cross_company.py
59
+ ./dq/rules/lexical.py
60
+ ./scripts/__init__.py
61
+ ./scripts/dq_apply.py
62
+ ./scripts/dq_batch.py
63
+ ./scripts/dq_plan.py
64
+ ./scripts/dq_revert.py
65
+ ./scripts/dq_scan.py
66
+ ./scripts/dq_verify_s3.py
45
67
  boarddata.egg-info/PKG-INFO
46
68
  boarddata.egg-info/SOURCES.txt
47
69
  boarddata.egg-info/dependency_links.txt
48
70
  boarddata.egg-info/requires.txt
49
71
  boarddata.egg-info/top_level.txt
72
+ dq/__init__.py
73
+ dq/adjudicate.py
74
+ dq/apply.py
75
+ dq/context.py
76
+ dq/db.py
77
+ dq/domains.py
78
+ dq/models.py
79
+ dq/oracle.py
80
+ dq/registry.py
81
+ dq/report.py
82
+ dq/s3.py
83
+ dq/verify_s3.py
84
+ dq/rules/__init__.py
85
+ dq/rules/cross_company.py
86
+ dq/rules/lexical.py
87
+ scripts/__init__.py
88
+ scripts/dq_apply.py
89
+ scripts/dq_batch.py
90
+ scripts/dq_plan.py
91
+ scripts/dq_revert.py
92
+ scripts/dq_scan.py
93
+ scripts/dq_verify_s3.py
50
94
  tests/__init__.py
51
95
  tests/conftest.py
52
96
  tests/test_assemblies.py
@@ -7,3 +7,7 @@ typing_extensions>=4.0
7
7
  pytest>=7.0
8
8
  ruff
9
9
  mypy
10
+
11
+ [dq]
12
+ psycopg[binary]>=3
13
+ boto3
@@ -0,0 +1 @@
1
+ """Document data-quality detection & remediation toolkit."""
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from .context import ScanContext
4
+ from .domains import domain_matches, host_of, is_shared_source
5
+ from .models import DocFinding, FixAction
6
+ from .oracle import file_key, is_s3_key
7
+
8
+
9
+ def _owner_has_own_copy(ctx: ScanContext, finding: DocFinding, owner_cid: str) -> str | None:
10
+ """Return the owner's own document id for the same file, if any."""
11
+ target_key = file_key({"url": finding.url}, ctx)
12
+ for d in ctx.iter_documents():
13
+ if d["company_id"] == owner_cid and file_key(d, ctx) == target_key and d["id"] != finding.document_id:
14
+ return d["id"]
15
+ return None
16
+
17
+
18
+ def adjudicate(finding: DocFinding, ctx: ScanContext) -> FixAction:
19
+ owner = finding.true_owner_guess
20
+ current_company = ctx.company(finding.company_id) if finding.company_id else None
21
+ before = {
22
+ "company_id": finding.company_id,
23
+ # ISIN is not readable back from the document endpoint, so the executor
24
+ # relies on the plan carrying it for repoint-revert / delete-recreate.
25
+ "isin": current_company["isin"] if current_company else None,
26
+ "url": finding.url,
27
+ "assembly_links": ctx.assembly_links(finding.document_id),
28
+ "field_source_count": ctx.field_source_count(finding.document_id),
29
+ }
30
+ ev = {"rule": finding.rule, "true_owner": owner}
31
+ if finding.url and not is_s3_key(finding.url) and is_shared_source(host_of(finding.url)):
32
+ # Platform/shared-source hosts (e.g. live.euronext.com) are
33
+ # legitimately cross-company REGARDLESS of owner inference. Without
34
+ # this top-level guard, a host that matches a real company's own
35
+ # domain (domain_matches("live.euronext.com", "euronext.com") is
36
+ # True) can get an inferred owner and fall into the owner-known
37
+ # branch below, which would emit delete/repoint against a
38
+ # co-attached issuer -- violating the do-not-touch guarantee for
39
+ # shared/platform sources. Guarded to web URLs only (S3-key docs are
40
+ # never contaminated by a web host).
41
+ return FixAction(finding.document_id, "keep", None, before, ev, confidence="low")
42
+ if not owner:
43
+ # No inferable owner. If this is a web-hosted (non-S3) doc whose host
44
+ # is not an allowlisted shared/platform source, and the company's own
45
+ # (known) domain positively contradicts that host, and the row powers
46
+ # no FieldSources, it's a pure contaminant -> cascade-delete it.
47
+ # Deletion requires a *known* domain to disprove attribution against;
48
+ # an unknown domain can never justify a delete (fall through to keep).
49
+ url = finding.url
50
+ if url and not is_s3_key(url):
51
+ host = host_of(url)
52
+ if not is_shared_source(host):
53
+ dom = ctx.domain_for_company(finding.company_id) if finding.company_id else None
54
+ if dom and not domain_matches(host, dom) and before["field_source_count"] == 0:
55
+ return FixAction(finding.document_id, "delete", None, before, ev, confidence="medium")
56
+ return FixAction(finding.document_id, "keep", None, before, ev, confidence="low")
57
+
58
+ owner_cid = owner["company_id"]
59
+ owner_copy = _owner_has_own_copy(ctx, finding, owner_cid)
60
+ fs = ctx.field_source_count(finding.document_id)
61
+
62
+ if fs > 0:
63
+ # Row powers provenance fields -> reparent into the owner's copy if it
64
+ # exists, else repoint the FK so the fields keep a source.
65
+ if owner_copy:
66
+ return FixAction(finding.document_id, "merge", owner_copy, before, ev, "high")
67
+ return FixAction(finding.document_id, "repoint", owner_cid, before, ev, "medium")
68
+ if owner_copy:
69
+ return FixAction(finding.document_id, "delete", None, before, ev, "high")
70
+ return FixAction(finding.document_id, "repoint", owner_cid, before, ev, "medium")
@@ -0,0 +1,186 @@
1
+ """Execute a FixPlan against the BoardData REST API, recording an UndoManifest.
2
+
3
+ This is the write side of the toolkit (``dq_apply`` / ``dq_revert``). Every
4
+ mutation goes through the SDK — ``delete_document`` / ``update_document`` /
5
+ ``create_document`` — so it runs through backend validation, permissions and
6
+ the pghistory audit trail. There is no direct DB write anywhere here.
7
+
8
+ Reversibility, by action:
9
+ * ``repoint`` — full. Restore the document's previous ISIN.
10
+ * ``delete`` — full. The document row is recreated from a full pre-delete
11
+ snapshot, and its assembly↔document link(s) are re-created via
12
+ ``link_assembly_document``. A link that fails to restore (e.g. a
13
+ cross-company refusal) is recorded rather than aborting the revert.
14
+ * ``merge`` — refused. Destructive and not yet revertible; ``apply`` raises
15
+ rather than perform an irreversible operation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Protocol
21
+
22
+ from .models import FixAction, FixPlan, UndoManifest
23
+
24
+ # Content fields we snapshot from get_document before a mutation and replay on
25
+ # revert. NOTE: DocumentDetail exposes ``company_name``, not ``isin`` — the
26
+ # company identity (ISIN) is not readable back from the document endpoint, so it
27
+ # must travel in the plan's before_state (see apply_action).
28
+ _DOC_FIELDS = (
29
+ "title",
30
+ "document_type",
31
+ "publication_date",
32
+ "fiscal_year",
33
+ "language",
34
+ "url",
35
+ "original_url",
36
+ )
37
+
38
+
39
+ class ApplyError(RuntimeError):
40
+ """A FixAction could not be applied or reverted safely."""
41
+
42
+
43
+ # Actions apply_plan will actually execute. Kept in sync with apply_action's
44
+ # own dispatch (merge/unknown raise there too, as a defensive backstop) --
45
+ # this set is what the plan-level pre-flight in apply_plan checks against.
46
+ _APPLIABLE_ACTIONS = {"keep", "delete", "repoint"}
47
+
48
+
49
+ class FixClient(Protocol):
50
+ """The slice of ``BoardDataClient`` the executor depends on."""
51
+
52
+ def get_document(self, uid: str) -> dict[str, Any]: ...
53
+ def create_document(self, title: str, document_type: str, **kwargs: Any) -> dict[str, Any]: ...
54
+ def update_document(self, uid: str, **kwargs: Any) -> dict[str, Any]: ...
55
+ def delete_document(self, uid: str, *, cascade_field_sources: bool = False) -> None: ...
56
+ def merge_document(self, uid: str, *, into: str) -> dict[str, Any]: ...
57
+ def link_assembly_document(
58
+ self, assembly_uid: str, document_id: str, category: str, doc_type: str
59
+ ) -> dict[str, Any]: ...
60
+
61
+
62
+ def _snapshot(client: FixClient, uid: str) -> dict[str, Any]:
63
+ """Full field snapshot of a document, taken before it is mutated."""
64
+ doc = client.get_document(uid)
65
+ return {k: doc.get(k) for k in _DOC_FIELDS}
66
+
67
+
68
+ def apply_action(action: FixAction, client: FixClient) -> dict[str, Any] | None:
69
+ """Execute one action and return its UndoManifest entry (None for no-op)."""
70
+ if action.action == "keep":
71
+ return None
72
+
73
+ doc_id = action.document_id
74
+ before = _snapshot(client, doc_id) # content fields, captured before any mutation
75
+ # ISIN is not returned by get_document; the plan carries it in before_state.
76
+ before["isin"] = action.before_state.get("isin")
77
+
78
+ if action.action == "delete":
79
+ client.delete_document(doc_id)
80
+ return {
81
+ "action": "delete",
82
+ "document_id": doc_id,
83
+ "before": before,
84
+ # links are gone (backend cascade); recorded so revert can re-create them
85
+ "assembly_links": list(action.before_state.get("assembly_links", [])),
86
+ "reversible": "full",
87
+ }
88
+
89
+ if action.action == "repoint":
90
+ if not action.before_state.get("isin"):
91
+ # No origin ISIN captured -> revert_entry's update_document(isin=None)
92
+ # would send an empty PATCH and silently fail to restore it, even
93
+ # though the manifest would claim reversible="full". Refuse rather
94
+ # than record a revert path that doesn't actually work.
95
+ raise ApplyError(f"repoint {doc_id}: refusing — origin ISIN unknown, revert could not restore it")
96
+ target_isin = (action.evidence.get("true_owner") or {}).get("isin")
97
+ if not target_isin:
98
+ raise ApplyError(f"repoint {doc_id}: no target ISIN in evidence.true_owner")
99
+ client.update_document(doc_id, isin=target_isin)
100
+ return {
101
+ "action": "repoint",
102
+ "document_id": doc_id,
103
+ "before": before, # carries the previous ISIN
104
+ "to_isin": target_isin,
105
+ "reversible": "full",
106
+ }
107
+
108
+ if action.action == "merge":
109
+ raise ApplyError(f"merge {doc_id}: refused — merge is destructive and not yet revertible by dq_revert")
110
+
111
+ raise ApplyError(f"unknown action {action.action!r} for document {doc_id}")
112
+
113
+
114
+ def apply_plan(plan: FixPlan, client: FixClient, *, applied_at: str) -> UndoManifest:
115
+ """Apply every action in ``plan``, returning an UndoManifest for dq_revert.
116
+
117
+ Pre-flight: reject the WHOLE plan atomically if any action is a ``merge``
118
+ or an unrecognized action, before any mutation is issued. Without this, a
119
+ plan like [delete, merge] would apply the delete against prod and only
120
+ then raise on the merge -- leaving an unrevertable mutation with no
121
+ manifest ever written (run_apply writes manifest_<stamp>.json only after
122
+ apply_plan returns successfully).
123
+ """
124
+ for action in plan.actions:
125
+ if action.action not in _APPLIABLE_ACTIONS:
126
+ raise ApplyError(
127
+ f"plan rejected before any mutation: action {action.action!r} for document "
128
+ f"{action.document_id!r} is not directly applicable (merge is refused as "
129
+ "destructive/unrevertible; unknown actions are rejected)"
130
+ )
131
+
132
+ manifest = UndoManifest(plan_ref=plan.source_scan, applied_at=applied_at, entries=[])
133
+ for action in plan.actions:
134
+ entry = apply_action(action, client)
135
+ if entry is not None:
136
+ manifest.entries.append(entry)
137
+ return manifest
138
+
139
+
140
+ def revert_entry(entry: dict[str, Any], client: FixClient) -> dict[str, Any]:
141
+ """Undo a single manifest entry."""
142
+ act = entry["action"]
143
+ doc_id = entry["document_id"]
144
+ before = entry.get("before", {})
145
+
146
+ if act == "repoint":
147
+ client.update_document(doc_id, isin=before.get("isin"))
148
+ return {"document_id": doc_id, "reverted": "repoint", "status": "ok"}
149
+
150
+ if act == "delete":
151
+ new = client.create_document(
152
+ title=before.get("title") or "",
153
+ document_type=before.get("document_type") or "other",
154
+ publication_date=before.get("publication_date"),
155
+ fiscal_year=before.get("fiscal_year"),
156
+ isin=before.get("isin"),
157
+ language=before.get("language"),
158
+ url=before.get("url"),
159
+ original_url=before.get("original_url"),
160
+ )
161
+ new_id = new.get("id")
162
+ restored: list[dict[str, Any]] = []
163
+ unrestored: list[dict[str, Any]] = []
164
+ for link in entry.get("assembly_links", []):
165
+ try:
166
+ client.link_assembly_document(
167
+ link["assembly_id"], new_id, link.get("category", ""), link.get("doc_type", "")
168
+ )
169
+ restored.append(link)
170
+ except Exception as exc: # noqa: BLE001 — best-effort restore; record, never abort the revert
171
+ unrestored.append({**link, "error": str(exc)})
172
+ return {
173
+ "document_id": doc_id,
174
+ "reverted": "delete",
175
+ "status": "ok" if not unrestored else "partial",
176
+ "new_document_id": new_id,
177
+ "restored_assembly_links": restored,
178
+ "unrestored_assembly_links": unrestored,
179
+ }
180
+
181
+ raise ApplyError(f"cannot revert action {act!r} for document {doc_id}")
182
+
183
+
184
+ def revert(manifest: UndoManifest, client: FixClient) -> list[dict[str, Any]]:
185
+ """Undo every entry, most-recent first."""
186
+ return [revert_entry(e, client) for e in reversed(manifest.entries)]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class ScanContext(Protocol):
8
+ """Read-only query surface the rules depend on."""
9
+
10
+ def url_groups_multi_company(self) -> list[dict[str, Any]]:
11
+ """Groups of documents sharing an exact URL across >1 company.
12
+ Each: {url, companies: [{document_id, company_id}]}."""
13
+
14
+ def hash_groups_multi_company(self) -> list[dict[str, Any]]:
15
+ """Groups of S3 docs sharing sha256 across >1 company.
16
+ Each: {sha256, companies: [{document_id, company_id, url}]}."""
17
+
18
+ def iter_documents(self, isin: str | None = None) -> list[dict[str, Any]]:
19
+ """Documents (optionally one company); rows: {id, url, title,
20
+ document_type, company_id, fiscal_year}."""
21
+
22
+ def company(self, company_id: str | None) -> dict[str, Any] | None:
23
+ """{id, isin, name, website, deleted_at} or None."""
24
+
25
+ def company_by_isin(self, isin: str) -> dict[str, Any] | None: ...
26
+ def all_companies(self) -> list[dict[str, Any]]: ...
27
+ def assembly_links(self, document_id: str) -> list[dict[str, Any]]:
28
+ """[{assembly_id, assembly_company_id, category, doc_type}]."""
29
+
30
+ def field_source_count(self, document_id: str) -> int: ...
31
+ def client_visible_ids(self) -> set[str]:
32
+ """Doc ids with >=1 assembly link OR >=1 field source, computed in bulk.
33
+
34
+ Batched equivalent of ``bool(assembly_links(id)) or field_source_count(id) > 0``
35
+ so whole-fleet rules do not issue two queries per document."""
36
+
37
+ def domain_for_company(self, company_id: str) -> str | None: ...
38
+ def s3_meta(self, url: str) -> dict[str, Any] | None:
39
+ """{sha256, size_bytes} for an S3-key url, else None."""
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import psycopg
6
+ from psycopg.conninfo import conninfo_to_dict
7
+
8
+ from .context import ScanContext # noqa: F401 (documents the interface)
9
+ from .domains import host_of
10
+ from .s3 import S3Reader
11
+
12
+ URL_GROUPS_SQL = """
13
+ SELECT url, json_agg(json_build_object('document_id', id::text,
14
+ 'company_id', company_id::text)) AS companies
15
+ FROM documents_document
16
+ WHERE url <> '' AND company_id IS NOT NULL
17
+ GROUP BY url
18
+ HAVING count(DISTINCT company_id) > 1
19
+ """
20
+
21
+ HASH_MEMBERS_SQL = """
22
+ SELECT id::text AS document_id, company_id::text AS company_id, url
23
+ FROM documents_document
24
+ WHERE url ~ '^(s3://[^/]+/)?[A-Za-z0-9]+/[0-9]{4}/' AND company_id IS NOT NULL
25
+ """
26
+
27
+ DOC_ALL_SQL = """
28
+ SELECT id::text, url, title, document_type, company_id::text, fiscal_year
29
+ FROM documents_document
30
+ """
31
+
32
+ # One company. Kept separate from DOC_ALL_SQL because a single "%(company_id)s
33
+ # IS NULL OR ..." parameter is type-ambiguous to Postgres on the NULL path
34
+ # (`could not determine data type of parameter $1`).
35
+ DOC_BY_COMPANY_SQL = DOC_ALL_SQL + "WHERE company_id = %(company_id)s"
36
+
37
+ ASSEMBLY_LINKS_SQL = """
38
+ SELECT ad.assembly_id::text, a.company_id::text AS assembly_company_id,
39
+ ad.category, ad.doc_type
40
+ FROM assemblies_assemblydocument ad
41
+ JOIN assemblies_assembly a ON a.id = ad.assembly_id
42
+ WHERE ad.document_id = %(doc_id)s
43
+ """
44
+
45
+ FIELD_SOURCE_COUNT_SQL = "SELECT count(*) FROM core_fieldsource WHERE document_id = %(doc_id)s"
46
+
47
+ CLIENT_VISIBLE_IDS_SQL = """
48
+ SELECT document_id::text AS id FROM assemblies_assemblydocument WHERE document_id IS NOT NULL
49
+ UNION
50
+ SELECT document_id::text AS id FROM core_fieldsource WHERE document_id IS NOT NULL
51
+ """
52
+
53
+ COMPANIES_SQL = "SELECT id::text, isin, name, website, deleted_at FROM companies_company"
54
+
55
+
56
+ def _require_local_host(dsn: str) -> None:
57
+ """Reject any DSN that could connect somewhere other than 127.0.0.1.
58
+
59
+ Parses via libpq (handles both URI and keyword/value DSNs) and checks BOTH
60
+ ``host`` and ``hostaddr``: a URI like ``...@127.0.0.1/db?hostaddr=10.0.0.5``
61
+ passes a naive host check yet libpq connects to the hostaddr.
62
+ """
63
+ try:
64
+ info = conninfo_to_dict(dsn)
65
+ except Exception as exc: # unparseable DSN -> refuse
66
+ raise ValueError(f"Unparseable DSN: {exc}") from exc
67
+ host, hostaddr = info.get("host"), info.get("hostaddr")
68
+ if not host and not hostaddr:
69
+ raise ValueError("DSN must pin host=127.0.0.1 (the operator tunnel); none given.")
70
+ for label, value in (("host", host), ("hostaddr", hostaddr)):
71
+ if value and value != "127.0.0.1":
72
+ raise ValueError(
73
+ f"Refusing DSN {label} {value!r}: connect to the operator tunnel via "
74
+ f"127.0.0.1 (IPv4) only, never localhost/remote."
75
+ )
76
+
77
+
78
+ def _domain_from_website(website: str | None) -> str | None:
79
+ """Authoritative domain from a stored website, tolerating a missing scheme."""
80
+ if not website:
81
+ return None
82
+ return host_of(website) or None
83
+
84
+
85
+ class DbScanContext:
86
+ def __init__(self, dsn: str, s3reader: S3Reader, domain_overrides: dict[str, str] | None = None):
87
+ _require_local_host(dsn)
88
+ self._conn = psycopg.connect(dsn, row_factory=psycopg.rows.dict_row)
89
+ self._s3 = s3reader
90
+ self._companies = {c["id"]: c for c in self._q(COMPANIES_SQL)}
91
+ self._by_isin = {c["isin"]: c for c in self._companies.values() if c["isin"]}
92
+ self._domain_overrides = domain_overrides or {}
93
+ self._all_docs: list[dict[str, Any]] | None = None
94
+ self._cv_ids: set[str] | None = None
95
+
96
+ def _q(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
97
+ with self._conn.cursor() as cur:
98
+ cur.execute(sql, params or {})
99
+ return cur.fetchall()
100
+
101
+ def url_groups_multi_company(self) -> list[dict[str, Any]]:
102
+ return [{"url": r["url"], "companies": r["companies"]} for r in self._q(URL_GROUPS_SQL)]
103
+
104
+ def hash_groups_multi_company(self) -> list[dict[str, Any]]:
105
+ from collections import defaultdict
106
+
107
+ buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
108
+ for r in self._q(HASH_MEMBERS_SQL):
109
+ meta = self._s3.meta(r["url"])
110
+ if meta and meta.get("sha256"):
111
+ buckets[meta["sha256"]].append(r)
112
+ return [{"sha256": h, "companies": m} for h, m in buckets.items() if len({x["company_id"] for x in m}) > 1]
113
+
114
+ def iter_documents(self, isin: str | None = None) -> list[dict[str, Any]]:
115
+ if isin is None:
116
+ # Read-only snapshot: cache the full table so repeated whole-fleet
117
+ # passes (rules R3-R5, adjudicate per finding) do not re-query it.
118
+ if self._all_docs is None:
119
+ self._all_docs = self._q(DOC_ALL_SQL)
120
+ return self._all_docs
121
+ company_id = self._by_isin.get(isin, {}).get("id")
122
+ if company_id is None: # unknown ISIN -> empty, never the whole table
123
+ return []
124
+ return self._q(DOC_BY_COMPANY_SQL, {"company_id": company_id})
125
+
126
+ def company(self, company_id: str | None) -> dict[str, Any] | None:
127
+ return self._companies.get(company_id)
128
+
129
+ def company_by_isin(self, isin: str) -> dict[str, Any] | None:
130
+ return self._by_isin.get((isin or "").upper()) or self._by_isin.get(isin)
131
+
132
+ def all_companies(self) -> list[dict[str, Any]]:
133
+ return list(self._companies.values())
134
+
135
+ def assembly_links(self, document_id: str) -> list[dict[str, Any]]:
136
+ return self._q(ASSEMBLY_LINKS_SQL, {"doc_id": document_id})
137
+
138
+ def field_source_count(self, document_id: str) -> int:
139
+ return self._q(FIELD_SOURCE_COUNT_SQL, {"doc_id": document_id})[0]["count"]
140
+
141
+ def client_visible_ids(self) -> set[str]:
142
+ # One UNION query for the whole fleet, memoized: replaces two per-doc
143
+ # queries that made whole-fleet rules (R3/R5) O(2N) round-trips.
144
+ if self._cv_ids is None:
145
+ self._cv_ids = {r["id"] for r in self._q(CLIENT_VISIBLE_IDS_SQL)}
146
+ return self._cv_ids
147
+
148
+ def domain_for_company(self, company_id: str) -> str | None:
149
+ if company_id in self._domain_overrides:
150
+ return self._domain_overrides[company_id]
151
+ c = self._companies.get(company_id)
152
+ return _domain_from_website(c.get("website")) if c else None
153
+
154
+ def s3_meta(self, url: str) -> dict[str, Any] | None:
155
+ return self._s3.meta(url)