boarddata 6.6.2__tar.gz → 6.7.1__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.2 → boarddata-6.7.1}/PKG-INFO +4 -1
  2. {boarddata-6.6.2 → boarddata-6.7.1}/__init__.py +1 -1
  3. {boarddata-6.6.2 → boarddata-6.7.1}/_criteria.py +14 -1
  4. {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/PKG-INFO +4 -1
  5. {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/SOURCES.txt +44 -0
  6. {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/requires.txt +4 -0
  7. boarddata-6.7.1/dq/__init__.py +1 -0
  8. boarddata-6.7.1/dq/adjudicate.py +70 -0
  9. boarddata-6.7.1/dq/apply.py +171 -0
  10. boarddata-6.7.1/dq/context.py +39 -0
  11. boarddata-6.7.1/dq/db.py +155 -0
  12. boarddata-6.7.1/dq/domains.py +90 -0
  13. boarddata-6.7.1/dq/models.py +81 -0
  14. boarddata-6.7.1/dq/oracle.py +45 -0
  15. boarddata-6.7.1/dq/registry.py +32 -0
  16. boarddata-6.7.1/dq/report.py +69 -0
  17. boarddata-6.7.1/dq/rules/__init__.py +6 -0
  18. boarddata-6.7.1/dq/rules/cross_company.py +170 -0
  19. boarddata-6.7.1/dq/rules/lexical.py +70 -0
  20. boarddata-6.7.1/dq/s3.py +52 -0
  21. boarddata-6.7.1/dq/verify_s3.py +104 -0
  22. {boarddata-6.6.2 → boarddata-6.7.1}/pyproject.toml +6 -2
  23. boarddata-6.7.1/scripts/dq_apply.py +112 -0
  24. boarddata-6.7.1/scripts/dq_batch.py +192 -0
  25. boarddata-6.7.1/scripts/dq_plan.py +48 -0
  26. boarddata-6.7.1/scripts/dq_revert.py +36 -0
  27. boarddata-6.7.1/scripts/dq_scan.py +44 -0
  28. boarddata-6.7.1/scripts/dq_verify_s3.py +97 -0
  29. boarddata-6.7.1/tests/__init__.py +0 -0
  30. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_criteria.py +13 -0
  31. {boarddata-6.6.2 → boarddata-6.7.1}/types/criteria.py +14 -0
  32. {boarddata-6.6.2 → boarddata-6.7.1}/CLAUDE.md +0 -0
  33. {boarddata-6.6.2 → boarddata-6.7.1}/README.md +0 -0
  34. {boarddata-6.6.2 → boarddata-6.7.1}/_assemblies.py +0 -0
  35. {boarddata-6.6.2 → boarddata-6.7.1}/_auditors.py +0 -0
  36. {boarddata-6.6.2 → boarddata-6.7.1}/_base.py +0 -0
  37. {boarddata-6.6.2 → boarddata-6.7.1}/_comex.py +0 -0
  38. {boarddata-6.6.2 → boarddata-6.7.1}/_committees.py +0 -0
  39. {boarddata-6.6.2 → boarddata-6.7.1}/_companies.py +0 -0
  40. {boarddata-6.6.2 → boarddata-6.7.1}/_directors.py +0 -0
  41. {boarddata-6.6.2 → boarddata-6.7.1}/_documents.py +0 -0
  42. {boarddata-6.6.2 → boarddata-6.7.1}/_esg.py +0 -0
  43. {boarddata-6.6.2 → boarddata-6.7.1}/_indexes.py +0 -0
  44. {boarddata-6.6.2 → boarddata-6.7.1}/_persons.py +0 -0
  45. {boarddata-6.6.2 → boarddata-6.7.1}/_risks.py +0 -0
  46. {boarddata-6.6.2 → boarddata-6.7.1}/_sentinel.py +0 -0
  47. {boarddata-6.6.2 → boarddata-6.7.1}/_utilities.py +0 -0
  48. {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/dependency_links.txt +0 -0
  49. {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/top_level.txt +0 -0
  50. {boarddata-6.6.2 → boarddata-6.7.1}/cache.py +0 -0
  51. {boarddata-6.6.2 → boarddata-6.7.1}/client.py +0 -0
  52. {boarddata-6.6.2 → boarddata-6.7.1}/errors.py +0 -0
  53. {boarddata-6.6.2 → boarddata-6.7.1}/py.typed +0 -0
  54. {boarddata-6.6.2/tests → boarddata-6.7.1/scripts}/__init__.py +0 -0
  55. {boarddata-6.6.2 → boarddata-6.7.1}/setup.cfg +0 -0
  56. {boarddata-6.6.2 → boarddata-6.7.1}/tests/conftest.py +0 -0
  57. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_assemblies.py +0 -0
  58. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_auditors.py +0 -0
  59. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_base.py +0 -0
  60. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_build_payload.py +0 -0
  61. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_cache.py +0 -0
  62. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_comex.py +0 -0
  63. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_committees.py +0 -0
  64. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_companies.py +0 -0
  65. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_config.py +0 -0
  66. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_directors.py +0 -0
  67. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_documents.py +0 -0
  68. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_esg.py +0 -0
  69. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_exports.py +0 -0
  70. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_indexes.py +0 -0
  71. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_persons.py +0 -0
  72. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_risks.py +0 -0
  73. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_sentinel.py +0 -0
  74. {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_utilities.py +0 -0
  75. {boarddata-6.6.2 → boarddata-6.7.1}/types/__init__.py +0 -0
  76. {boarddata-6.6.2 → boarddata-6.7.1}/types/assemblies.py +0 -0
  77. {boarddata-6.6.2 → boarddata-6.7.1}/types/auditors.py +0 -0
  78. {boarddata-6.6.2 → boarddata-6.7.1}/types/comex.py +0 -0
  79. {boarddata-6.6.2 → boarddata-6.7.1}/types/committees.py +0 -0
  80. {boarddata-6.6.2 → boarddata-6.7.1}/types/companies.py +0 -0
  81. {boarddata-6.6.2 → boarddata-6.7.1}/types/core.py +0 -0
  82. {boarddata-6.6.2 → boarddata-6.7.1}/types/directors.py +0 -0
  83. {boarddata-6.6.2 → boarddata-6.7.1}/types/documents.py +0 -0
  84. {boarddata-6.6.2 → boarddata-6.7.1}/types/esg.py +0 -0
  85. {boarddata-6.6.2 → boarddata-6.7.1}/types/indexes.py +0 -0
  86. {boarddata-6.6.2 → boarddata-6.7.1}/types/persons.py +0 -0
  87. {boarddata-6.6.2 → boarddata-6.7.1}/types/risks.py +0 -0
  88. {boarddata-6.6.2 → boarddata-6.7.1}/types/sentinel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boarddata
3
- Version: 6.6.2
3
+ Version: 6.7.1
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.2"
9
+ __version__ = "6.7.1"
10
10
  __all__ = [
11
11
  "Base",
12
12
  "BoardDataClient",
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from .types.criteria import CriteriaCatalogItem, CriteriaThemeItem
7
+ from .types.criteria import CriteriaCatalogItem, CriteriaFamilyItem, CriteriaThemeItem
8
8
 
9
9
 
10
10
  class CriteriaMixin:
@@ -25,6 +25,19 @@ class CriteriaMixin:
25
25
  """
26
26
  return self._get("criteria-themes/") # type: ignore[attr-defined]
27
27
 
28
+ def list_criteria_families(self) -> list[CriteriaFamilyItem]:
29
+ """List the 17 criteria families (business-meaning classification).
30
+
31
+ Returns:
32
+ List of criteria families in ``sort_order`` (not paginated).
33
+ Slugs are a cross-repo contract — extraction committers
34
+ preflight their families.json against this list.
35
+
36
+ Raises:
37
+ BoardDataError: On non-2xx API response.
38
+ """
39
+ return self._get("criteria-families/") # type: ignore[attr-defined]
40
+
28
41
  def list_criteria(
29
42
  self,
30
43
  theme: str | None = None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boarddata
3
- Version: 6.6.2
3
+ Version: 6.7.1
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,171 @@
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`` — partial. The document row is recreated from a full pre-delete
11
+ snapshot, but its assembly↔document link(s) cannot be restored through the
12
+ current API (the link is read-only / has no create endpoint). The links are
13
+ recorded in the manifest so the gap is explicit, never silent.
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
+
58
+
59
+ def _snapshot(client: FixClient, uid: str) -> dict[str, Any]:
60
+ """Full field snapshot of a document, taken before it is mutated."""
61
+ doc = client.get_document(uid)
62
+ return {k: doc.get(k) for k in _DOC_FIELDS}
63
+
64
+
65
+ def apply_action(action: FixAction, client: FixClient) -> dict[str, Any] | None:
66
+ """Execute one action and return its UndoManifest entry (None for no-op)."""
67
+ if action.action == "keep":
68
+ return None
69
+
70
+ doc_id = action.document_id
71
+ before = _snapshot(client, doc_id) # content fields, captured before any mutation
72
+ # ISIN is not returned by get_document; the plan carries it in before_state.
73
+ before["isin"] = action.before_state.get("isin")
74
+
75
+ if action.action == "delete":
76
+ client.delete_document(doc_id)
77
+ return {
78
+ "action": "delete",
79
+ "document_id": doc_id,
80
+ "before": before,
81
+ # links are gone (backend cascade) and not API-restorable — record them
82
+ "assembly_links": list(action.before_state.get("assembly_links", [])),
83
+ "reversible": "partial",
84
+ }
85
+
86
+ if action.action == "repoint":
87
+ if not action.before_state.get("isin"):
88
+ # No origin ISIN captured -> revert_entry's update_document(isin=None)
89
+ # would send an empty PATCH and silently fail to restore it, even
90
+ # though the manifest would claim reversible="full". Refuse rather
91
+ # than record a revert path that doesn't actually work.
92
+ raise ApplyError(f"repoint {doc_id}: refusing — origin ISIN unknown, revert could not restore it")
93
+ target_isin = (action.evidence.get("true_owner") or {}).get("isin")
94
+ if not target_isin:
95
+ raise ApplyError(f"repoint {doc_id}: no target ISIN in evidence.true_owner")
96
+ client.update_document(doc_id, isin=target_isin)
97
+ return {
98
+ "action": "repoint",
99
+ "document_id": doc_id,
100
+ "before": before, # carries the previous ISIN
101
+ "to_isin": target_isin,
102
+ "reversible": "full",
103
+ }
104
+
105
+ if action.action == "merge":
106
+ raise ApplyError(f"merge {doc_id}: refused — merge is destructive and not yet revertible by dq_revert")
107
+
108
+ raise ApplyError(f"unknown action {action.action!r} for document {doc_id}")
109
+
110
+
111
+ def apply_plan(plan: FixPlan, client: FixClient, *, applied_at: str) -> UndoManifest:
112
+ """Apply every action in ``plan``, returning an UndoManifest for dq_revert.
113
+
114
+ Pre-flight: reject the WHOLE plan atomically if any action is a ``merge``
115
+ or an unrecognized action, before any mutation is issued. Without this, a
116
+ plan like [delete, merge] would apply the delete against prod and only
117
+ then raise on the merge -- leaving an unrevertable mutation with no
118
+ manifest ever written (run_apply writes manifest_<stamp>.json only after
119
+ apply_plan returns successfully).
120
+ """
121
+ for action in plan.actions:
122
+ if action.action not in _APPLIABLE_ACTIONS:
123
+ raise ApplyError(
124
+ f"plan rejected before any mutation: action {action.action!r} for document "
125
+ f"{action.document_id!r} is not directly applicable (merge is refused as "
126
+ "destructive/unrevertible; unknown actions are rejected)"
127
+ )
128
+
129
+ manifest = UndoManifest(plan_ref=plan.source_scan, applied_at=applied_at, entries=[])
130
+ for action in plan.actions:
131
+ entry = apply_action(action, client)
132
+ if entry is not None:
133
+ manifest.entries.append(entry)
134
+ return manifest
135
+
136
+
137
+ def revert_entry(entry: dict[str, Any], client: FixClient) -> dict[str, Any]:
138
+ """Undo a single manifest entry."""
139
+ act = entry["action"]
140
+ doc_id = entry["document_id"]
141
+ before = entry.get("before", {})
142
+
143
+ if act == "repoint":
144
+ client.update_document(doc_id, isin=before.get("isin"))
145
+ return {"document_id": doc_id, "reverted": "repoint", "status": "ok"}
146
+
147
+ if act == "delete":
148
+ new = client.create_document(
149
+ title=before.get("title") or "",
150
+ document_type=before.get("document_type") or "other",
151
+ publication_date=before.get("publication_date"),
152
+ fiscal_year=before.get("fiscal_year"),
153
+ isin=before.get("isin"),
154
+ language=before.get("language"),
155
+ url=before.get("url"),
156
+ original_url=before.get("original_url"),
157
+ )
158
+ return {
159
+ "document_id": doc_id,
160
+ "reverted": "delete",
161
+ "status": "partial", # doc recreated (new id); assembly links not restored
162
+ "new_document_id": new.get("id"),
163
+ "unrestored_assembly_links": list(entry.get("assembly_links", [])),
164
+ }
165
+
166
+ raise ApplyError(f"cannot revert action {act!r} for document {doc_id}")
167
+
168
+
169
+ def revert(manifest: UndoManifest, client: FixClient) -> list[dict[str, Any]]:
170
+ """Undo every entry, most-recent first."""
171
+ 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)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlparse
4
+
5
+ # Official filing/regulator/association sources shared verbatim by many issuers
6
+ # (BALO, AMF, MEDEF, FIR). A doc hosted here is legitimately cross-company.
7
+ SHARED_SOURCE_DOMAINS = frozenset(
8
+ {
9
+ "journal-officiel.gouv.fr", # BALO
10
+ "amf-france.org", # AMF
11
+ "medef.com", # MEDEF
12
+ "frenchsif.org", # FIR
13
+ }
14
+ )
15
+
16
+ # Platform/newswire hosts that carry many issuers' notices/press releases.
17
+ # NOTE: "live.euronext.com" is the platform host (exact) — the bare
18
+ # "euronext.com" is NOT allowlisted, since that is a company's own domain.
19
+ PLATFORM_DOMAINS = frozenset(
20
+ {
21
+ "live.euronext.com",
22
+ "globenewswire.com",
23
+ "businesswire.com",
24
+ }
25
+ )
26
+
27
+ # Trailing hyphenated markers on a company's finance/investor-relations
28
+ # subdomain, e.g. "loreal-finance.com" for L'Oréal's investor site.
29
+ _FINANCE_SUBDOMAIN_MARKERS = ("-finance", "-bourse", "-invest", "-corp")
30
+
31
+ # Generic second-level labels that must NOT be treated as a company identity
32
+ # after stripping a finance marker. If stripping results in one of these,
33
+ # return the original label unchanged to avoid false "same owner" matches.
34
+ _GENERIC_SLDS = frozenset(
35
+ {"invest", "corp", "finance", "bourse", "group", "groupe", "holding", "capital", "bank", "asset"}
36
+ )
37
+
38
+ # Union of all allowlisted domains for shared sources and platforms.
39
+ # Used in is_shared_source to check if a host is within an allowlisted domain.
40
+ _ALLOWED_HOST_DOMAINS = SHARED_SOURCE_DOMAINS | PLATFORM_DOMAINS
41
+
42
+
43
+ def host_of(url: str) -> str:
44
+ netloc = urlparse(url if "//" in url else "//" + url).netloc.lower()
45
+ return netloc[4:] if netloc.startswith("www.") else netloc
46
+
47
+
48
+ def is_shared_source(host: str) -> bool:
49
+ """True when `host` is (or is a subdomain of) an allowlisted shared-source
50
+ or platform/newswire domain. Directional: the host must be *within* the
51
+ allowlisted domain, not the reverse — a short allowlist entry must not
52
+ over-match an unrelated longer host (see domain_matches' reverse clause,
53
+ which this deliberately does not reuse)."""
54
+ if not host:
55
+ return False
56
+ h = host[4:] if host.startswith("www.") else host
57
+ return any(h == d or h.endswith("." + d) for d in _ALLOWED_HOST_DOMAINS)
58
+
59
+
60
+ def _strip_finance_marker(host: str) -> str:
61
+ """Strip a trailing finance/IR marker (e.g. "-finance") from the
62
+ registrable second-level label only (the label immediately left of the
63
+ final TLD), so "loreal-finance.com" normalizes to "loreal.com" while
64
+ markers embedded in other labels (e.g. a subdomain) are left untouched.
65
+
66
+ If stripping results in a generic second-level label (e.g., "invest",
67
+ "corp", "finance"), return the original label unchanged to avoid
68
+ false "same owner" matches for unrelated generic domains."""
69
+ labels = host.split(".")
70
+ if len(labels) < 2:
71
+ return host
72
+ second_level = labels[-2]
73
+ for marker in _FINANCE_SUBDOMAIN_MARKERS:
74
+ if second_level.endswith(marker) and len(second_level) > len(marker):
75
+ stripped = second_level[: -len(marker)]
76
+ # Do not apply the alias if the result is a generic label.
77
+ if stripped not in _GENERIC_SLDS:
78
+ labels[-2] = stripped
79
+ break
80
+ return ".".join(labels)
81
+
82
+
83
+ def domain_matches(host: str, domain: str) -> bool:
84
+ if not host or not domain:
85
+ return False
86
+ a = host[4:] if host.startswith("www.") else host
87
+ b = domain[4:] if domain.startswith("www.") else domain
88
+ a = _strip_finance_marker(a)
89
+ b = _strip_finance_marker(b)
90
+ return a == b or a.endswith("." + b) or b.endswith("." + a)