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.
- {boarddata-6.6.2 → boarddata-6.7.1}/PKG-INFO +4 -1
- {boarddata-6.6.2 → boarddata-6.7.1}/__init__.py +1 -1
- {boarddata-6.6.2 → boarddata-6.7.1}/_criteria.py +14 -1
- {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/PKG-INFO +4 -1
- {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/SOURCES.txt +44 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/requires.txt +4 -0
- boarddata-6.7.1/dq/__init__.py +1 -0
- boarddata-6.7.1/dq/adjudicate.py +70 -0
- boarddata-6.7.1/dq/apply.py +171 -0
- boarddata-6.7.1/dq/context.py +39 -0
- boarddata-6.7.1/dq/db.py +155 -0
- boarddata-6.7.1/dq/domains.py +90 -0
- boarddata-6.7.1/dq/models.py +81 -0
- boarddata-6.7.1/dq/oracle.py +45 -0
- boarddata-6.7.1/dq/registry.py +32 -0
- boarddata-6.7.1/dq/report.py +69 -0
- boarddata-6.7.1/dq/rules/__init__.py +6 -0
- boarddata-6.7.1/dq/rules/cross_company.py +170 -0
- boarddata-6.7.1/dq/rules/lexical.py +70 -0
- boarddata-6.7.1/dq/s3.py +52 -0
- boarddata-6.7.1/dq/verify_s3.py +104 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/pyproject.toml +6 -2
- boarddata-6.7.1/scripts/dq_apply.py +112 -0
- boarddata-6.7.1/scripts/dq_batch.py +192 -0
- boarddata-6.7.1/scripts/dq_plan.py +48 -0
- boarddata-6.7.1/scripts/dq_revert.py +36 -0
- boarddata-6.7.1/scripts/dq_scan.py +44 -0
- boarddata-6.7.1/scripts/dq_verify_s3.py +97 -0
- boarddata-6.7.1/tests/__init__.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_criteria.py +13 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/criteria.py +14 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/CLAUDE.md +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/README.md +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_assemblies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_auditors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_base.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_comex.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_committees.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_companies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_directors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_documents.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_esg.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_indexes.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_persons.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_risks.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_sentinel.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/_utilities.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/dependency_links.txt +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/boarddata.egg-info/top_level.txt +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/cache.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/client.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/errors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/py.typed +0 -0
- {boarddata-6.6.2/tests → boarddata-6.7.1/scripts}/__init__.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/setup.cfg +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/conftest.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_assemblies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_auditors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_base.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_build_payload.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_cache.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_comex.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_committees.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_companies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_config.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_directors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_documents.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_esg.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_exports.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_indexes.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_persons.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_risks.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_sentinel.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/tests/test_utilities.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/__init__.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/assemblies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/auditors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/comex.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/committees.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/companies.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/core.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/directors.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/documents.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/esg.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/indexes.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/persons.py +0 -0
- {boarddata-6.6.2 → boarddata-6.7.1}/types/risks.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
@@ -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."""
|
boarddata-6.7.1/dq/db.py
ADDED
|
@@ -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)
|