-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpostprocess.py
More file actions
205 lines (165 loc) · 6.99 KB
/
Copy pathpostprocess.py
File metadata and controls
205 lines (165 loc) · 6.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""
capscribe — postprocess.py
Deduplication, Pydantic validation, and CSV export.
Fixes applied (vs original):
- Deduplication key now matches extractor.py's actual field names
(shares / face_value / issue_price, not amount / securities_count)
- Pydantic models validate every event; malformed events are logged
to invalid_events.json instead of silently corrupting the output
- Dedup key uses a full-dict hash for robustness (not 4 hand-picked fields)
- CLI now writes the cleaned JSON back alongside the CSV
"""
from __future__ import annotations
import csv
import hashlib
import json
import sys
from pathlib import Path
from typing import Any, Optional
try:
from pydantic import BaseModel, ValidationError
PYDANTIC_AVAILABLE = True
except ImportError:
PYDANTIC_AVAILABLE = False
print("[warn] pydantic not installed — schema validation skipped. Run: pip install pydantic")
# ── Pydantic models ────────────────────────────────────────────────────────────
if PYDANTIC_AVAILABLE:
class AllotmentEvent(BaseModel):
event_type: str
date: Optional[str] = None
shares: Optional[int] = None
face_value: Optional[float] = None
issue_price: Optional[float] = None
consideration: Optional[str] = None
allottee_category: Optional[str] = None
class BonusIssueEvent(BaseModel):
event_type: str
date: Optional[str] = None
ratio: Optional[str] = None
shares_issued: Optional[int] = None
pre_issue_capital: Optional[int] = None
post_issue_capital: Optional[int] = None
class RightsIssueEvent(BaseModel):
event_type: str
date: Optional[str] = None
ratio: Optional[str] = None
price: Optional[float] = None
shares_offered: Optional[int] = None
class AuthorisedCapitalChange(BaseModel):
event_type: str
date: Optional[str] = None
old_capital: Optional[float] = None
new_capital: Optional[float] = None
resolution_type: Optional[str] = None
_SCHEMA_MAP = {
"allotment": AllotmentEvent,
"bonus_issue": BonusIssueEvent,
"rights_issue": RightsIssueEvent,
"authorised_capital_change": AuthorisedCapitalChange,
}
# ── Validation ─────────────────────────────────────────────────────────────────
def validate_event(event: dict) -> tuple[bool, Any]:
"""
Returns (is_valid, parsed_model_or_raw_dict).
Falls back to accepting the raw dict when Pydantic isn't installed.
"""
if not PYDANTIC_AVAILABLE:
return True, event
etype = event.get("event_type", "").lower()
model_cls = _SCHEMA_MAP.get(etype)
if model_cls is None:
# Unknown event type — accept as-is with a warning
return True, event
try:
parsed = model_cls.model_validate(event)
return True, parsed.model_dump(exclude_none=False)
except ValidationError as exc:
return False, str(exc)
# ── Deduplication ──────────────────────────────────────────────────────────────
def _event_hash(event: dict) -> str:
"""
Stable hash of the normalised event dict.
Using the full dict is more robust than hand-picking 4 fields.
"""
# Sort keys for stability, lowercase string values
normalised = {
k: (v.lower().strip() if isinstance(v, str) else v)
for k, v in sorted(event.items())
}
blob = json.dumps(normalised, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(blob.encode()).hexdigest()
def deduplicate(events: list[dict]) -> list[dict]:
seen: set[str] = set()
out: list[dict] = []
for e in events:
h = _event_hash(e)
if h not in seen:
seen.add(h)
out.append(e)
return out
# ── CSV export ─────────────────────────────────────────────────────────────────
# All fields that might appear across event types
_CSV_FIELDS = [
"event_type",
"date",
"shares",
"shares_issued",
"shares_offered",
"face_value",
"issue_price",
"price",
"consideration",
"allottee_category",
"ratio",
"pre_issue_capital",
"post_issue_capital",
"old_capital",
"new_capital",
"resolution_type",
]
def to_csv(events: list[dict], out_path: Path) -> None:
with open(out_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=_CSV_FIELDS, extrasaction="ignore")
writer.writeheader()
writer.writerows(events)
print(f"CSV saved to {out_path} ({len(events)} rows)")
# ── Main ───────────────────────────────────────────────────────────────────────
def postprocess(json_path: Path) -> None:
raw = json.loads(json_path.read_text(encoding="utf-8"))
# Accept both top-level list and {"capital_events": [...]} wrapper
if isinstance(raw, dict):
events = raw.get("capital_events", [])
meta = {k: v for k, v in raw.items() if k != "capital_events"}
else:
events = raw
meta = {}
print(f"Loaded {len(events)} raw events from {json_path.name}")
# ── Validate ──
valid_events: list[dict] = []
invalid_events: list[dict] = []
for e in events:
ok, result = validate_event(e)
if ok:
valid_events.append(result)
else:
invalid_events.append({"raw": e, "error": result})
if invalid_events:
bad_path = json_path.parent / f"{json_path.stem}_invalid.json"
bad_path.write_text(json.dumps(invalid_events, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[warn] {len(invalid_events)} invalid events logged to {bad_path.name}")
# ── Deduplicate ──
deduped = deduplicate(valid_events)
print(f"After deduplication: {len(deduped)} unique events (removed {len(valid_events) - len(deduped)} duplicates)")
# ── Write cleaned JSON ──
clean_json_path = json_path.parent / f"{json_path.stem}_clean.json"
output = {**meta, "capital_events": deduped}
clean_json_path.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Clean JSON saved to {clean_json_path.name}")
# ── Write CSV ──
csv_path = json_path.with_suffix(".csv")
to_csv(deduped, csv_path)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python postprocess.py <path-to-extracted.json>")
sys.exit(1)
postprocess(Path(sys.argv[1]))