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