Guard every ingester against the corpus-wipe failure mode
Browse filesEvery ingester rebuilds its whole processed file from a fresh scrape and
then wrote unconditionally, so an upstream page that changes shape becomes
data loss. That is not hypothetical: it emptied dmemos.json last commit.
_common gains write_corpus / stored_chunks / preserve_dropped, and all 14
corpus writers now use them:
- write_corpus refuses to replace a corpus with under 80% of its stored
chunks and the process exits non-zero, so a chained `&& py -m
canlex.embed` stops instead of propagating a gutted corpus.
--allow-shrink is the override once a drop is understood.
- Writes are atomic (tmp + os.replace). A run killed mid-write used to
leave truncated JSON, which stored_chunks reads as empty -- disabling
the guard on the next run, exactly when it is needed.
- preserve_dropped re-attaches the last-good chunks of items that failed
this run, restricted to items that actually errored so anything removed
on purpose stays gone. A stored chunk with no identity field is skipped
rather than crashing the rebuild.
ingest.py (the 132 per-Act legislation files, the largest corpus) and
irb_guidelines.py were missing from the first sweep and are covered too.
caselaw.py had no failure tracking at all -- its three loops printed and
continued -- so it now records failures keyed on act_short, the identity
its chunks actually carry.
enf.py is the one deliberate exception to preserve-on-failure: _CHAPTERS is
a blind range(1, 41) probe and a 404 is the module's documented signal that
IRCC retired a chapter, so 404s drop the chapter (is_missing_upstream) while
every other error preserves it. Preserving through 404s would have pinned
withdrawn chapters in the corpus forever. enf and pdi also now treat
"fetched fine, parsed to zero chunks" as a failure -- the exact shape the
D-memo loss took.
No corpus content changes: every data/processed file is byte-identical, and
dmemo rebuilds to the same 1,857 chunks through the new write path. 278
tests (up from 129), all offline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- RUNBOOK.md +27 -4
- canlex/_common.py +86 -0
- canlex/agreement.py +40 -8
- canlex/amps.py +42 -9
- canlex/benefits.py +44 -8
- canlex/caselaw.py +43 -9
- canlex/charter.py +27 -9
- canlex/commentary.py +16 -6
- canlex/delegation.py +38 -8
- canlex/directive.py +45 -13
- canlex/dmemo.py +14 -49
- canlex/enf.py +79 -9
- canlex/ingest.py +15 -10
- canlex/irb_guidelines.py +29 -8
- canlex/pdi.py +55 -10
- canlex/tariff_schedule.py +54 -13
- tests/test_agreement.py +78 -0
- tests/test_amps.py +172 -0
- tests/test_benefits.py +128 -1
- tests/test_caselaw.py +150 -0
- tests/test_charter.py +146 -0
- tests/test_commentary.py +133 -1
- tests/test_common.py +128 -0
- tests/test_delegation.py +89 -0
- tests/test_directive.py +83 -0
- tests/test_dmemo.py +0 -15
- tests/test_enf.py +131 -0
- tests/test_ingest.py +55 -0
- tests/test_irb_guidelines.py +48 -0
- tests/test_pdi.py +108 -0
- tests/test_tariff_schedule.py +206 -0
|
@@ -30,6 +30,31 @@ py -m canlex.eval # gate: no metric below the last committ
|
|
| 30 |
vectors for chunks missing from embeddings.npz and quietly degrades them to
|
| 31 |
BM25-only (a stderr warning is the only signal).
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
## Deploys
|
| 34 |
|
| 35 |
- **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
|
|
@@ -86,11 +111,9 @@ only if it disappears. Two consequences worth knowing:
|
|
| 86 |
that fails to fetch, so a 404 (or a dropped connection) can no longer delete
|
| 87 |
still-in-force guidance. The two D8s were never ingestable and are absent.
|
| 88 |
|
| 89 |
-
|
| 90 |
-
(`--allow-shrink` overrides). That guard exists because the first run after the
|
| 91 |
rebuild scraped zero memos and wrote an empty dmemos.json straight over 1,870
|
| 92 |
-
good chunks
|
| 93 |
-
a scrape has this failure mode; dmemo.py is the only one hardened so far.
|
| 94 |
|
| 95 |
## Curated-dataset review protocol
|
| 96 |
|
|
|
|
| 30 |
vectors for chunks missing from embeddings.npz and quietly degrades them to
|
| 31 |
BM25-only (a stderr warning is the only signal).
|
| 32 |
|
| 33 |
+
### The corpus-write guard
|
| 34 |
+
|
| 35 |
+
Every ingester above rebuilds its whole processed file from a fresh scrape, so
|
| 36 |
+
an upstream page that changes shape turns into data loss at the write. All of
|
| 37 |
+
them now go through `_common.write_corpus`, which **refuses to write a corpus
|
| 38 |
+
below 80% of the stored one** and makes the process exit non-zero:
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
REFUSING to write: 0 chunks would replace 1870 already in dmemos.json. ...
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
That is not a transient error — it means the scrape broke. Diagnose it, don't
|
| 45 |
+
retry. Once the drop is understood and intended (a source genuinely shrank),
|
| 46 |
+
re-run the same module with `--allow-shrink`. Because the runbook chains these
|
| 47 |
+
as `py -m canlex.X && py -m canlex.embed`, a refusal now correctly stops the
|
| 48 |
+
embed instead of propagating a gutted corpus.
|
| 49 |
+
|
| 50 |
+
Most ingesters also **preserve on failure**: an item that errors keeps its
|
| 51 |
+
last-good chunks (`_common.preserve_dropped`), restricted to items that
|
| 52 |
+
actually failed, so anything removed on purpose stays gone. Deliberate
|
| 53 |
+
exception: `enf.py` treats an HTTP 404 as IRCC retiring a chapter — its
|
| 54 |
+
documented policy — so a 404 drops the chapter while any other error preserves
|
| 55 |
+
it. Writes are atomic (tmp + `os.replace`): a killed run cannot leave truncated
|
| 56 |
+
JSON that would read as an empty corpus and disable the guard next time.
|
| 57 |
+
|
| 58 |
## Deploys
|
| 59 |
|
| 60 |
- **MCP Space** (`Beemer0/CanLex`): commit, then `git push space main`.
|
|
|
|
| 111 |
that fails to fetch, so a 404 (or a dropped connection) can no longer delete
|
| 112 |
still-in-force guidance. The two D8s were never ingestable and are absent.
|
| 113 |
|
| 114 |
+
This is the incident the write guard above exists for: the first run after the
|
|
|
|
| 115 |
rebuild scraped zero memos and wrote an empty dmemos.json straight over 1,870
|
| 116 |
+
good chunks, recovered from git. Every ingester now carries the guard.
|
|
|
|
| 117 |
|
| 118 |
## Curated-dataset review protocol
|
| 119 |
|
|
@@ -8,6 +8,8 @@ UA, a longer timeout, a different split target -- stay at the call sites as
|
|
| 8 |
arguments. caselaw.py keeps its own fetch (retry/throttle for Lexum's CAPTCHA)
|
| 9 |
and its own splitter (sentence-boundary windowing, a different algorithm).
|
| 10 |
"""
|
|
|
|
|
|
|
| 11 |
import re
|
| 12 |
import subprocess
|
| 13 |
import time
|
|
@@ -94,6 +96,90 @@ def split_lines(text, target=1800, fold_stubs=False):
|
|
| 94 |
return pieces
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def uniquify_ids(chunks):
|
| 98 |
"""Rename duplicate chunk ids in place (second occurrence -> id-b, -c...).
|
| 99 |
|
|
|
|
| 8 |
arguments. caselaw.py keeps its own fetch (retry/throttle for Lexum's CAPTCHA)
|
| 9 |
and its own splitter (sentence-boundary windowing, a different algorithm).
|
| 10 |
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
import re
|
| 14 |
import subprocess
|
| 15 |
import time
|
|
|
|
| 96 |
return pieces
|
| 97 |
|
| 98 |
|
| 99 |
+
# Every ingester rebuilds its whole processed file from a fresh scrape, so an
|
| 100 |
+
# upstream page that changes shape -- or a run that loses its network halfway --
|
| 101 |
+
# shows up as a collapse in chunk count, and the write turns that into data
|
| 102 |
+
# loss. On 2026-07-27 CBSA's rebuilt D-memo index scraped to zero memos and the
|
| 103 |
+
# write replaced 1,870 good chunks with an empty list; one line of stdout was
|
| 104 |
+
# the only warning. Refuse to write below this share of what is already stored.
|
| 105 |
+
CORPUS_SHRINK_GUARD = 0.8
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def stored_chunks(path):
|
| 109 |
+
"""The chunks already written to `path`, or [] if absent or unreadable."""
|
| 110 |
+
if not path.exists():
|
| 111 |
+
return []
|
| 112 |
+
try:
|
| 113 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 114 |
+
except (ValueError, OSError):
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def safe_to_write(new_count, old_count, ratio=CORPUS_SHRINK_GUARD):
|
| 119 |
+
"""False when writing `new_count` chunks would gut an existing corpus.
|
| 120 |
+
|
| 121 |
+
Pure, for tests. The write is the irreversible step, so it is the one to
|
| 122 |
+
gate -- a first-ever run (old_count 0) is always allowed.
|
| 123 |
+
"""
|
| 124 |
+
return old_count == 0 or new_count >= old_count * ratio
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def preserve_dropped(chunks, stored, *, key=lambda c: c["section"], only=None):
|
| 128 |
+
"""Re-attach the last-good chunks of items this run failed to produce.
|
| 129 |
+
|
| 130 |
+
`key` maps a chunk to the item it belongs to; `only` restricts preservation
|
| 131 |
+
to a set of item identities (pass the ones that actually errored, so items
|
| 132 |
+
dropped ON PURPOSE -- cancelled memos, retired manuals -- are not
|
| 133 |
+
resurrected). Returns (chunks, preserved identities).
|
| 134 |
+
"""
|
| 135 |
+
def identity(chunk):
|
| 136 |
+
# A chunk written by an older schema may not carry the identity field
|
| 137 |
+
# at all. It simply is not preservable -- but crashing here would take
|
| 138 |
+
# down the rebuild at the exact moment the guard exists to protect it.
|
| 139 |
+
try:
|
| 140 |
+
return key(chunk)
|
| 141 |
+
except (KeyError, IndexError, TypeError):
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
fresh = {identity(c) for c in chunks}
|
| 145 |
+
by_item = {}
|
| 146 |
+
for chunk in stored:
|
| 147 |
+
item = identity(chunk)
|
| 148 |
+
if item is not None:
|
| 149 |
+
by_item.setdefault(item, []).append(chunk)
|
| 150 |
+
dropped = sorted(item for item in by_item
|
| 151 |
+
if item not in fresh and (only is None or item in only))
|
| 152 |
+
for item in dropped:
|
| 153 |
+
chunks.extend(by_item[item])
|
| 154 |
+
return chunks, dropped
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def write_corpus(path, chunks, *, indent=2, allow_shrink=False,
|
| 158 |
+
ratio=CORPUS_SHRINK_GUARD):
|
| 159 |
+
"""Write `chunks` to `path`, unless that would gut the stored corpus.
|
| 160 |
+
|
| 161 |
+
Returns True if written, False if refused -- callers exit non-zero on False
|
| 162 |
+
so a broken scrape fails loudly instead of quietly emptying a file.
|
| 163 |
+
"""
|
| 164 |
+
stored = stored_chunks(path)
|
| 165 |
+
if not allow_shrink and not safe_to_write(len(chunks), len(stored), ratio):
|
| 166 |
+
print(f" REFUSING to write: {len(chunks)} chunks would replace "
|
| 167 |
+
f"{len(stored)} already in {path.name}. The upstream source "
|
| 168 |
+
f"likely changed shape -- check the scrape before writing "
|
| 169 |
+
f"(re-run with --allow-shrink once the drop is understood).")
|
| 170 |
+
return False
|
| 171 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
# Write-then-rename, not write-in-place: a run killed mid-write (Ctrl-C on
|
| 173 |
+
# the scheduled refresh, a full disk) would otherwise leave truncated JSON
|
| 174 |
+
# behind, and stored_chunks reads unparseable as empty -- which disables
|
| 175 |
+
# the guard above on the very next run, when it is needed most.
|
| 176 |
+
tmp = path.with_name(path.name + ".tmp")
|
| 177 |
+
tmp.write_text(json.dumps(chunks, ensure_ascii=False, indent=indent),
|
| 178 |
+
encoding="utf-8")
|
| 179 |
+
os.replace(tmp, path)
|
| 180 |
+
return True
|
| 181 |
+
|
| 182 |
+
|
| 183 |
def uniquify_ids(chunks):
|
| 184 |
"""Rename duplicate chunk ids in place (second occurrence -> id-b, -c...).
|
| 185 |
|
|
@@ -4,13 +4,13 @@ A collective agreement is a binding contract between the Treasury Board and a
|
|
| 4 |
bargaining agent for one occupational group. Chunks are tagged
|
| 5 |
doc_type="agreement" so CanLex keeps them distinct from legislation and guidance.
|
| 6 |
"""
|
| 7 |
-
import json
|
| 8 |
import re
|
| 9 |
import sys
|
| 10 |
|
| 11 |
from bs4 import BeautifulSoup
|
| 12 |
|
| 13 |
-
from ._common import fetch_cached, norm_ws as _norm,
|
|
|
|
| 14 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
|
| 16 |
AGREEMENT_DIR = RAW_DIR / "agreements"
|
|
@@ -142,9 +142,23 @@ def parse_agreement(html, code):
|
|
| 142 |
return chunks
|
| 143 |
|
| 144 |
|
| 145 |
-
def
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
for code, meta in AGREEMENTS.items():
|
| 149 |
print(f"Ingesting {code} ({meta['short']})...")
|
| 150 |
try:
|
|
@@ -152,12 +166,30 @@ def main():
|
|
| 152 |
chunks = parse_agreement(html, code)
|
| 153 |
print(f" {len(chunks)} chunks")
|
| 154 |
all_chunks.extend(chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
except Exception as exc:
|
|
|
|
| 156 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
if __name__ == "__main__":
|
|
|
|
| 4 |
bargaining agent for one occupational group. Chunks are tagged
|
| 5 |
doc_type="agreement" so CanLex keeps them distinct from legislation and guidance.
|
| 6 |
"""
|
|
|
|
| 7 |
import re
|
| 8 |
import sys
|
| 9 |
|
| 10 |
from bs4 import BeautifulSoup
|
| 11 |
|
| 12 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 13 |
+
split_lines, stored_chunks, write_corpus)
|
| 14 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
|
| 16 |
AGREEMENT_DIR = RAW_DIR / "agreements"
|
|
|
|
| 142 |
return chunks
|
| 143 |
|
| 144 |
|
| 145 |
+
def preserve_failed(chunks, failures, stored):
|
| 146 |
+
"""Re-attach the last-good chunks of agreements this run could not rebuild.
|
| 147 |
+
|
| 148 |
+
Ingestion is a full rebuild, so an agreement whose page fails to fetch --
|
| 149 |
+
or whose canada.ca layout changes so that nothing parses out of it --
|
| 150 |
+
disappears from the corpus. An agreement that is still in force is better
|
| 151 |
+
served from the copy we already had than not at all. Restricted to the
|
| 152 |
+
codes that actually failed, so one removed from AGREEMENTS on purpose is
|
| 153 |
+
not resurrected. Returns (chunks, preserved codes).
|
| 154 |
+
"""
|
| 155 |
+
failed = {code for code, _why in failures}
|
| 156 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 157 |
+
only=failed)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def ingest(force=False, allow_shrink=False):
|
| 161 |
+
all_chunks, failures = [], []
|
| 162 |
for code, meta in AGREEMENTS.items():
|
| 163 |
print(f"Ingesting {code} ({meta['short']})...")
|
| 164 |
try:
|
|
|
|
| 166 |
chunks = parse_agreement(html, code)
|
| 167 |
print(f" {len(chunks)} chunks")
|
| 168 |
all_chunks.extend(chunks)
|
| 169 |
+
if not chunks:
|
| 170 |
+
# A page that parses to nothing is a redesign upstream, not an
|
| 171 |
+
# empty agreement -- same class of failure as a dead fetch.
|
| 172 |
+
failures.append((code, "no content parsed"))
|
| 173 |
except Exception as exc:
|
| 174 |
+
failures.append((code, f"{type(exc).__name__}: {exc}"))
|
| 175 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 176 |
+
|
| 177 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 178 |
+
stored_chunks(OUT_FILE))
|
| 179 |
+
if preserved:
|
| 180 |
+
print(f" kept the last-good copy of {len(preserved)} agreement(s): "
|
| 181 |
+
f"{', '.join(preserved)}")
|
| 182 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 183 |
+
allow_shrink=allow_shrink):
|
| 184 |
+
return False
|
| 185 |
print(f"\n{len(all_chunks)} chunks from {len(AGREEMENTS)} agreement(s) -> {OUT_FILE.name}")
|
| 186 |
+
return True
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def main():
|
| 190 |
+
ok = ingest(force="--force" in sys.argv,
|
| 191 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 192 |
+
sys.exit(0 if ok else 1)
|
| 193 |
|
| 194 |
|
| 195 |
if __name__ == "__main__":
|
|
@@ -14,10 +14,11 @@ short), doc_type='memorandum' (administrative guidance, not law).
|
|
| 14 |
|
| 15 |
python -m canlex.amps
|
| 16 |
"""
|
| 17 |
-
import json
|
| 18 |
import re
|
|
|
|
| 19 |
|
| 20 |
-
from ._common import fetch_cached, norm_ws as _norm
|
|
|
|
| 21 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 22 |
|
| 23 |
INDEX_URL = "https://www.cbsa-asfc.gc.ca/trade-commerce/amps/mpd-dmi-eng.html"
|
|
@@ -89,7 +90,23 @@ def _fetch(url, dest):
|
|
| 89 |
return data
|
| 90 |
|
| 91 |
|
| 92 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
html = _fetch(INDEX_URL, RAW / "index.html").decode("utf-8", "replace")
|
| 94 |
links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
|
| 95 |
key=lambda t: t[1])
|
|
@@ -110,12 +127,28 @@ def build(limit=None):
|
|
| 110 |
except Exception as exc:
|
| 111 |
failed.append((code, f"{type(exc).__name__}: {exc}"))
|
| 112 |
out = PROCESSED_DIR / "amps.json"
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
if __name__ == "__main__":
|
| 121 |
-
|
|
|
|
| 14 |
|
| 15 |
python -m canlex.amps
|
| 16 |
"""
|
|
|
|
| 17 |
import re
|
| 18 |
+
import sys
|
| 19 |
|
| 20 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 21 |
+
stored_chunks, write_corpus)
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
INDEX_URL = "https://www.cbsa-asfc.gc.ca/trade-commerce/amps/mpd-dmi-eng.html"
|
|
|
|
| 90 |
return data
|
| 91 |
|
| 92 |
|
| 93 |
+
def preserve_failed(chunks, failed, stored):
|
| 94 |
+
"""Re-attach the last-good chunks of contraventions this run could not fetch.
|
| 95 |
+
|
| 96 |
+
Ingestion is a full rebuild, so a page that 404s or times out silently
|
| 97 |
+
leaves the corpus -- and a penalty still in force is worse missing than
|
| 98 |
+
slightly stale. `failed` carries the (code, why) pairs build() collects;
|
| 99 |
+
codes are lower-case on the index but upper-case in a chunk's `section`,
|
| 100 |
+
so match on the upper-cased form. Restricted to codes that actually
|
| 101 |
+
errored, so a contravention CBSA has genuinely retired stays gone.
|
| 102 |
+
Returns (chunks, preserved codes).
|
| 103 |
+
"""
|
| 104 |
+
codes = {code.upper() for code, _why in failed}
|
| 105 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["section"].upper(),
|
| 106 |
+
only=codes)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def build(limit=None, allow_shrink=False):
|
| 110 |
html = _fetch(INDEX_URL, RAW / "index.html").decode("utf-8", "replace")
|
| 111 |
links = sorted({(m.group(1), m.group(2)) for m in _LINK.finditer(html)},
|
| 112 |
key=lambda t: t[1])
|
|
|
|
| 127 |
except Exception as exc:
|
| 128 |
failed.append((code, f"{type(exc).__name__}: {exc}"))
|
| 129 |
out = PROCESSED_DIR / "amps.json"
|
| 130 |
+
# Printed before the write so a refusal still shows what went wrong.
|
| 131 |
+
if failed:
|
| 132 |
+
print(f"amps: {len(failed)} failed: {failed[:5]}")
|
| 133 |
+
if not limit:
|
| 134 |
+
chunks, preserved = preserve_failed(chunks, failed, stored_chunks(out))
|
| 135 |
+
if preserved:
|
| 136 |
+
print(f"amps: kept the last-good copy of {len(preserved)} "
|
| 137 |
+
f"unfetchable contravention(s): {', '.join(preserved)}")
|
| 138 |
+
# The guard applies to --limit runs too: a smoke test over five
|
| 139 |
+
# contraventions must not be able to write a five-page corpus over the real
|
| 140 |
+
# one. indent=2 matches what is already on disk -- changing it would
|
| 141 |
+
# rewrite every line of the file.
|
| 142 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 143 |
+
return False
|
| 144 |
+
print(f"amps: {len(chunks)} contraventions -> {out.name}")
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main():
|
| 149 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 150 |
+
sys.exit(0 if ok else 1)
|
| 151 |
|
| 152 |
|
| 153 |
if __name__ == "__main__":
|
| 154 |
+
main()
|
|
@@ -29,13 +29,14 @@ benefit topic.
|
|
| 29 |
py -m canlex.benefits
|
| 30 |
"""
|
| 31 |
import io
|
| 32 |
-
import json
|
| 33 |
import re
|
|
|
|
| 34 |
|
| 35 |
from bs4 import BeautifulSoup
|
| 36 |
from pypdf import PdfReader
|
| 37 |
|
| 38 |
-
from ._common import fetch_cached, norm_ws, split_lines
|
|
|
|
| 39 |
|
| 40 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 41 |
|
|
@@ -282,8 +283,24 @@ def _fetch_html(url, dest):
|
|
| 282 |
return fetch_cached(url, dest, powershell=True)
|
| 283 |
|
| 284 |
|
| 285 |
-
def
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
for src in SOURCES.values():
|
| 288 |
print(f"Ingesting {src['code']} {src['label'].lower()} ...")
|
| 289 |
try:
|
|
@@ -296,15 +313,34 @@ def build():
|
|
| 296 |
chunks = parse_html_booklet(html, src)
|
| 297 |
except Exception as exc:
|
| 298 |
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
continue
|
| 300 |
all_chunks.extend(chunks)
|
| 301 |
print(f" {len(chunks)} chunks")
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
|
| 306 |
f"booklet(s) -> {OUT}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
if __name__ == "__main__":
|
| 310 |
-
|
|
|
|
| 29 |
py -m canlex.benefits
|
| 30 |
"""
|
| 31 |
import io
|
|
|
|
| 32 |
import re
|
| 33 |
+
import sys
|
| 34 |
|
| 35 |
from bs4 import BeautifulSoup
|
| 36 |
from pypdf import PdfReader
|
| 37 |
|
| 38 |
+
from ._common import (fetch_cached, norm_ws, preserve_dropped, split_lines,
|
| 39 |
+
stored_chunks, write_corpus)
|
| 40 |
|
| 41 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 42 |
|
|
|
|
| 283 |
return fetch_cached(url, dest, powershell=True)
|
| 284 |
|
| 285 |
|
| 286 |
+
def preserve_failed(chunks, failures, stored):
|
| 287 |
+
"""Re-attach the last-good chunks of plans this run could not rebuild.
|
| 288 |
+
|
| 289 |
+
Ingestion is a full rebuild of all four booklets into one file, so one plan
|
| 290 |
+
failing -- Canada Life moves a PDF, canada.ca times out, a re-typeset
|
| 291 |
+
booklet no longer segments -- silently deletes that plan's coverage while
|
| 292 |
+
the other three look healthy. A plan's chunks all carry its code in
|
| 293 |
+
act_code, so the stored copy can be restored intact. Restricted to plans
|
| 294 |
+
that actually failed, so a plan removed from SOURCES on purpose (a wound-up
|
| 295 |
+
plan) is not resurrected. Returns (chunks, preserved plan codes).
|
| 296 |
+
"""
|
| 297 |
+
failed = {code for code, _why in failures}
|
| 298 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 299 |
+
only=failed)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def build(allow_shrink=False):
|
| 303 |
+
all_chunks, failures = [], []
|
| 304 |
for src in SOURCES.values():
|
| 305 |
print(f"Ingesting {src['code']} {src['label'].lower()} ...")
|
| 306 |
try:
|
|
|
|
| 313 |
chunks = parse_html_booklet(html, src)
|
| 314 |
except Exception as exc:
|
| 315 |
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 316 |
+
failures.append((src["code"], f"{type(exc).__name__}: {exc}"))
|
| 317 |
+
continue
|
| 318 |
+
if not chunks:
|
| 319 |
+
# A booklet that downloads fine but segments to nothing has failed
|
| 320 |
+
# just as surely as one that 404s -- that is how the CBSA index
|
| 321 |
+
# loss looked -- so it counts as a failure rather than a plan that
|
| 322 |
+
# legitimately has no content.
|
| 323 |
+
print(f" !! {src['code']}: no sections parsed")
|
| 324 |
+
failures.append((src["code"], "no sections parsed"))
|
| 325 |
continue
|
| 326 |
all_chunks.extend(chunks)
|
| 327 |
print(f" {len(chunks)} chunks")
|
| 328 |
+
|
| 329 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 330 |
+
stored_chunks(OUT))
|
| 331 |
+
if preserved:
|
| 332 |
+
print(f" kept the last-good copy of {len(preserved)} booklet(s): "
|
| 333 |
+
f"{', '.join(preserved)}")
|
| 334 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 335 |
+
return False
|
| 336 |
print(f"\n{len(all_chunks)} benefit-plan chunks from {len(SOURCES)} "
|
| 337 |
f"booklet(s) -> {OUT}")
|
| 338 |
+
return True
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def main():
|
| 342 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 343 |
|
| 344 |
|
| 345 |
if __name__ == "__main__":
|
| 346 |
+
main()
|
|
@@ -10,15 +10,16 @@ deliberately not a comprehensive scrape.
|
|
| 10 |
|
| 11 |
py -m canlex.caselaw
|
| 12 |
"""
|
| 13 |
-
import json
|
| 14 |
import re
|
|
|
|
| 15 |
import time
|
| 16 |
import urllib.error
|
| 17 |
import urllib.request
|
| 18 |
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
|
| 21 |
-
from ._common import MONTHS as _MONTHS, norm_ws as _norm
|
|
|
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
# Each court or tribunal's Lexum decisions database: (display name, item-URL
|
|
@@ -852,9 +853,21 @@ def _irb_chunks(guide, soup):
|
|
| 852 |
return chunks, cite, len(paras)
|
| 853 |
|
| 854 |
|
| 855 |
-
def build():
|
| 856 |
-
"""Fetch, parse and chunk every curated decision into caselaw.json.
|
| 857 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
for case in CASES:
|
| 859 |
try:
|
| 860 |
soup = BeautifulSoup(_fetch(case["court"], case["id"]),
|
|
@@ -862,11 +875,13 @@ def build():
|
|
| 862 |
except Exception as exc:
|
| 863 |
print(f" !! {case['short']}: fetch failed -- "
|
| 864 |
f"{type(exc).__name__}: {exc}")
|
|
|
|
| 865 |
continue
|
| 866 |
chunks, citation, n_paras = _decision_chunks(case, soup)
|
| 867 |
if not chunks:
|
| 868 |
print(f" !! {case['short']} ({case['court']} item "
|
| 869 |
f"{case['id']}): 0 chunks -- check parsing")
|
|
|
|
| 870 |
continue
|
| 871 |
all_chunks.extend(chunks)
|
| 872 |
print(f" {case['court']:4s} {case['short']:20s} {n_paras:4d} paras -> "
|
|
@@ -878,12 +893,14 @@ def build():
|
|
| 878 |
f"automated fetches (DataDome). Open https://www.canlii.org/"
|
| 879 |
f"{case['canlii_path']} in a browser and save the page text "
|
| 880 |
f"to that path, then re-run.")
|
|
|
|
| 881 |
continue
|
| 882 |
text = cache.read_text(encoding="utf-8-sig")
|
| 883 |
chunks, citation, n_paras = _canlii_chunks(case, text)
|
| 884 |
if not chunks:
|
| 885 |
print(f" !! {case['short']} ({case['file']}): 0 chunks -- "
|
| 886 |
f"check parsing")
|
|
|
|
| 887 |
continue
|
| 888 |
all_chunks.extend(chunks)
|
| 889 |
print(f" cnli {case['short']:20s} {n_paras:4d} paras -> "
|
|
@@ -895,21 +912,38 @@ def build():
|
|
| 895 |
except Exception as exc:
|
| 896 |
print(f" !! {guide['file']}: fetch failed -- "
|
| 897 |
f"{type(exc).__name__}: {exc}")
|
|
|
|
| 898 |
continue
|
| 899 |
chunks, citation, n_paras = _irb_chunks(guide, soup)
|
| 900 |
if not chunks:
|
| 901 |
print(f" !! {guide['file']}: 0 chunks -- check parsing")
|
|
|
|
| 902 |
continue
|
| 903 |
all_chunks.extend(chunks)
|
| 904 |
print(f" irb {guide['file']:20s} {n_paras:4d} paras -> "
|
| 905 |
f"{len(chunks):3d} chunks {citation}")
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
print(f"\n{len(all_chunks)} case-law chunks from "
|
| 910 |
f"{len(CASES) + len(CANLII_CASES) + len(IRB_GUIDES)} decisions "
|
| 911 |
f"-> {OUT}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
|
| 913 |
|
| 914 |
if __name__ == "__main__":
|
| 915 |
-
|
|
|
|
| 10 |
|
| 11 |
py -m canlex.caselaw
|
| 12 |
"""
|
|
|
|
| 13 |
import re
|
| 14 |
+
import sys
|
| 15 |
import time
|
| 16 |
import urllib.error
|
| 17 |
import urllib.request
|
| 18 |
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
|
| 21 |
+
from ._common import (MONTHS as _MONTHS, norm_ws as _norm, preserve_dropped,
|
| 22 |
+
stored_chunks, write_corpus)
|
| 23 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 24 |
|
| 25 |
# Each court or tribunal's Lexum decisions database: (display name, item-URL
|
|
|
|
| 853 |
return chunks, cite, len(paras)
|
| 854 |
|
| 855 |
|
| 856 |
+
def build(allow_shrink=False):
|
| 857 |
+
"""Fetch, parse and chunk every curated decision into caselaw.json.
|
| 858 |
+
|
| 859 |
+
Returns True when the corpus was written, False when the write was refused
|
| 860 |
+
-- a Lexum CAPTCHA wall or an upstream re-platforming makes every fetch
|
| 861 |
+
fail at once, and the rebuild-then-write shape would turn that into an
|
| 862 |
+
emptied caselaw.json.
|
| 863 |
+
"""
|
| 864 |
+
# Every path that drops a decision records it here, keyed the way the
|
| 865 |
+
# chunks are (act_short): Lexum throttles, CanLII captures go missing and
|
| 866 |
+
# court templates shift, so on any given run some decisions fail while the
|
| 867 |
+
# rest succeed -- and the rebuild-then-write shape would quietly delete
|
| 868 |
+
# exactly those. `failed` is what preserve_dropped is allowed to restore,
|
| 869 |
+
# so decisions removed on purpose (deleted from CASES) stay gone.
|
| 870 |
+
all_chunks, failed = [], set()
|
| 871 |
for case in CASES:
|
| 872 |
try:
|
| 873 |
soup = BeautifulSoup(_fetch(case["court"], case["id"]),
|
|
|
|
| 875 |
except Exception as exc:
|
| 876 |
print(f" !! {case['short']}: fetch failed -- "
|
| 877 |
f"{type(exc).__name__}: {exc}")
|
| 878 |
+
failed.add(case["short"])
|
| 879 |
continue
|
| 880 |
chunks, citation, n_paras = _decision_chunks(case, soup)
|
| 881 |
if not chunks:
|
| 882 |
print(f" !! {case['short']} ({case['court']} item "
|
| 883 |
f"{case['id']}): 0 chunks -- check parsing")
|
| 884 |
+
failed.add(case["short"])
|
| 885 |
continue
|
| 886 |
all_chunks.extend(chunks)
|
| 887 |
print(f" {case['court']:4s} {case['short']:20s} {n_paras:4d} paras -> "
|
|
|
|
| 893 |
f"automated fetches (DataDome). Open https://www.canlii.org/"
|
| 894 |
f"{case['canlii_path']} in a browser and save the page text "
|
| 895 |
f"to that path, then re-run.")
|
| 896 |
+
failed.add(case["short"])
|
| 897 |
continue
|
| 898 |
text = cache.read_text(encoding="utf-8-sig")
|
| 899 |
chunks, citation, n_paras = _canlii_chunks(case, text)
|
| 900 |
if not chunks:
|
| 901 |
print(f" !! {case['short']} ({case['file']}): 0 chunks -- "
|
| 902 |
f"check parsing")
|
| 903 |
+
failed.add(case["short"])
|
| 904 |
continue
|
| 905 |
all_chunks.extend(chunks)
|
| 906 |
print(f" cnli {case['short']:20s} {n_paras:4d} paras -> "
|
|
|
|
| 912 |
except Exception as exc:
|
| 913 |
print(f" !! {guide['file']}: fetch failed -- "
|
| 914 |
f"{type(exc).__name__}: {exc}")
|
| 915 |
+
failed.add(guide["file"])
|
| 916 |
continue
|
| 917 |
chunks, citation, n_paras = _irb_chunks(guide, soup)
|
| 918 |
if not chunks:
|
| 919 |
print(f" !! {guide['file']}: 0 chunks -- check parsing")
|
| 920 |
+
failed.add(guide["file"])
|
| 921 |
continue
|
| 922 |
all_chunks.extend(chunks)
|
| 923 |
print(f" irb {guide['file']:20s} {n_paras:4d} paras -> "
|
| 924 |
f"{len(chunks):3d} chunks {citation}")
|
| 925 |
+
# act_short is the per-decision identity in this corpus (section is "" for
|
| 926 |
+
# every case-law chunk), and it is unique across all three lists.
|
| 927 |
+
all_chunks, preserved = preserve_dropped(all_chunks, stored_chunks(OUT),
|
| 928 |
+
key=lambda c: c["act_short"],
|
| 929 |
+
only=failed)
|
| 930 |
+
if preserved:
|
| 931 |
+
print(f" kept the last-good copy of {len(preserved)} decision(s) "
|
| 932 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 933 |
+
# indent=1 is what this corpus was written with; changing it would rewrite
|
| 934 |
+
# every line of caselaw.json for no content change.
|
| 935 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 936 |
+
return False
|
| 937 |
print(f"\n{len(all_chunks)} case-law chunks from "
|
| 938 |
f"{len(CASES) + len(CANLII_CASES) + len(IRB_GUIDES)} decisions "
|
| 939 |
f"-> {OUT}")
|
| 940 |
+
return True
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def main():
|
| 944 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 945 |
+
sys.exit(0 if ok else 1)
|
| 946 |
|
| 947 |
|
| 948 |
if __name__ == "__main__":
|
| 949 |
+
main()
|
|
@@ -11,14 +11,15 @@ the supremacy clause s. 52, and the 1867 heads-of-power ss. 91-92.
|
|
| 11 |
|
| 12 |
python -m canlex.charter
|
| 13 |
"""
|
| 14 |
-
import json
|
| 15 |
import re
|
|
|
|
| 16 |
|
| 17 |
-
from ._common import fetch_cached, norm_ws as _norm
|
| 18 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 19 |
|
| 20 |
URL = "https://laws-lois.justice.gc.ca/eng/const/FullText.html"
|
| 21 |
RAW = RAW_DIR / "charter"
|
|
|
|
| 22 |
|
| 23 |
# (act filter, sections wanted). The 1982 Act's Charter is ss. 1-34; s. 35 is
|
| 24 |
# Part II (Aboriginal rights) and s. 52 the supremacy clause -- both cited
|
|
@@ -51,8 +52,14 @@ def _sections(segment_html):
|
|
| 51 |
yield num_m.group(1), _norm(_strip(m.group(1))), _strip(body_html)
|
| 52 |
|
| 53 |
|
| 54 |
-
def
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# The page is both Acts in sequence; split at the 1982 Act's title so
|
| 57 |
# section numbers (which restart) land in the right instrument.
|
| 58 |
cut = html.find("CONSTITUTION ACT, 1982")
|
|
@@ -90,12 +97,23 @@ def build():
|
|
| 90 |
"citation": f"{short}, {label}",
|
| 91 |
"source_url": URL,
|
| 92 |
})
|
| 93 |
-
out = PROCESSED_DIR / "charter.json"
|
| 94 |
-
out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
|
| 95 |
-
encoding="utf-8")
|
| 96 |
-
print(f"charter: {len(chunks)} sections -> {out.name}")
|
| 97 |
return chunks
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if __name__ == "__main__":
|
| 101 |
-
|
|
|
|
| 11 |
|
| 12 |
python -m canlex.charter
|
| 13 |
"""
|
|
|
|
| 14 |
import re
|
| 15 |
+
import sys
|
| 16 |
|
| 17 |
+
from ._common import fetch_cached, norm_ws as _norm, write_corpus
|
| 18 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 19 |
|
| 20 |
URL = "https://laws-lois.justice.gc.ca/eng/const/FullText.html"
|
| 21 |
RAW = RAW_DIR / "charter"
|
| 22 |
+
OUT_FILE = PROCESSED_DIR / "charter.json"
|
| 23 |
|
| 24 |
# (act filter, sections wanted). The 1982 Act's Charter is ss. 1-34; s. 35 is
|
| 25 |
# Part II (Aboriginal rights) and s. 52 the supremacy clause -- both cited
|
|
|
|
| 52 |
yield num_m.group(1), _norm(_strip(m.group(1))), _strip(body_html)
|
| 53 |
|
| 54 |
|
| 55 |
+
def parse(html):
|
| 56 |
+
"""Chunks for every in-scope section of both Acts. Pure, for tests.
|
| 57 |
+
|
| 58 |
+
Justice Laws could re-shape this page the way CBSA re-shaped its D-memo
|
| 59 |
+
index, in which case the MarginalNote/Section markup stops matching and
|
| 60 |
+
this returns []; the write guard in build() is what keeps that from
|
| 61 |
+
emptying the corpus.
|
| 62 |
+
"""
|
| 63 |
# The page is both Acts in sequence; split at the 1982 Act's title so
|
| 64 |
# section numbers (which restart) land in the right instrument.
|
| 65 |
cut = html.find("CONSTITUTION ACT, 1982")
|
|
|
|
| 97 |
"citation": f"{short}, {label}",
|
| 98 |
"source_url": URL,
|
| 99 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return chunks
|
| 101 |
|
| 102 |
|
| 103 |
+
def build(allow_shrink=False):
|
| 104 |
+
html = fetch_cached(URL, RAW / "FullText.html").decode("utf-8", "replace")
|
| 105 |
+
chunks = parse(html)
|
| 106 |
+
# indent=2 is what charter.json already carries -- changing it would
|
| 107 |
+
# rewrite every line of the file for nothing.
|
| 108 |
+
if not write_corpus(OUT_FILE, chunks, indent=2, allow_shrink=allow_shrink):
|
| 109 |
+
return False
|
| 110 |
+
print(f"charter: {len(chunks)} sections -> {OUT_FILE.name}")
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
if __name__ == "__main__":
|
| 119 |
+
main()
|
|
@@ -9,10 +9,12 @@ flag their reasoning as interpretation. The source of truth is
|
|
| 9 |
data/curated/us_dispositions.json, which is reviewed by the user before it
|
| 10 |
ships; this module just renders it into corpus chunks.
|
| 11 |
|
| 12 |
-
py -m canlex.commentary
|
| 13 |
"""
|
| 14 |
import json
|
|
|
|
| 15 |
|
|
|
|
| 16 |
from .config import DATA_DIR, PROCESSED_DIR
|
| 17 |
|
| 18 |
CURATED = DATA_DIR / "curated" / "us_dispositions.json"
|
|
@@ -105,7 +107,7 @@ def _entry_text(e, max_states=None):
|
|
| 105 |
return "\n".join(lines)
|
| 106 |
|
| 107 |
|
| 108 |
-
def build():
|
| 109 |
data = json.loads(CURATED.read_text(encoding="utf-8"))
|
| 110 |
chunks = []
|
| 111 |
for e in data.get("methodology", []):
|
|
@@ -249,14 +251,22 @@ def build():
|
|
| 249 |
"source_url": "",
|
| 250 |
})
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
print(f"{len(chunks)} commentary chunks "
|
| 256 |
f"({len(data.get('dispositions', []))} dispositions, "
|
| 257 |
f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
|
| 258 |
f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
|
| 261 |
if __name__ == "__main__":
|
| 262 |
-
|
|
|
|
| 9 |
data/curated/us_dispositions.json, which is reviewed by the user before it
|
| 10 |
ships; this module just renders it into corpus chunks.
|
| 11 |
|
| 12 |
+
py -m canlex.commentary [--allow-shrink]
|
| 13 |
"""
|
| 14 |
import json
|
| 15 |
+
import sys
|
| 16 |
|
| 17 |
+
from ._common import write_corpus
|
| 18 |
from .config import DATA_DIR, PROCESSED_DIR
|
| 19 |
|
| 20 |
CURATED = DATA_DIR / "curated" / "us_dispositions.json"
|
|
|
|
| 107 |
return "\n".join(lines)
|
| 108 |
|
| 109 |
|
| 110 |
+
def build(allow_shrink=False):
|
| 111 |
data = json.loads(CURATED.read_text(encoding="utf-8"))
|
| 112 |
chunks = []
|
| 113 |
for e in data.get("methodology", []):
|
|
|
|
| 251 |
"source_url": "",
|
| 252 |
})
|
| 253 |
|
| 254 |
+
# A full re-render of the curated files, so a truncated or half-edited
|
| 255 |
+
# us_dispositions.json (or an equivalency file that moved) collapses the
|
| 256 |
+
# chunk count and the write would replace good analysis with the remnant.
|
| 257 |
+
# indent=1 is the stored file's format -- changing it rewrites every line.
|
| 258 |
+
if not write_corpus(OUT, chunks, indent=1, allow_shrink=allow_shrink):
|
| 259 |
+
return False
|
| 260 |
print(f"{len(chunks)} commentary chunks "
|
| 261 |
f"({len(data.get('dispositions', []))} dispositions, "
|
| 262 |
f"{len(by_state)} state pages, {n_pairings} equivalency pairings, "
|
| 263 |
f"{len(data.get('methodology', []))}+ methodology) -> {OUT}")
|
| 264 |
+
return True
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def main():
|
| 268 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 269 |
|
| 270 |
|
| 271 |
if __name__ == "__main__":
|
| 272 |
+
main()
|
|
@@ -13,13 +13,14 @@ Sources:
|
|
| 13 |
py -m canlex.delegation
|
| 14 |
"""
|
| 15 |
import io
|
| 16 |
-
import json
|
| 17 |
import re
|
|
|
|
| 18 |
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
from pypdf import PdfReader
|
| 21 |
|
| 22 |
-
from ._common import fetch_cached, norm_ws as _norm,
|
|
|
|
| 23 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 24 |
|
| 25 |
RAW = RAW_DIR / "delegation"
|
|
@@ -441,9 +442,25 @@ def parse_ircc(pdf_bytes, src):
|
|
| 441 |
return chunks
|
| 442 |
|
| 443 |
|
| 444 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
"""Fetch, parse and chunk every delegation instrument into delegation.json."""
|
| 446 |
-
all_chunks = []
|
| 447 |
for src in SOURCES.values():
|
| 448 |
print(f"Ingesting {src['act_short']} ...")
|
| 449 |
try:
|
|
@@ -461,16 +478,29 @@ def build():
|
|
| 461 |
chunks = []
|
| 462 |
except Exception as exc:
|
| 463 |
print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
|
|
|
|
| 464 |
continue
|
|
|
|
|
|
|
| 465 |
all_chunks.extend(chunks)
|
| 466 |
print(f" {len(chunks)} chunks")
|
| 467 |
-
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 468 |
uniquify_ids(all_chunks)
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
| 472 |
f"instrument(s) -> {OUT}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
|
| 474 |
|
| 475 |
if __name__ == "__main__":
|
| 476 |
-
|
|
|
|
| 13 |
py -m canlex.delegation
|
| 14 |
"""
|
| 15 |
import io
|
|
|
|
| 16 |
import re
|
| 17 |
+
import sys
|
| 18 |
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
from pypdf import PdfReader
|
| 21 |
|
| 22 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 23 |
+
stored_chunks, uniquify_ids, write_corpus)
|
| 24 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 25 |
|
| 26 |
RAW = RAW_DIR / "delegation"
|
|
|
|
| 442 |
return chunks
|
| 443 |
|
| 444 |
|
| 445 |
+
def preserve_failed(chunks, failures, stored):
|
| 446 |
+
"""Re-attach the last-good chunks of instruments this run could not build.
|
| 447 |
+
|
| 448 |
+
Ingestion is a full rebuild, so an instrument that fails its fetch -- or
|
| 449 |
+
that parses to nothing because CBSA changed the page's shape again -- would
|
| 450 |
+
silently vanish from the corpus. An instrument withdrawn on purpose is
|
| 451 |
+
removed from SOURCES instead, and so never lands in `failures`, which is
|
| 452 |
+
why preservation is restricted to the codes that actually failed here.
|
| 453 |
+
act_code is the per-instrument identity every parser stamps on its chunks.
|
| 454 |
+
Returns (chunks, preserved act_codes).
|
| 455 |
+
"""
|
| 456 |
+
failed = {act_code for act_code, _why in failures}
|
| 457 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 458 |
+
only=failed)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def build(allow_shrink=False):
|
| 462 |
"""Fetch, parse and chunk every delegation instrument into delegation.json."""
|
| 463 |
+
all_chunks, failures = [], []
|
| 464 |
for src in SOURCES.values():
|
| 465 |
print(f"Ingesting {src['act_short']} ...")
|
| 466 |
try:
|
|
|
|
| 478 |
chunks = []
|
| 479 |
except Exception as exc:
|
| 480 |
print(f" !! {src['act_short']}: {type(exc).__name__}: {exc}")
|
| 481 |
+
failures.append((src["act_code"], f"{type(exc).__name__}: {exc}"))
|
| 482 |
continue
|
| 483 |
+
if not chunks:
|
| 484 |
+
failures.append((src["act_code"], "no content parsed"))
|
| 485 |
all_chunks.extend(chunks)
|
| 486 |
print(f" {len(chunks)} chunks")
|
|
|
|
| 487 |
uniquify_ids(all_chunks)
|
| 488 |
+
|
| 489 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 490 |
+
stored_chunks(OUT))
|
| 491 |
+
if preserved:
|
| 492 |
+
print(f" kept the last-good copy of {len(preserved)} instrument(s): "
|
| 493 |
+
f"{', '.join(preserved)}")
|
| 494 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 495 |
+
return False
|
| 496 |
print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
|
| 497 |
f"instrument(s) -> {OUT}")
|
| 498 |
+
return True
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def main():
|
| 502 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 503 |
|
| 504 |
|
| 505 |
if __name__ == "__main__":
|
| 506 |
+
main()
|
|
@@ -4,13 +4,13 @@ NJC directives are negotiated by employer and bargaining-agent representatives;
|
|
| 4 |
their provisions form part of collective agreements (and the rate tables in
|
| 5 |
their appendices apply too). Chunks are tagged doc_type="directive".
|
| 6 |
"""
|
| 7 |
-
import json
|
| 8 |
import re
|
| 9 |
import sys
|
| 10 |
|
| 11 |
from bs4 import BeautifulSoup
|
| 12 |
|
| 13 |
-
from ._common import MONTHS, fetch_cached, norm_ws as _norm,
|
|
|
|
| 14 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
|
| 16 |
INDEX_URL = "https://www.njc-cnm.gc.ca/directive/en"
|
|
@@ -160,10 +160,23 @@ def _print_link(html):
|
|
| 160 |
return None
|
| 161 |
|
| 162 |
|
| 163 |
-
def
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
directives = directive_links(force=force)
|
| 168 |
if limit:
|
| 169 |
directives = directives[:limit]
|
|
@@ -187,16 +200,35 @@ def main():
|
|
| 187 |
all_chunks.extend(chunks)
|
| 188 |
print(f" {title}: {len(chunks)} chunks")
|
| 189 |
else:
|
| 190 |
-
failures.append((title, "no content parsed"))
|
| 191 |
except Exception as exc:
|
| 192 |
-
failures.append((title, f"{type(exc).__name__}: {exc}"))
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
print(f"\n{len(all_chunks)} chunks from {len(directives) - len(failures)} "
|
| 197 |
f"directives -> {OUT_FILE.name}")
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
if __name__ == "__main__":
|
|
|
|
| 4 |
their provisions form part of collective agreements (and the rate tables in
|
| 5 |
their appendices apply too). Chunks are tagged doc_type="directive".
|
| 6 |
"""
|
|
|
|
| 7 |
import re
|
| 8 |
import sys
|
| 9 |
|
| 10 |
from bs4 import BeautifulSoup
|
| 11 |
|
| 12 |
+
from ._common import (MONTHS, fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 13 |
+
split_lines, stored_chunks, write_corpus)
|
| 14 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 15 |
|
| 16 |
INDEX_URL = "https://www.njc-cnm.gc.ca/directive/en"
|
|
|
|
| 160 |
return None
|
| 161 |
|
| 162 |
|
| 163 |
+
def preserve_failed(chunks, failures, stored):
|
| 164 |
+
"""Re-attach the last-good chunks of directives this run could not parse.
|
| 165 |
+
|
| 166 |
+
Ingestion is a full rebuild, so a directive whose page 404s or is restyled
|
| 167 |
+
vanishes from the corpus entirely -- and a directive that is still in force
|
| 168 |
+
is better served from the copy we already had than not at all. Keyed on
|
| 169 |
+
act_code (the /directive/<code>/ slug), which survives the title rewordings
|
| 170 |
+
the NJC index goes through; restricted to the directives that actually
|
| 171 |
+
failed, so any the NJC has genuinely retired stay gone.
|
| 172 |
+
Returns (chunks, preserved directive codes).
|
| 173 |
+
"""
|
| 174 |
+
failed = {code for code, _title, _why in failures}
|
| 175 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 176 |
+
only=failed)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def ingest(force=False, limit=None, allow_shrink=False):
|
| 180 |
directives = directive_links(force=force)
|
| 181 |
if limit:
|
| 182 |
directives = directives[:limit]
|
|
|
|
| 200 |
all_chunks.extend(chunks)
|
| 201 |
print(f" {title}: {len(chunks)} chunks")
|
| 202 |
else:
|
| 203 |
+
failures.append((code, title, "no content parsed"))
|
| 204 |
except Exception as exc:
|
| 205 |
+
failures.append((code, title, f"{type(exc).__name__}: {exc}"))
|
| 206 |
+
if not limit:
|
| 207 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 208 |
+
stored_chunks(OUT_FILE))
|
| 209 |
+
if preserved:
|
| 210 |
+
print(f" kept the last-good copy of {len(preserved)} unparseable "
|
| 211 |
+
f"directive(s): {', '.join(preserved)}")
|
| 212 |
+
# Before the write, so a refusal does not bury why the run came up short.
|
| 213 |
+
for _code, title, why in failures:
|
| 214 |
+
print(f" FAILED {title}: {why}")
|
| 215 |
+
# The guard applies to --limit runs too: a smoke test over two directives
|
| 216 |
+
# must not be able to write a two-directive corpus over the real one.
|
| 217 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 218 |
+
allow_shrink=allow_shrink):
|
| 219 |
+
return False
|
| 220 |
print(f"\n{len(all_chunks)} chunks from {len(directives) - len(failures)} "
|
| 221 |
f"directives -> {OUT_FILE.name}")
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def main():
|
| 226 |
+
force = "--force" in sys.argv
|
| 227 |
+
limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
|
| 228 |
+
if a.startswith("--limit=")), None)
|
| 229 |
+
ok = ingest(force=force, limit=limit,
|
| 230 |
+
allow_shrink="--allow-shrink" in sys.argv)
|
| 231 |
+
sys.exit(0 if ok else 1)
|
| 232 |
|
| 233 |
|
| 234 |
if __name__ == "__main__":
|
|
@@ -13,7 +13,9 @@ from urllib.parse import urljoin
|
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
from pypdf import PdfReader
|
| 15 |
|
| 16 |
-
from ._common import POLITE_UA, fetch_cached, norm_ws as _norm,
|
|
|
|
|
|
|
| 17 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 18 |
|
| 19 |
INDEX_URL = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
|
|
@@ -35,12 +37,6 @@ _MEMO_HREF = re.compile(r"/dm-md/d\d+/d[\d-]+-eng\.html")
|
|
| 35 |
_URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-eng\.html")
|
| 36 |
_DS_HREF = re.compile(r'href="([^"]+)"')
|
| 37 |
|
| 38 |
-
# Refuse to replace the corpus with less than this share of what it already
|
| 39 |
-
# holds. When the index broke, the link-scrape returned zero memos and the
|
| 40 |
-
# ingester happily wrote an empty dmemos.json over 1,870 good chunks; a single
|
| 41 |
-
# stdout line was the only warning.
|
| 42 |
-
_SHRINK_GUARD = 0.8
|
| 43 |
-
|
| 44 |
|
| 45 |
def _fetch(url, dest, force=False):
|
| 46 |
return fetch_cached(url, dest, ua=POLITE_UA, force=force)
|
|
@@ -245,24 +241,6 @@ def parse_pdf_memo(html, url):
|
|
| 245 |
return chunks
|
| 246 |
|
| 247 |
|
| 248 |
-
def safe_to_write(new_count, old_count, ratio=_SHRINK_GUARD):
|
| 249 |
-
"""False when writing `new_count` chunks would gut an existing corpus.
|
| 250 |
-
|
| 251 |
-
Pure, for tests. An upstream shape change shows up as a collapse in chunk
|
| 252 |
-
count, and the write is the irreversible step -- so it is the one to gate.
|
| 253 |
-
"""
|
| 254 |
-
return old_count == 0 or new_count >= old_count * ratio
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def _stored_chunks():
|
| 258 |
-
if not OUT_FILE.exists():
|
| 259 |
-
return []
|
| 260 |
-
try:
|
| 261 |
-
return json.loads(OUT_FILE.read_text(encoding="utf-8"))
|
| 262 |
-
except (ValueError, OSError):
|
| 263 |
-
return []
|
| 264 |
-
|
| 265 |
-
|
| 266 |
def preserve_failed(chunks, failures, stored):
|
| 267 |
"""Re-attach the last-good chunks of memos that could not be fetched.
|
| 268 |
|
|
@@ -270,23 +248,17 @@ def preserve_failed(chunks, failures, stored):
|
|
| 270 |
the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
|
| 271 |
whose pages 404, and a transient network error looks the same -- in both
|
| 272 |
cases dropping guidance that is still in force is worse than serving the
|
| 273 |
-
copy we already had.
|
| 274 |
-
out before fetching
|
|
|
|
| 275 |
"""
|
| 276 |
-
|
| 277 |
-
by_memo = {}
|
| 278 |
-
for chunk in stored:
|
| 279 |
-
by_memo.setdefault(chunk["section"].upper(), []).append(chunk)
|
| 280 |
-
preserved = []
|
| 281 |
for url, _why in failures:
|
| 282 |
match = _URL_NUMBER.search(url)
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
for number in sorted(preserved):
|
| 288 |
-
chunks.extend(by_memo[number])
|
| 289 |
-
return chunks, sorted(preserved)
|
| 290 |
|
| 291 |
|
| 292 |
def ingest(force=False, limit=None, allow_shrink=False):
|
|
@@ -312,23 +284,16 @@ def ingest(force=False, limit=None, allow_shrink=False):
|
|
| 312 |
for url, why in failures[:15]:
|
| 313 |
print(f" - {url.rsplit('/', 1)[-1]}: {why}")
|
| 314 |
|
| 315 |
-
stored = _stored_chunks()
|
| 316 |
if not limit:
|
| 317 |
-
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
|
|
|
| 318 |
if preserved:
|
| 319 |
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 320 |
f"memo(s): {', '.join(preserved)}")
|
| 321 |
# The guard applies to --limit runs too: a smoke test over five memos must
|
| 322 |
# not be able to write a five-memo corpus over the real one.
|
| 323 |
-
if not
|
| 324 |
-
print(f" REFUSING to write: {len(all_chunks)} chunks would replace "
|
| 325 |
-
f"{len(stored)} already in {OUT_FILE.name}. The upstream index or "
|
| 326 |
-
f"memo pages likely changed shape -- check the scrape before "
|
| 327 |
-
f"writing (re-run with --allow-shrink once the drop is understood).")
|
| 328 |
return False
|
| 329 |
-
|
| 330 |
-
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 331 |
-
OUT_FILE.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 332 |
print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
|
| 333 |
f"-> {OUT_FILE.name}")
|
| 334 |
return True
|
|
|
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
from pypdf import PdfReader
|
| 15 |
|
| 16 |
+
from ._common import (POLITE_UA, fetch_cached, norm_ws as _norm,
|
| 17 |
+
preserve_dropped, split_lines, stored_chunks,
|
| 18 |
+
write_corpus)
|
| 19 |
from .config import RAW_DIR, PROCESSED_DIR
|
| 20 |
|
| 21 |
INDEX_URL = "https://www.cbsa-asfc.gc.ca/publications/dm-md/d1-d23-eng.html"
|
|
|
|
| 37 |
_URL_NUMBER = re.compile(r"/(d\d+-[\d-]+)-eng\.html")
|
| 38 |
_DS_HREF = re.compile(r'href="([^"]+)"')
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
def _fetch(url, dest, force=False):
|
| 42 |
return fetch_cached(url, dest, ua=POLITE_UA, force=force)
|
|
|
|
| 241 |
return chunks
|
| 242 |
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
def preserve_failed(chunks, failures, stored):
|
| 245 |
"""Re-attach the last-good chunks of memos that could not be fetched.
|
| 246 |
|
|
|
|
| 248 |
the corpus. CBSA's rebuilt index (2026-07) lists several memos as Active
|
| 249 |
whose pages 404, and a transient network error looks the same -- in both
|
| 250 |
cases dropping guidance that is still in force is worse than serving the
|
| 251 |
+
copy we already had. Restricted to memos that actually failed, so the
|
| 252 |
+
cancelled ones (filtered out before fetching) are not resurrected.
|
| 253 |
+
Returns (chunks, preserved memo numbers).
|
| 254 |
"""
|
| 255 |
+
failed = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
for url, _why in failures:
|
| 257 |
match = _URL_NUMBER.search(url)
|
| 258 |
+
if match:
|
| 259 |
+
failed.add(match.group(1).upper())
|
| 260 |
+
return preserve_dropped(chunks, stored,
|
| 261 |
+
key=lambda c: c["section"].upper(), only=failed)
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
def ingest(force=False, limit=None, allow_shrink=False):
|
|
|
|
| 284 |
for url, why in failures[:15]:
|
| 285 |
print(f" - {url.rsplit('/', 1)[-1]}: {why}")
|
| 286 |
|
|
|
|
| 287 |
if not limit:
|
| 288 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 289 |
+
stored_chunks(OUT_FILE))
|
| 290 |
if preserved:
|
| 291 |
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 292 |
f"memo(s): {', '.join(preserved)}")
|
| 293 |
# The guard applies to --limit runs too: a smoke test over five memos must
|
| 294 |
# not be able to write a five-memo corpus over the real one.
|
| 295 |
+
if not write_corpus(OUT_FILE, all_chunks, allow_shrink=allow_shrink):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
return False
|
|
|
|
|
|
|
|
|
|
| 297 |
print(f" {len(all_chunks)} section-chunks from {len(urls) - len(failures)} memos "
|
| 298 |
f"-> {OUT_FILE.name}")
|
| 299 |
return True
|
|
@@ -13,12 +13,14 @@ retires chapters, so anything that 404s is skipped with a note. doc_type is
|
|
| 13 |
'memorandum' -- operational guidance, persuasive not binding, exactly like
|
| 14 |
the D-memoranda.
|
| 15 |
|
| 16 |
-
python -m canlex.enf
|
| 17 |
"""
|
| 18 |
-
import json
|
| 19 |
import re
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
from ._common import fetch_cached, norm_ws as _norm,
|
|
|
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
|
@@ -169,8 +171,50 @@ def _norm_block(lines):
|
|
| 169 |
return "\n".join(p for p in (_norm(l) for l in lines) if p)
|
| 170 |
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
for n in chapters:
|
| 175 |
unofficial = UNOFFICIAL.get(n)
|
| 176 |
url = (unofficial["url"] if unofficial
|
|
@@ -180,8 +224,12 @@ def build(chapters=_CHAPTERS):
|
|
| 180 |
# The ATIP host is an ordinary WordPress site; canada.ca needs
|
| 181 |
# the PowerShell TLS workaround.
|
| 182 |
fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0)
|
| 183 |
-
except Exception:
|
| 184 |
skipped.append(n)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
if dest.exists():
|
| 186 |
dest.unlink() # don't cache an error page as a PDF
|
| 187 |
continue
|
|
@@ -198,10 +246,19 @@ def build(chapters=_CHAPTERS):
|
|
| 198 |
else:
|
| 199 |
print(f" !! ENF {n}: no text layer and no OCR cache "
|
| 200 |
f"({ocr.name}); skipping")
|
|
|
|
| 201 |
continue
|
| 202 |
chunks = _chunks_for(n, pages, url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
except Exception as exc:
|
| 204 |
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
|
|
|
| 205 |
continue
|
| 206 |
if unofficial:
|
| 207 |
for c in chunks:
|
|
@@ -214,12 +271,25 @@ def build(chapters=_CHAPTERS):
|
|
| 214 |
all_chunks.extend(chunks)
|
| 215 |
uniquify_ids(all_chunks)
|
| 216 |
out = PROCESSED_DIR / "enf.json"
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
print(f"enf: {len(all_chunks)} chunks -> {out.name} "
|
| 220 |
f"(chapters absent upstream: {skipped})")
|
| 221 |
return all_chunks
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
if __name__ == "__main__":
|
| 225 |
-
|
|
|
|
| 13 |
'memorandum' -- operational guidance, persuasive not binding, exactly like
|
| 14 |
the D-memoranda.
|
| 15 |
|
| 16 |
+
python -m canlex.enf [--allow-shrink]
|
| 17 |
"""
|
|
|
|
| 18 |
import re
|
| 19 |
+
import sys
|
| 20 |
+
import urllib.error
|
| 21 |
|
| 22 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 23 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 24 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 25 |
|
| 26 |
BASE = ("https://www.canada.ca/content/dam/ircc/migration/ircc/english/"
|
|
|
|
| 171 |
return "\n".join(p for p in (_norm(l) for l in lines) if p)
|
| 172 |
|
| 173 |
|
| 174 |
+
_HTTP_404 = re.compile(r"\b404\b")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def is_missing_upstream(exc):
|
| 178 |
+
"""True when a fetch failed because the chapter simply is not there (404).
|
| 179 |
+
|
| 180 |
+
_CHAPTERS is a blind range(1, 41) probe, so on a healthy run most numbers
|
| 181 |
+
404 -- and a chapter IRCC RETIRES starts 404ing too. That is this module's
|
| 182 |
+
documented signal to drop a chapter, so a 404 must not be preserved
|
| 183 |
+
through: doing so would pin every withdrawn chapter in the corpus forever.
|
| 184 |
+
Every other fetch error (TLS, timeout, a dropped connection) is a real
|
| 185 |
+
failure the stored copy should survive. The two fetch paths report
|
| 186 |
+
differently -- urllib raises HTTPError with a status, the PowerShell
|
| 187 |
+
fallback raises CalledProcessError with the status only in its stderr
|
| 188 |
+
prose ("The remote server returned an error: (404) Not Found."), so that
|
| 189 |
+
side is a text match.
|
| 190 |
+
"""
|
| 191 |
+
if isinstance(exc, urllib.error.HTTPError):
|
| 192 |
+
return exc.code == 404
|
| 193 |
+
stderr = getattr(exc, "stderr", "") or ""
|
| 194 |
+
if isinstance(stderr, bytes):
|
| 195 |
+
stderr = stderr.decode("utf-8", "replace")
|
| 196 |
+
return bool(_HTTP_404.search(stderr))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def preserve_failed(chunks, failures, stored):
|
| 200 |
+
"""Re-attach the last-good chunks of chapters this run could not produce.
|
| 201 |
+
|
| 202 |
+
A chapter that stops fetching for any reason OTHER than a 404 -- a dropped
|
| 203 |
+
network, a TLS refusal, a PDF that suddenly has no text layer -- has not
|
| 204 |
+
been retired, it just did not arrive, and losing guidance that is still in
|
| 205 |
+
force is the worse error. So the stored copy stays and the run says so
|
| 206 |
+
loudly enough to be investigated. Chapters that 404 (retired, or never
|
| 207 |
+
existed) never enter `failures` and are not resurrected.
|
| 208 |
+
Returns (chunks, preserved act_codes).
|
| 209 |
+
"""
|
| 210 |
+
failed = {f"ENF-{n}" for n, _why in failures}
|
| 211 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["act_code"],
|
| 212 |
+
only=failed)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def build(chapters=_CHAPTERS, allow_shrink=False):
|
| 216 |
+
# Returns the chunks written, or False if the write was refused.
|
| 217 |
+
all_chunks, skipped, failures = [], [], []
|
| 218 |
for n in chapters:
|
| 219 |
unofficial = UNOFFICIAL.get(n)
|
| 220 |
url = (unofficial["url"] if unofficial
|
|
|
|
| 224 |
# The ATIP host is an ordinary WordPress site; canada.ca needs
|
| 225 |
# the PowerShell TLS workaround.
|
| 226 |
fetch_cached(url, dest, powershell="canada.ca" in url, pause=1.0)
|
| 227 |
+
except Exception as exc:
|
| 228 |
skipped.append(n)
|
| 229 |
+
if not is_missing_upstream(exc):
|
| 230 |
+
# Not a 404: the chapter may well still be published, so it
|
| 231 |
+
# counts as a failure and its stored chunks are preserved.
|
| 232 |
+
failures.append((n, f"fetch failed: {type(exc).__name__}: {exc}"))
|
| 233 |
if dest.exists():
|
| 234 |
dest.unlink() # don't cache an error page as a PDF
|
| 235 |
continue
|
|
|
|
| 246 |
else:
|
| 247 |
print(f" !! ENF {n}: no text layer and no OCR cache "
|
| 248 |
f"({ocr.name}); skipping")
|
| 249 |
+
failures.append((n, f"no text layer and no {ocr.name}"))
|
| 250 |
continue
|
| 251 |
chunks = _chunks_for(n, pages, url)
|
| 252 |
+
if not chunks:
|
| 253 |
+
# Fetched fine, parsed to nothing: the PDF's heading structure
|
| 254 |
+
# changed under us. That is the shape the D-memo loss took, so
|
| 255 |
+
# treat it as a failure and keep the stored chapter.
|
| 256 |
+
print(f" !! ENF {n}: no chunks parsed; keeping stored copy")
|
| 257 |
+
failures.append((n, "no content parsed"))
|
| 258 |
+
continue
|
| 259 |
except Exception as exc:
|
| 260 |
print(f" !! ENF {n}: parse failed: {type(exc).__name__}: {exc}")
|
| 261 |
+
failures.append((n, f"parse failed: {type(exc).__name__}: {exc}"))
|
| 262 |
continue
|
| 263 |
if unofficial:
|
| 264 |
for c in chunks:
|
|
|
|
| 271 |
all_chunks.extend(chunks)
|
| 272 |
uniquify_ids(all_chunks)
|
| 273 |
out = PROCESSED_DIR / "enf.json"
|
| 274 |
+
# Preserved chunks keep the ids they were stored with, so they are added
|
| 275 |
+
# after uniquify_ids -- ids embed the chapter number, and a preserved
|
| 276 |
+
# chapter is by definition absent from this run's chunks.
|
| 277 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 278 |
+
stored_chunks(out))
|
| 279 |
+
if preserved:
|
| 280 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s) "
|
| 281 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 282 |
+
if not write_corpus(out, all_chunks, indent=2, allow_shrink=allow_shrink):
|
| 283 |
+
return False
|
| 284 |
print(f"enf: {len(all_chunks)} chunks -> {out.name} "
|
| 285 |
f"(chapters absent upstream: {skipped})")
|
| 286 |
return all_chunks
|
| 287 |
|
| 288 |
|
| 289 |
+
def main():
|
| 290 |
+
chunks = build(allow_shrink="--allow-shrink" in sys.argv)
|
| 291 |
+
sys.exit(1 if chunks is False else 0)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
if __name__ == "__main__":
|
| 295 |
+
main()
|
|
@@ -1,12 +1,11 @@
|
|
| 1 |
"""Ingest Justice Laws XML into structured, section-level JSON chunks."""
|
| 2 |
-
import json
|
| 3 |
import re
|
| 4 |
import sys
|
| 5 |
import time
|
| 6 |
import urllib.request
|
| 7 |
import xml.etree.ElementTree as ET
|
| 8 |
|
| 9 |
-
from ._common import norm_ws as _norm, split_lines
|
| 10 |
from .config import SOURCES, RAW_DIR, PROCESSED_DIR
|
| 11 |
|
| 12 |
LIMS = "{http://justice.gc.ca/lims}"
|
|
@@ -388,13 +387,15 @@ def _nif_chunks(root, code, src, current_to):
|
|
| 388 |
return chunks
|
| 389 |
|
| 390 |
|
| 391 |
-
def ingest(code, force=False):
|
| 392 |
print(f"Ingesting {code} ({SOURCES[code]['short']})...")
|
| 393 |
xml_path = fetch_xml(code, force=force)
|
| 394 |
chunks = parse_legislation(xml_path, code)
|
| 395 |
-
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 396 |
out = PROCESSED_DIR / f"{code}.json"
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
| 398 |
print(f" {len(chunks)} sections -> {out.name}")
|
| 399 |
return chunks
|
| 400 |
|
|
@@ -430,7 +431,7 @@ def _frenchify(chunks, code):
|
|
| 430 |
return out
|
| 431 |
|
| 432 |
|
| 433 |
-
def ingest_fr(code, force=False):
|
| 434 |
src = SOURCES[code]
|
| 435 |
dest = RAW_DIR / f"{code}-fr.xml"
|
| 436 |
if not dest.exists() or force:
|
|
@@ -442,8 +443,8 @@ def ingest_fr(code, force=False):
|
|
| 442 |
time.sleep(1.0)
|
| 443 |
chunks = _frenchify(parse_legislation(dest, code), code)
|
| 444 |
out = PROCESSED_DIR / f"{code}-fr.json"
|
| 445 |
-
|
| 446 |
-
|
| 447 |
print(f" {len(chunks)} articles -> {out.name}")
|
| 448 |
return chunks
|
| 449 |
|
|
@@ -451,6 +452,7 @@ def ingest_fr(code, force=False):
|
|
| 451 |
def main():
|
| 452 |
force = "--force" in sys.argv
|
| 453 |
french = "--fr" in sys.argv
|
|
|
|
| 454 |
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 455 |
codes = only or list(SOURCES)
|
| 456 |
failures = []
|
|
@@ -461,15 +463,18 @@ def main():
|
|
| 461 |
try:
|
| 462 |
if french:
|
| 463 |
print(f"Ingesting {code} (français)...")
|
| 464 |
-
ingest_fr(code, force=force)
|
| 465 |
else:
|
| 466 |
-
ingest(code, force=force)
|
| 467 |
except Exception as exc:
|
| 468 |
failures.append((code, exc))
|
| 469 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 470 |
print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
|
| 471 |
for code, exc in failures:
|
| 472 |
print(f" FAILED {code}: {exc}")
|
|
|
|
|
|
|
|
|
|
| 473 |
|
| 474 |
|
| 475 |
if __name__ == "__main__":
|
|
|
|
| 1 |
"""Ingest Justice Laws XML into structured, section-level JSON chunks."""
|
|
|
|
| 2 |
import re
|
| 3 |
import sys
|
| 4 |
import time
|
| 5 |
import urllib.request
|
| 6 |
import xml.etree.ElementTree as ET
|
| 7 |
|
| 8 |
+
from ._common import norm_ws as _norm, split_lines, write_corpus
|
| 9 |
from .config import SOURCES, RAW_DIR, PROCESSED_DIR
|
| 10 |
|
| 11 |
LIMS = "{http://justice.gc.ca/lims}"
|
|
|
|
| 387 |
return chunks
|
| 388 |
|
| 389 |
|
| 390 |
+
def ingest(code, force=False, allow_shrink=False):
|
| 391 |
print(f"Ingesting {code} ({SOURCES[code]['short']})...")
|
| 392 |
xml_path = fetch_xml(code, force=force)
|
| 393 |
chunks = parse_legislation(xml_path, code)
|
|
|
|
| 394 |
out = PROCESSED_DIR / f"{code}.json"
|
| 395 |
+
# An Act whose XML schema shifts parses to a fraction of its sections; the
|
| 396 |
+
# guard turns that into a refusal instead of a quietly gutted statute.
|
| 397 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 398 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 399 |
print(f" {len(chunks)} sections -> {out.name}")
|
| 400 |
return chunks
|
| 401 |
|
|
|
|
| 431 |
return out
|
| 432 |
|
| 433 |
|
| 434 |
+
def ingest_fr(code, force=False, allow_shrink=False):
|
| 435 |
src = SOURCES[code]
|
| 436 |
dest = RAW_DIR / f"{code}-fr.xml"
|
| 437 |
if not dest.exists() or force:
|
|
|
|
| 443 |
time.sleep(1.0)
|
| 444 |
chunks = _frenchify(parse_legislation(dest, code), code)
|
| 445 |
out = PROCESSED_DIR / f"{code}-fr.json"
|
| 446 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 447 |
+
raise RuntimeError(f"{code}: refused to write a collapsed corpus")
|
| 448 |
print(f" {len(chunks)} articles -> {out.name}")
|
| 449 |
return chunks
|
| 450 |
|
|
|
|
| 452 |
def main():
|
| 453 |
force = "--force" in sys.argv
|
| 454 |
french = "--fr" in sys.argv
|
| 455 |
+
allow_shrink = "--allow-shrink" in sys.argv
|
| 456 |
only = [a for a in sys.argv[1:] if not a.startswith("-")]
|
| 457 |
codes = only or list(SOURCES)
|
| 458 |
failures = []
|
|
|
|
| 463 |
try:
|
| 464 |
if french:
|
| 465 |
print(f"Ingesting {code} (français)...")
|
| 466 |
+
ingest_fr(code, force=force, allow_shrink=allow_shrink)
|
| 467 |
else:
|
| 468 |
+
ingest(code, force=force, allow_shrink=allow_shrink)
|
| 469 |
except Exception as exc:
|
| 470 |
failures.append((code, exc))
|
| 471 |
print(f" FAILED {code}: {type(exc).__name__}: {exc}")
|
| 472 |
print(f"\nDone: {len(codes) - len(failures)}/{len(codes)} ingested.")
|
| 473 |
for code, exc in failures:
|
| 474 |
print(f" FAILED {code}: {exc}")
|
| 475 |
+
# One collapsed Act must not pass as a clean run: refresh.py and the
|
| 476 |
+
# RUNBOOK chain this into `&& py -m canlex.embed`.
|
| 477 |
+
sys.exit(1 if failures else 0)
|
| 478 |
|
| 479 |
|
| 480 |
if __name__ == "__main__":
|
|
@@ -12,12 +12,13 @@ parses for jurisprudential guides).
|
|
| 12 |
|
| 13 |
python -m canlex.irb_guidelines
|
| 14 |
"""
|
| 15 |
-
import json
|
| 16 |
import re
|
|
|
|
| 17 |
|
| 18 |
from bs4 import BeautifulSoup
|
| 19 |
|
| 20 |
-
from ._common import fetch_cached, norm_ws as _norm,
|
|
|
|
| 21 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 22 |
|
| 23 |
RAW = RAW_DIR / "irb_guidelines"
|
|
@@ -72,8 +73,8 @@ def _sections(main):
|
|
| 72 |
yield label, title, body
|
| 73 |
|
| 74 |
|
| 75 |
-
def build():
|
| 76 |
-
chunks = []
|
| 77 |
for g in GUIDELINES:
|
| 78 |
try:
|
| 79 |
html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
|
|
@@ -81,6 +82,7 @@ def build():
|
|
| 81 |
except Exception as exc:
|
| 82 |
print(f" !! Guideline {g['num']}: fetch failed "
|
| 83 |
f"({type(exc).__name__}: {exc})")
|
|
|
|
| 84 |
continue
|
| 85 |
soup = BeautifulSoup(html, "html.parser")
|
| 86 |
main = soup.find("main") or soup
|
|
@@ -114,15 +116,34 @@ def build():
|
|
| 114 |
"source_url": g["url"],
|
| 115 |
})
|
| 116 |
n += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 118 |
f"effective {effective or '?'}")
|
| 119 |
-
uniquify_ids(chunks)
|
| 120 |
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
|
| 124 |
return chunks
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
if __name__ == "__main__":
|
| 128 |
-
|
|
|
|
| 12 |
|
| 13 |
python -m canlex.irb_guidelines
|
| 14 |
"""
|
|
|
|
| 15 |
import re
|
| 16 |
+
import sys
|
| 17 |
|
| 18 |
from bs4 import BeautifulSoup
|
| 19 |
|
| 20 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 21 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 22 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 23 |
|
| 24 |
RAW = RAW_DIR / "irb_guidelines"
|
|
|
|
| 73 |
yield label, title, body
|
| 74 |
|
| 75 |
|
| 76 |
+
def build(allow_shrink=False):
|
| 77 |
+
chunks, failures = [], []
|
| 78 |
for g in GUIDELINES:
|
| 79 |
try:
|
| 80 |
html = fetch_cached(g["url"], RAW / f"{g['file']}.html",
|
|
|
|
| 82 |
except Exception as exc:
|
| 83 |
print(f" !! Guideline {g['num']}: fetch failed "
|
| 84 |
f"({type(exc).__name__}: {exc})")
|
| 85 |
+
failures.append(f"IRB-G{g['num']}")
|
| 86 |
continue
|
| 87 |
soup = BeautifulSoup(html, "html.parser")
|
| 88 |
main = soup.find("main") or soup
|
|
|
|
| 116 |
"source_url": g["url"],
|
| 117 |
})
|
| 118 |
n += 1
|
| 119 |
+
if not n:
|
| 120 |
+
# Fetched, parsed to nothing: the IRB page template moved.
|
| 121 |
+
print(f" !! Guideline {g['num']}: no content parsed; "
|
| 122 |
+
f"keeping stored copy")
|
| 123 |
+
failures.append(f"IRB-G{g['num']}")
|
| 124 |
+
continue
|
| 125 |
print(f" Guideline {g['num']} ({g['title'][:40]}): {n} chunks, "
|
| 126 |
f"effective {effective or '?'}")
|
|
|
|
| 127 |
out = PROCESSED_DIR / "irb_guidelines.json"
|
| 128 |
+
chunks, preserved = preserve_dropped(chunks, stored_chunks(out),
|
| 129 |
+
key=lambda c: c["act_code"],
|
| 130 |
+
only=set(failures))
|
| 131 |
+
if preserved:
|
| 132 |
+
print(f" kept the last-good copy of {len(preserved)} guideline(s) "
|
| 133 |
+
f"that failed this run: {', '.join(preserved)}")
|
| 134 |
+
uniquify_ids(chunks)
|
| 135 |
+
if not write_corpus(out, chunks, indent=2, allow_shrink=allow_shrink):
|
| 136 |
+
return False
|
| 137 |
print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
|
| 138 |
return chunks
|
| 139 |
|
| 140 |
|
| 141 |
+
def main():
|
| 142 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 143 |
+
# empty corpus succeeded, a refused write did not.
|
| 144 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
|
| 145 |
+
sys.exit(0 if ok else 1)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
if __name__ == "__main__":
|
| 149 |
+
main()
|
|
@@ -7,15 +7,17 @@ Tran serious-criminality page (partial successor to ENF 2). Each named
|
|
| 7 |
index page is fetched, its same-section sub-pages discovered, and every
|
| 8 |
page chunked by heading -- doc_type='memorandum', like all guidance.
|
| 9 |
|
| 10 |
-
python -m canlex.pdi
|
| 11 |
"""
|
| 12 |
-
import json
|
| 13 |
import re
|
|
|
|
| 14 |
|
| 15 |
-
from ._common import fetch_cached, norm_ws as _norm,
|
|
|
|
| 16 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 17 |
|
| 18 |
RAW = RAW_DIR / "pdi"
|
|
|
|
| 19 |
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 20 |
"publications-manuals/operational-bulletins-manuals/")
|
| 21 |
|
|
@@ -98,8 +100,25 @@ def _page_chunks(src, page_url, html, page_no):
|
|
| 98 |
return chunks
|
| 99 |
|
| 100 |
|
| 101 |
-
def
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
for src in SETS:
|
| 104 |
index_html = fetch_cached(
|
| 105 |
src["index"], RAW / f"{src['code']}-index.html",
|
|
@@ -122,20 +141,46 @@ def build():
|
|
| 122 |
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 123 |
pages.append((url, page))
|
| 124 |
except Exception as exc:
|
|
|
|
| 125 |
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 126 |
n = 0
|
| 127 |
for page_no, (url, html) in enumerate(pages):
|
| 128 |
chunks = _page_chunks(src, url, html, page_no)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
all_chunks.extend(chunks)
|
| 130 |
n += len(chunks)
|
| 131 |
print(f" {src['short']}: {len(pages)} pages, {n} chunks")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
uniquify_ids(all_chunks)
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
print(f"pdi: {len(all_chunks)} chunks -> {
|
| 137 |
return all_chunks
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if __name__ == "__main__":
|
| 141 |
-
|
|
|
|
| 7 |
index page is fetched, its same-section sub-pages discovered, and every
|
| 8 |
page chunked by heading -- doc_type='memorandum', like all guidance.
|
| 9 |
|
| 10 |
+
python -m canlex.pdi [--allow-shrink]
|
| 11 |
"""
|
|
|
|
| 12 |
import re
|
| 13 |
+
import sys
|
| 14 |
|
| 15 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 16 |
+
split_lines, stored_chunks, uniquify_ids, write_corpus)
|
| 17 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 18 |
|
| 19 |
RAW = RAW_DIR / "pdi"
|
| 20 |
+
OUT_FILE = PROCESSED_DIR / "pdi.json"
|
| 21 |
_OBM = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 22 |
"publications-manuals/operational-bulletins-manuals/")
|
| 23 |
|
|
|
|
| 100 |
return chunks
|
| 101 |
|
| 102 |
|
| 103 |
+
def preserve_failed(chunks, failures, stored):
|
| 104 |
+
"""Re-attach the last-good chunks of sub-pages this run could not fetch.
|
| 105 |
+
|
| 106 |
+
Ingestion is a full rebuild, so a sub-page that 404s or drops its
|
| 107 |
+
connection silently vanishes from the corpus; serving the copy we already
|
| 108 |
+
had beats losing instructions that are still in force. Keyed on source_url,
|
| 109 |
+
the only per-page identity a PDI chunk carries that survives pages coming
|
| 110 |
+
and going -- the chunk id embeds the page's position in the index listing,
|
| 111 |
+
which shifts whenever one page drops out. Restricted to pages that actually
|
| 112 |
+
errored, so a sub-page IRCC retired on purpose is not resurrected.
|
| 113 |
+
Returns (chunks, preserved page URLs).
|
| 114 |
+
"""
|
| 115 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["source_url"],
|
| 116 |
+
only={url for url, _why in failures})
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build(allow_shrink=False):
|
| 120 |
+
"""Rebuild pdi.json. Returns the chunks, or False if the write was refused."""
|
| 121 |
+
all_chunks, failures = [], []
|
| 122 |
for src in SETS:
|
| 123 |
index_html = fetch_cached(
|
| 124 |
src["index"], RAW / f"{src['code']}-index.html",
|
|
|
|
| 141 |
powershell=True, pause=0.8).decode("utf-8", "replace")
|
| 142 |
pages.append((url, page))
|
| 143 |
except Exception as exc:
|
| 144 |
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
| 145 |
print(f" !! {name}: {type(exc).__name__}: {exc}")
|
| 146 |
n = 0
|
| 147 |
for page_no, (url, html) in enumerate(pages):
|
| 148 |
chunks = _page_chunks(src, url, html, page_no)
|
| 149 |
+
if not chunks:
|
| 150 |
+
# Downloaded fine, parsed to nothing: the page template moved
|
| 151 |
+
# under us. That is exactly how the D-memo corpus was lost, so
|
| 152 |
+
# it counts as a failure and the stored page is kept.
|
| 153 |
+
print(f" !! {url.rsplit('/', 1)[-1]}: no content parsed; "
|
| 154 |
+
f"keeping stored copy")
|
| 155 |
+
failures.append((url, "no content parsed"))
|
| 156 |
+
continue
|
| 157 |
all_chunks.extend(chunks)
|
| 158 |
n += len(chunks)
|
| 159 |
print(f" {src['short']}: {len(pages)} pages, {n} chunks")
|
| 160 |
+
|
| 161 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 162 |
+
stored_chunks(OUT_FILE))
|
| 163 |
+
if preserved:
|
| 164 |
+
names = ", ".join(u.rsplit("/", 1)[-1] for u in preserved)
|
| 165 |
+
print(f" kept the last-good copy of {len(preserved)} unfetchable "
|
| 166 |
+
f"page(s): {names}")
|
| 167 |
+
# After preservation, not before: a preserved page brings back the ids it
|
| 168 |
+
# was written with, and this run may have handed those ids to other pages
|
| 169 |
+
# (an id embeds the page's position in the index listing).
|
| 170 |
uniquify_ids(all_chunks)
|
| 171 |
+
if not write_corpus(OUT_FILE, all_chunks, indent=2,
|
| 172 |
+
allow_shrink=allow_shrink):
|
| 173 |
+
return False
|
| 174 |
+
print(f"pdi: {len(all_chunks)} chunks -> {OUT_FILE.name}")
|
| 175 |
return all_chunks
|
| 176 |
|
| 177 |
|
| 178 |
+
def main():
|
| 179 |
+
# `is not False` rather than truthiness: a first-ever run that writes an
|
| 180 |
+
# empty corpus succeeded, a refused write did not.
|
| 181 |
+
ok = build(allow_shrink="--allow-shrink" in sys.argv) is not False
|
| 182 |
+
sys.exit(0 if ok else 1)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
if __name__ == "__main__":
|
| 186 |
+
main()
|
|
@@ -20,15 +20,16 @@ per chapter for its Notes and Subheading Notes -- a heading is the natural
|
|
| 20 |
unit of legal classification (the eight- and ten-digit items below it are the
|
| 21 |
same rule with finer rate granularity).
|
| 22 |
|
| 23 |
-
py -m canlex.tariff_schedule
|
| 24 |
"""
|
| 25 |
-
import json
|
| 26 |
import re
|
|
|
|
| 27 |
from collections import defaultdict
|
| 28 |
|
| 29 |
from bs4 import BeautifulSoup
|
| 30 |
|
| 31 |
-
from ._common import fetch_cached, norm_ws as _norm
|
|
|
|
| 32 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 33 |
|
| 34 |
RAW = RAW_DIR / "tariff_schedule"
|
|
@@ -73,6 +74,13 @@ def _heading_of(item):
|
|
| 73 |
return f"{digits[:2]}.{digits[2:4]}"
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def _collect_notes(main):
|
| 77 |
"""Return the chapter's Notes + Subheading Notes as a single text block."""
|
| 78 |
out = []
|
|
@@ -120,7 +128,7 @@ def parse_chapter(html, src):
|
|
| 120 |
"section": f"Sch-Ch{chapter}-Notes",
|
| 121 |
"marginal_note": (f"Chapter {chapter} Notes — "
|
| 122 |
f"{src['title']}"),
|
| 123 |
-
"part":
|
| 124 |
"division": "",
|
| 125 |
"heading": src["title"],
|
| 126 |
"text": notes_body,
|
|
@@ -207,7 +215,7 @@ def parse_chapter(html, src):
|
|
| 207 |
"act_name": "Customs Tariff",
|
| 208 |
"section": f"Sch-{heading}",
|
| 209 |
"marginal_note": desc[:200],
|
| 210 |
-
"part":
|
| 211 |
"division": "",
|
| 212 |
"heading": src["title"],
|
| 213 |
"text": body,
|
|
@@ -221,8 +229,23 @@ def parse_chapter(html, src):
|
|
| 221 |
return chunks
|
| 222 |
|
| 223 |
|
| 224 |
-
def
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
for src in SOURCES.values():
|
| 227 |
print(f"Ingesting Customs Tariff Schedule {src['code']} ...")
|
| 228 |
try:
|
|
@@ -230,15 +253,33 @@ def build():
|
|
| 230 |
chunks = parse_chapter(html, src)
|
| 231 |
except Exception as exc:
|
| 232 |
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
continue
|
| 234 |
all_chunks.extend(chunks)
|
| 235 |
print(f" {len(chunks)} chunks")
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
if __name__ == "__main__":
|
| 244 |
-
|
|
|
|
| 20 |
unit of legal classification (the eight- and ten-digit items below it are the
|
| 21 |
same rule with finer rate granularity).
|
| 22 |
|
| 23 |
+
py -m canlex.tariff_schedule [--allow-shrink]
|
| 24 |
"""
|
|
|
|
| 25 |
import re
|
| 26 |
+
import sys
|
| 27 |
from collections import defaultdict
|
| 28 |
|
| 29 |
from bs4 import BeautifulSoup
|
| 30 |
|
| 31 |
+
from ._common import (fetch_cached, norm_ws as _norm, preserve_dropped,
|
| 32 |
+
stored_chunks, write_corpus)
|
| 33 |
from .config import PROCESSED_DIR, RAW_DIR
|
| 34 |
|
| 35 |
RAW = RAW_DIR / "tariff_schedule"
|
|
|
|
| 74 |
return f"{digits[:2]}.{digits[2:4]}"
|
| 75 |
|
| 76 |
|
| 77 |
+
def _chapter_part(chapter):
|
| 78 |
+
"""The `part` every chunk of a chapter carries -- and, because nothing else
|
| 79 |
+
on a chunk names its chapter, the per-chapter identity preserve_failed
|
| 80 |
+
matches on. Derived in one place so the two stay in step."""
|
| 81 |
+
return f"Schedule, Chapter {chapter}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def _collect_notes(main):
|
| 85 |
"""Return the chapter's Notes + Subheading Notes as a single text block."""
|
| 86 |
out = []
|
|
|
|
| 128 |
"section": f"Sch-Ch{chapter}-Notes",
|
| 129 |
"marginal_note": (f"Chapter {chapter} Notes — "
|
| 130 |
f"{src['title']}"),
|
| 131 |
+
"part": _chapter_part(chapter),
|
| 132 |
"division": "",
|
| 133 |
"heading": src["title"],
|
| 134 |
"text": notes_body,
|
|
|
|
| 215 |
"act_name": "Customs Tariff",
|
| 216 |
"section": f"Sch-{heading}",
|
| 217 |
"marginal_note": desc[:200],
|
| 218 |
+
"part": _chapter_part(chapter),
|
| 219 |
"division": "",
|
| 220 |
"heading": src["title"],
|
| 221 |
"text": body,
|
|
|
|
| 229 |
return chunks
|
| 230 |
|
| 231 |
|
| 232 |
+
def preserve_failed(chunks, failures, stored):
|
| 233 |
+
"""Re-attach the last-good chunks of chapters this run could not rebuild.
|
| 234 |
+
|
| 235 |
+
Only two chapters make up this corpus, so losing one is losing a third to
|
| 236 |
+
two thirds of it -- and a CBSA page that has been reshaped (or a fetch that
|
| 237 |
+
died mid-run) is indistinguishable from a chapter that legitimately
|
| 238 |
+
vanished until a human looks. Serving last week's copy of ch. 98 beats
|
| 239 |
+
serving no traveller exemptions at all. Restricted to the chapters that
|
| 240 |
+
actually failed. Returns (chunks, preserved chapter identities).
|
| 241 |
+
"""
|
| 242 |
+
failed = {_chapter_part(chapter) for chapter, _why in failures}
|
| 243 |
+
return preserve_dropped(chunks, stored, key=lambda c: c["part"],
|
| 244 |
+
only=failed)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def build(allow_shrink=False):
|
| 248 |
+
all_chunks, failures = [], []
|
| 249 |
for src in SOURCES.values():
|
| 250 |
print(f"Ingesting Customs Tariff Schedule {src['code']} ...")
|
| 251 |
try:
|
|
|
|
| 253 |
chunks = parse_chapter(html, src)
|
| 254 |
except Exception as exc:
|
| 255 |
print(f" !! {src['code']}: {type(exc).__name__}: {exc}")
|
| 256 |
+
failures.append((src["chapter"], f"{type(exc).__name__}: {exc}"))
|
| 257 |
+
continue
|
| 258 |
+
if not chunks:
|
| 259 |
+
# A chapter never parses to nothing legitimately -- an empty result
|
| 260 |
+
# means the page moved or changed shape, so treat it as a failure
|
| 261 |
+
# rather than letting it silently shrink the corpus.
|
| 262 |
+
print(f" !! {src['code']}: no chunks parsed")
|
| 263 |
+
failures.append((src["chapter"], "no chunks parsed"))
|
| 264 |
continue
|
| 265 |
all_chunks.extend(chunks)
|
| 266 |
print(f" {len(chunks)} chunks")
|
| 267 |
+
|
| 268 |
+
all_chunks, preserved = preserve_failed(all_chunks, failures,
|
| 269 |
+
stored_chunks(OUT))
|
| 270 |
+
if preserved:
|
| 271 |
+
print(f" kept the last-good copy of {len(preserved)} chapter(s): "
|
| 272 |
+
f"{', '.join(preserved)}")
|
| 273 |
+
if not write_corpus(OUT, all_chunks, indent=1, allow_shrink=allow_shrink):
|
| 274 |
+
return False
|
| 275 |
+
print(f"\n{len(all_chunks)} tariff-schedule chunks from "
|
| 276 |
+
f"{len(SOURCES) - len(failures)} chapter(s) -> {OUT}")
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def main():
|
| 281 |
+
sys.exit(0 if build(allow_shrink="--allow-shrink" in sys.argv) else 1)
|
| 282 |
|
| 283 |
|
| 284 |
if __name__ == "__main__":
|
| 285 |
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the collective-agreement ingester (canlex/agreement.py).
|
| 2 |
+
|
| 3 |
+
Offline only. Ingestion is a full rebuild, so the failure that cost the D-memo
|
| 4 |
+
corpus on 2026-07-27 -- an upstream page that changes shape, scrapes to nothing
|
| 5 |
+
and is written straight over the good chunks -- applies here too: canada.ca is
|
| 6 |
+
one redesign away from parsing to zero Articles.
|
| 7 |
+
"""
|
| 8 |
+
import unittest
|
| 9 |
+
|
| 10 |
+
from canlex import agreement
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ParseAgreementTests(unittest.TestCase):
|
| 14 |
+
def test_redesigned_page_parses_to_nothing(self):
|
| 15 |
+
# What a layout change looks like: no <main>, so no Articles. ingest()
|
| 16 |
+
# records this as a failure rather than writing the empty result.
|
| 17 |
+
self.assertEqual(agreement.parse_agreement("<div>hello</div>", "FB"), [])
|
| 18 |
+
|
| 19 |
+
def test_article_heading_becomes_a_numbered_chunk(self):
|
| 20 |
+
html = ("<main><h3>Article 17: discipline</h3>"
|
| 21 |
+
"<p>An employee may be disciplined.</p></main>")
|
| 22 |
+
chunk, = agreement.parse_agreement(html, "FB")
|
| 23 |
+
self.assertEqual(chunk["section"], "17")
|
| 24 |
+
self.assertEqual(chunk["marginal_note"], "discipline")
|
| 25 |
+
self.assertEqual(chunk["act_code"], "FB")
|
| 26 |
+
|
| 27 |
+
def test_every_chunk_carries_its_agreement_code(self):
|
| 28 |
+
# preserve_failed keys on act_code, so it has to be on every chunk.
|
| 29 |
+
html = ("<main><h3>Article 1: purpose</h3><p>one</p>"
|
| 30 |
+
"<h3>Appendix A</h3><p>two</p></main>")
|
| 31 |
+
chunks = agreement.parse_agreement(html, "FB")
|
| 32 |
+
self.assertEqual({c["act_code"] for c in chunks}, {"FB"})
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 36 |
+
STORED = [{"act_code": "FB", "text": "hours of work"},
|
| 37 |
+
{"act_code": "FB", "text": "overtime"},
|
| 38 |
+
{"act_code": "PA", "text": "leave"}]
|
| 39 |
+
|
| 40 |
+
def test_failed_agreement_keeps_its_last_good_chunks(self):
|
| 41 |
+
failures = [("FB", "no content parsed")]
|
| 42 |
+
chunks, preserved = agreement.preserve_failed([], failures, self.STORED)
|
| 43 |
+
self.assertEqual(preserved, ["FB"])
|
| 44 |
+
self.assertEqual([c["text"] for c in chunks],
|
| 45 |
+
["hours of work", "overtime"])
|
| 46 |
+
|
| 47 |
+
def test_fetch_error_counts_as_a_failure_too(self):
|
| 48 |
+
failures = [("FB", "URLError: timed out")]
|
| 49 |
+
_chunks, preserved = agreement.preserve_failed([], failures, self.STORED)
|
| 50 |
+
self.assertEqual(preserved, ["FB"])
|
| 51 |
+
|
| 52 |
+
def test_agreement_dropped_on_purpose_is_not_resurrected(self):
|
| 53 |
+
# PA is stored but no longer in AGREEMENTS, so it never failed: a
|
| 54 |
+
# retired agreement must not come back through the preserve path.
|
| 55 |
+
chunks, preserved = agreement.preserve_failed(
|
| 56 |
+
[], [("FB", "no content parsed")], self.STORED)
|
| 57 |
+
self.assertNotIn("PA", preserved)
|
| 58 |
+
self.assertNotIn("leave", [c["text"] for c in chunks])
|
| 59 |
+
|
| 60 |
+
def test_freshly_parsed_chunks_win_over_stored(self):
|
| 61 |
+
fresh = [{"act_code": "FB", "text": "new text"}]
|
| 62 |
+
chunks, preserved = agreement.preserve_failed(
|
| 63 |
+
fresh, [("FB", "no content parsed")], self.STORED)
|
| 64 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 65 |
+
|
| 66 |
+
def test_agreement_we_never_stored_cannot_be_preserved(self):
|
| 67 |
+
chunks, preserved = agreement.preserve_failed(
|
| 68 |
+
[], [("SV", "URLError: timed out")], self.STORED)
|
| 69 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 70 |
+
|
| 71 |
+
def test_nothing_happens_without_failures(self):
|
| 72 |
+
fresh = [{"act_code": "FB", "text": "x"}]
|
| 73 |
+
chunks, preserved = agreement.preserve_failed(fresh, [], self.STORED)
|
| 74 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
if __name__ == "__main__":
|
| 78 |
+
unittest.main()
|
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the AMPS Master Penalty Document ingester (canlex/amps.py).
|
| 2 |
+
|
| 3 |
+
Offline only. The ingester is a full rebuild, so a run that loses its network
|
| 4 |
+
half way through -- or an index CBSA rebuilds into something the link regex no
|
| 5 |
+
longer matches -- used to write the shortfall straight over the stored corpus.
|
| 6 |
+
These cover the module's own half of the fix: which contraventions count as
|
| 7 |
+
"failed this run", and that the identity used to preserve them is the same one
|
| 8 |
+
parse_page stamps on a chunk.
|
| 9 |
+
"""
|
| 10 |
+
import contextlib
|
| 11 |
+
import io
|
| 12 |
+
import json
|
| 13 |
+
import tempfile
|
| 14 |
+
import unittest
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from unittest import mock
|
| 17 |
+
|
| 18 |
+
from canlex import amps
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 22 |
+
STORED = [{"section": "C005", "text": "records not kept"},
|
| 23 |
+
{"section": "C348", "text": "no advance data"}]
|
| 24 |
+
|
| 25 |
+
def test_unfetchable_contravention_keeps_its_last_good_chunk(self):
|
| 26 |
+
chunks, preserved = amps.preserve_failed(
|
| 27 |
+
[], [("c005", "ValueError: empty response")], self.STORED)
|
| 28 |
+
self.assertEqual(preserved, ["C005"])
|
| 29 |
+
self.assertEqual([c["text"] for c in chunks], ["records not kept"])
|
| 30 |
+
|
| 31 |
+
def test_index_codes_are_lower_case_but_sections_are_not(self):
|
| 32 |
+
# The whole preserve step turns on this: build() collects the index's
|
| 33 |
+
# lower-case c-number, a chunk carries it upper-cased.
|
| 34 |
+
_chunks, preserved = amps.preserve_failed([], [("c348", "404")],
|
| 35 |
+
self.STORED)
|
| 36 |
+
self.assertEqual(preserved, ["C348"])
|
| 37 |
+
|
| 38 |
+
def test_contravention_dropped_upstream_is_not_resurrected(self):
|
| 39 |
+
# C348 vanished from the index rather than failing; it stays gone.
|
| 40 |
+
chunks, preserved = amps.preserve_failed([], [("c005", "404")],
|
| 41 |
+
self.STORED)
|
| 42 |
+
self.assertEqual(preserved, ["C005"])
|
| 43 |
+
self.assertNotIn("C348", [c["section"] for c in chunks])
|
| 44 |
+
|
| 45 |
+
def test_freshly_scraped_chunk_wins_over_the_stored_copy(self):
|
| 46 |
+
fresh = [{"section": "C005", "text": "new penalty amounts"}]
|
| 47 |
+
chunks, preserved = amps.preserve_failed(fresh, [("c005", "404")],
|
| 48 |
+
self.STORED)
|
| 49 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 50 |
+
|
| 51 |
+
def test_contravention_we_never_had_cannot_be_preserved(self):
|
| 52 |
+
chunks, preserved = amps.preserve_failed([], [("c999", "404")],
|
| 53 |
+
self.STORED)
|
| 54 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 55 |
+
|
| 56 |
+
def test_a_clean_run_preserves_nothing(self):
|
| 57 |
+
fresh = [{"section": "C005", "text": "x"}]
|
| 58 |
+
chunks, preserved = amps.preserve_failed(fresh, [], self.STORED)
|
| 59 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 60 |
+
|
| 61 |
+
def test_total_scrape_failure_restores_the_whole_corpus(self):
|
| 62 |
+
# Every page erroring is the shape of the outage the guard is for: the
|
| 63 |
+
# stored corpus survives intact instead of being written away.
|
| 64 |
+
failed = [("c005", "timeout"), ("c348", "timeout")]
|
| 65 |
+
chunks, preserved = amps.preserve_failed([], failed, self.STORED)
|
| 66 |
+
self.assertEqual(preserved, ["C005", "C348"])
|
| 67 |
+
self.assertEqual(len(chunks), len(self.STORED))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class ChunkIdentityTests(unittest.TestCase):
|
| 71 |
+
"""preserve_failed keys on `section`; parse_page must keep filling it."""
|
| 72 |
+
|
| 73 |
+
HTML = ("<main><h1>Administrative Monetary Penalty C005</h1>"
|
| 74 |
+
"<p>Person failed to keep the prescribed records for the "
|
| 75 |
+
"prescribed period.</p>"
|
| 76 |
+
"<table><tr><td>First</td><td>$500</td></tr></table></main>"
|
| 77 |
+
'<time property="dateModified">2026-05-01</time>')
|
| 78 |
+
|
| 79 |
+
def test_parsed_section_is_the_preserve_key(self):
|
| 80 |
+
chunk = amps.parse_page(self.HTML, "c005", "https://x/c005-eng.html")
|
| 81 |
+
self.assertEqual(chunk["section"], "C005")
|
| 82 |
+
_chunks, preserved = amps.preserve_failed([], [("c005", "404")],
|
| 83 |
+
[chunk])
|
| 84 |
+
self.assertEqual(preserved, ["C005"])
|
| 85 |
+
|
| 86 |
+
def test_one_chunk_per_contravention_so_the_key_is_unique(self):
|
| 87 |
+
chunk = amps.parse_page(self.HTML, "c005", "https://x/c005-eng.html")
|
| 88 |
+
self.assertEqual(chunk["id"], "amps-c005")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class BuildWiringTests(unittest.TestCase):
|
| 92 |
+
"""build() with the network stubbed out: is the guard actually reached?"""
|
| 93 |
+
|
| 94 |
+
INDEX = ('<a href="/trade-commerce/amps/contraventions-infractions/'
|
| 95 |
+
'c005-eng.html">C005</a>')
|
| 96 |
+
PAGE = ("<main><h1>Administrative Monetary Penalty C005</h1>"
|
| 97 |
+
"<p>Person failed to keep the prescribed records for the "
|
| 98 |
+
"prescribed period.</p></main>")
|
| 99 |
+
|
| 100 |
+
def setUp(self):
|
| 101 |
+
tmp = tempfile.TemporaryDirectory()
|
| 102 |
+
self.addCleanup(tmp.cleanup)
|
| 103 |
+
self.dir = Path(tmp.name)
|
| 104 |
+
self.out = self.dir / "amps.json"
|
| 105 |
+
|
| 106 |
+
def _build(self, index_html, **kwargs):
|
| 107 |
+
def stub(url, _dest):
|
| 108 |
+
return (index_html if url == amps.INDEX_URL
|
| 109 |
+
else self.PAGE).encode("utf-8")
|
| 110 |
+
with mock.patch.object(amps, "PROCESSED_DIR", self.dir), \
|
| 111 |
+
mock.patch.object(amps, "_fetch", side_effect=stub), \
|
| 112 |
+
contextlib.redirect_stdout(io.StringIO()) as out:
|
| 113 |
+
return amps.build(**kwargs), out.getvalue()
|
| 114 |
+
|
| 115 |
+
def test_an_index_that_scrapes_to_nothing_cannot_empty_the_corpus(self):
|
| 116 |
+
# CBSA rebuilding the MPD index into something _LINK no longer matches.
|
| 117 |
+
self.out.write_text(json.dumps([{"section": f"C{i:03d}"}
|
| 118 |
+
for i in range(50)]), encoding="utf-8")
|
| 119 |
+
ok, log = self._build("<table id='mpd'></table>")
|
| 120 |
+
self.assertFalse(ok)
|
| 121 |
+
self.assertIn("REFUSING", log)
|
| 122 |
+
self.assertEqual(
|
| 123 |
+
len(json.loads(self.out.read_text(encoding="utf-8"))), 50)
|
| 124 |
+
|
| 125 |
+
def test_allow_shrink_lets_an_understood_drop_through(self):
|
| 126 |
+
self.out.write_text(json.dumps([{"section": f"C{i:03d}"}
|
| 127 |
+
for i in range(50)]), encoding="utf-8")
|
| 128 |
+
ok, _log = self._build("<table id='mpd'></table>", allow_shrink=True)
|
| 129 |
+
self.assertTrue(ok)
|
| 130 |
+
self.assertEqual(json.loads(self.out.read_text(encoding="utf-8")), [])
|
| 131 |
+
|
| 132 |
+
def test_a_healthy_run_writes_two_space_indent(self):
|
| 133 |
+
# The stored file is indent=2; writing any other width would rewrite
|
| 134 |
+
# every line of a 440KB data file as a spurious diff.
|
| 135 |
+
ok, log = self._build(self.INDEX)
|
| 136 |
+
self.assertTrue(ok)
|
| 137 |
+
self.assertTrue(self.out.read_text(encoding="utf-8").startswith('[\n {'))
|
| 138 |
+
self.assertIn("1 contraventions -> amps.json", log)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class MainExitCodeTests(unittest.TestCase):
|
| 142 |
+
def test_refused_write_exits_non_zero(self):
|
| 143 |
+
with mock.patch.object(amps, "build", return_value=False), \
|
| 144 |
+
mock.patch.object(amps.sys, "argv", ["amps"]):
|
| 145 |
+
with self.assertRaises(SystemExit) as caught:
|
| 146 |
+
amps.main()
|
| 147 |
+
self.assertEqual(caught.exception.code, 1)
|
| 148 |
+
|
| 149 |
+
def test_successful_run_exits_zero(self):
|
| 150 |
+
with mock.patch.object(amps, "build", return_value=True), \
|
| 151 |
+
mock.patch.object(amps.sys, "argv", ["amps"]):
|
| 152 |
+
with self.assertRaises(SystemExit) as caught:
|
| 153 |
+
amps.main()
|
| 154 |
+
self.assertEqual(caught.exception.code, 0)
|
| 155 |
+
|
| 156 |
+
def test_allow_shrink_flag_reaches_build(self):
|
| 157 |
+
with mock.patch.object(amps, "build", return_value=True) as build, \
|
| 158 |
+
mock.patch.object(amps.sys, "argv", ["amps", "--allow-shrink"]):
|
| 159 |
+
with self.assertRaises(SystemExit):
|
| 160 |
+
amps.main()
|
| 161 |
+
self.assertTrue(build.call_args.kwargs["allow_shrink"])
|
| 162 |
+
|
| 163 |
+
def test_allow_shrink_is_off_by_default(self):
|
| 164 |
+
with mock.patch.object(amps, "build", return_value=True) as build, \
|
| 165 |
+
mock.patch.object(amps.sys, "argv", ["amps"]):
|
| 166 |
+
with self.assertRaises(SystemExit):
|
| 167 |
+
amps.main()
|
| 168 |
+
self.assertFalse(build.call_args.kwargs["allow_shrink"])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
if __name__ == "__main__":
|
| 172 |
+
unittest.main()
|
|
@@ -1,5 +1,15 @@
|
|
| 1 |
-
"""Unit tests for benefit-plan booklet ingestion (canlex/benefits.py).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import unittest
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from canlex import benefits
|
| 5 |
|
|
@@ -75,5 +85,122 @@ class EmitTests(unittest.TestCase):
|
|
| 75 |
self.assertIn("June 2024", c["citation"])
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if __name__ == "__main__":
|
| 79 |
unittest.main()
|
|
|
|
| 1 |
+
"""Unit tests for benefit-plan booklet ingestion (canlex/benefits.py).
|
| 2 |
+
|
| 3 |
+
Offline only: the build tests patch out the fetches and point OUT at a
|
| 4 |
+
tempfile, so the real corpus is never touched.
|
| 5 |
+
"""
|
| 6 |
+
import contextlib
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import tempfile
|
| 10 |
import unittest
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from unittest import mock
|
| 13 |
|
| 14 |
from canlex import benefits
|
| 15 |
|
|
|
|
| 85 |
self.assertIn("June 2024", c["citation"])
|
| 86 |
|
| 87 |
|
| 88 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 89 |
+
STORED = [{"act_code": "PSHCP", "text": "drug benefit"},
|
| 90 |
+
{"act_code": "PSHCP", "text": "hospital benefit"},
|
| 91 |
+
{"act_code": "PSMIP", "text": "basic life insurance"}]
|
| 92 |
+
|
| 93 |
+
def test_failed_plan_keeps_its_last_good_chunks(self):
|
| 94 |
+
chunks, preserved = benefits.preserve_failed(
|
| 95 |
+
[], [("PSHCP", "HTTPError: 404")], self.STORED)
|
| 96 |
+
self.assertEqual(preserved, ["PSHCP"])
|
| 97 |
+
self.assertEqual(len(chunks), 2)
|
| 98 |
+
|
| 99 |
+
def test_freshly_parsed_chunks_win_over_stored(self):
|
| 100 |
+
fresh = [{"act_code": "PSHCP", "text": "reworded drug benefit"}]
|
| 101 |
+
chunks, preserved = benefits.preserve_failed(
|
| 102 |
+
fresh, [("PSHCP", "HTTPError: 404")], self.STORED)
|
| 103 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 104 |
+
|
| 105 |
+
def test_plan_dropped_on_purpose_is_not_resurrected(self):
|
| 106 |
+
# PSMIP is gone from SOURCES (plan wound up), not failing; only the
|
| 107 |
+
# plan that errored comes back.
|
| 108 |
+
chunks, preserved = benefits.preserve_failed(
|
| 109 |
+
[], [("PSHCP", "no sections parsed")], self.STORED)
|
| 110 |
+
self.assertEqual(preserved, ["PSHCP"])
|
| 111 |
+
self.assertNotIn("basic life insurance", [c["text"] for c in chunks])
|
| 112 |
+
|
| 113 |
+
def test_plan_we_never_stored_cannot_be_preserved(self):
|
| 114 |
+
chunks, preserved = benefits.preserve_failed(
|
| 115 |
+
[], [("PSDCP", "timeout")], self.STORED)
|
| 116 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 117 |
+
|
| 118 |
+
def test_nothing_happens_without_failures(self):
|
| 119 |
+
fresh = [{"act_code": "PSDCP", "text": "x"}]
|
| 120 |
+
chunks, preserved = benefits.preserve_failed(fresh, [], self.STORED)
|
| 121 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _chunk(n, code="PSHCP"):
|
| 125 |
+
return {"id": f"benefits-{code}-{n}", "act_code": code, "text": f"body {n}"}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class BuildGuardTests(unittest.TestCase):
|
| 129 |
+
"""A booklet that stops parsing must not empty benefits.json."""
|
| 130 |
+
|
| 131 |
+
SRC = dict(SRC, toc_pages=[1], body_start=2)
|
| 132 |
+
|
| 133 |
+
def setUp(self):
|
| 134 |
+
tmp = tempfile.TemporaryDirectory()
|
| 135 |
+
self.addCleanup(tmp.cleanup)
|
| 136 |
+
self.out = Path(tmp.name) / "benefits.json"
|
| 137 |
+
|
| 138 |
+
def _store(self, chunks):
|
| 139 |
+
self.out.write_text(json.dumps(chunks), encoding="utf-8")
|
| 140 |
+
|
| 141 |
+
def _build(self, parser, allow_shrink=False):
|
| 142 |
+
"""Run build() with one PDF plan parsed by `parser`; no network, no
|
| 143 |
+
real corpus. Returns (ok, stdout)."""
|
| 144 |
+
log = io.StringIO()
|
| 145 |
+
with mock.patch.object(benefits, "OUT", self.out), \
|
| 146 |
+
mock.patch.object(benefits, "SOURCES", {"PSHCP": self.SRC}), \
|
| 147 |
+
mock.patch.object(benefits, "_fetch", lambda url, dest: b"%PDF"), \
|
| 148 |
+
mock.patch.object(benefits, "parse_pdf_booklet", parser), \
|
| 149 |
+
contextlib.redirect_stdout(log):
|
| 150 |
+
ok = benefits.build(allow_shrink=allow_shrink)
|
| 151 |
+
return ok, log.getvalue()
|
| 152 |
+
|
| 153 |
+
def _written(self):
|
| 154 |
+
return json.loads(self.out.read_text(encoding="utf-8"))
|
| 155 |
+
|
| 156 |
+
def test_booklet_that_segments_to_nothing_counts_as_a_failure(self):
|
| 157 |
+
self._store([_chunk(i) for i in range(10)])
|
| 158 |
+
ok, log = self._build(lambda pdf, src: [])
|
| 159 |
+
self.assertTrue(ok)
|
| 160 |
+
self.assertIn("no sections parsed", log)
|
| 161 |
+
self.assertIn("kept the last-good copy", log)
|
| 162 |
+
self.assertEqual(len(self._written()), 10)
|
| 163 |
+
|
| 164 |
+
def test_fetch_error_preserves_the_plan(self):
|
| 165 |
+
self._store([_chunk(i) for i in range(10)])
|
| 166 |
+
|
| 167 |
+
def boom(pdf, src):
|
| 168 |
+
raise RuntimeError("PDF moved")
|
| 169 |
+
|
| 170 |
+
ok, log = self._build(boom)
|
| 171 |
+
self.assertTrue(ok)
|
| 172 |
+
self.assertIn("kept the last-good copy", log)
|
| 173 |
+
self.assertEqual(len(self._written()), 10)
|
| 174 |
+
|
| 175 |
+
def test_collapse_without_a_failure_is_refused(self):
|
| 176 |
+
# The booklet still parses, but to a fraction of what is stored -- a
|
| 177 |
+
# re-typeset PDF whose headings no longer match. Nothing failed, so
|
| 178 |
+
# preservation does not apply and the guard is the only backstop.
|
| 179 |
+
self._store([_chunk(i) for i in range(10)])
|
| 180 |
+
ok, log = self._build(lambda pdf, src: [_chunk(0), _chunk(1)])
|
| 181 |
+
self.assertFalse(ok)
|
| 182 |
+
self.assertIn("REFUSING", log)
|
| 183 |
+
self.assertEqual(len(self._written()), 10)
|
| 184 |
+
|
| 185 |
+
def test_allow_shrink_lets_an_understood_drop_through(self):
|
| 186 |
+
self._store([_chunk(i) for i in range(10)])
|
| 187 |
+
ok, _log = self._build(lambda pdf, src: [_chunk(0), _chunk(1)],
|
| 188 |
+
allow_shrink=True)
|
| 189 |
+
self.assertTrue(ok)
|
| 190 |
+
self.assertEqual(len(self._written()), 2)
|
| 191 |
+
|
| 192 |
+
def test_first_ever_run_writes(self):
|
| 193 |
+
ok, _log = self._build(lambda pdf, src: [_chunk(0), _chunk(1)])
|
| 194 |
+
self.assertTrue(ok)
|
| 195 |
+
self.assertEqual(len(self._written()), 2)
|
| 196 |
+
|
| 197 |
+
def test_written_file_keeps_the_one_space_indent(self):
|
| 198 |
+
# benefits.json has always been written with indent=1; widening it
|
| 199 |
+
# would rewrite every line of the data file for nothing.
|
| 200 |
+
self._build(lambda pdf, src: [_chunk(0)])
|
| 201 |
+
self.assertEqual(self.out.read_text(encoding="utf-8").splitlines()[1],
|
| 202 |
+
" {")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
if __name__ == "__main__":
|
| 206 |
unittest.main()
|
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the case-law ingester (canlex/caselaw.py). Offline only.
|
| 2 |
+
|
| 3 |
+
build() rebuilds caselaw.json from a fresh scrape of the Lexum decision
|
| 4 |
+
databases, so one CAPTCHA wall or re-platforming makes every fetch fail at once
|
| 5 |
+
and the write turns that into an emptied corpus -- the failure that cost the
|
| 6 |
+
D-memos 1,870 chunks on 2026-07-27. These tests drive build() with the curated
|
| 7 |
+
lists emptied (or pointed at a fake on-disk CanLII capture), so no network is
|
| 8 |
+
touched; they cover the write guard, the on-disk indent, and main()'s exit code.
|
| 9 |
+
"""
|
| 10 |
+
import contextlib
|
| 11 |
+
import io
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
import tempfile
|
| 15 |
+
import unittest
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from unittest import mock
|
| 18 |
+
|
| 19 |
+
from canlex import caselaw
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _run(**kwargs):
|
| 23 |
+
"""Call build() with stdout captured; returns (result, printed text)."""
|
| 24 |
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
| 25 |
+
ok = caselaw.build(**kwargs)
|
| 26 |
+
return ok, out.getvalue()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class WriteGuardTests(unittest.TestCase):
|
| 30 |
+
"""Every curated list is empty here, so build() reaches the write without
|
| 31 |
+
fetching anything -- exactly the shape a scrape that lost every decision
|
| 32 |
+
would take."""
|
| 33 |
+
|
| 34 |
+
def setUp(self):
|
| 35 |
+
tmp = tempfile.TemporaryDirectory()
|
| 36 |
+
self.addCleanup(tmp.cleanup)
|
| 37 |
+
self.out = Path(tmp.name) / "caselaw.json"
|
| 38 |
+
for attr, value in (("OUT", self.out), ("CASES", []),
|
| 39 |
+
("CANLII_CASES", []), ("IRB_GUIDES", [])):
|
| 40 |
+
patch = mock.patch.object(caselaw, attr, value)
|
| 41 |
+
patch.start()
|
| 42 |
+
self.addCleanup(patch.stop)
|
| 43 |
+
|
| 44 |
+
def _store(self, count):
|
| 45 |
+
self.out.write_text(json.dumps([{"id": f"scc-{i}-1"}
|
| 46 |
+
for i in range(count)]),
|
| 47 |
+
encoding="utf-8")
|
| 48 |
+
|
| 49 |
+
def test_first_ever_run_writes(self):
|
| 50 |
+
ok, _printed = _run()
|
| 51 |
+
self.assertTrue(ok)
|
| 52 |
+
self.assertEqual(json.loads(self.out.read_text(encoding="utf-8")), [])
|
| 53 |
+
|
| 54 |
+
def test_a_scrape_that_lost_every_decision_is_refused(self):
|
| 55 |
+
self._store(100)
|
| 56 |
+
ok, printed = _run()
|
| 57 |
+
self.assertFalse(ok)
|
| 58 |
+
self.assertIn("REFUSING", printed)
|
| 59 |
+
self.assertEqual(len(json.loads(self.out.read_text(encoding="utf-8"))),
|
| 60 |
+
100)
|
| 61 |
+
|
| 62 |
+
def test_allow_shrink_overrides_the_refusal(self):
|
| 63 |
+
self._store(100)
|
| 64 |
+
ok, _printed = _run(allow_shrink=True)
|
| 65 |
+
self.assertTrue(ok)
|
| 66 |
+
self.assertEqual(json.loads(self.out.read_text(encoding="utf-8")), [])
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class WrittenFormatTests(unittest.TestCase):
|
| 70 |
+
"""One fake CanLII capture: that branch reads cached page text off disk, so
|
| 71 |
+
a real chunk reaches the write with no fetch."""
|
| 72 |
+
|
| 73 |
+
CASE = {"file": "canlii-fake.txt",
|
| 74 |
+
"canlii_path": "en/ca/fca/doc/2020/2020fca1/2020fca1.html",
|
| 75 |
+
"short": "Fake", "name": "R v Fake", "cite": "2020 FCA 1",
|
| 76 |
+
"report": "", "date": "2020-01-01",
|
| 77 |
+
"court_name": "Federal Court of Appeal", "topic": "Testing"}
|
| 78 |
+
|
| 79 |
+
def setUp(self):
|
| 80 |
+
tmp = tempfile.TemporaryDirectory()
|
| 81 |
+
self.addCleanup(tmp.cleanup)
|
| 82 |
+
raw = Path(tmp.name) / "raw"
|
| 83 |
+
raw.mkdir()
|
| 84 |
+
(raw / self.CASE["file"]).write_text(
|
| 85 |
+
"R v Fake\nThe appeal is dismissed.\nCosts to the respondent.",
|
| 86 |
+
encoding="utf-8")
|
| 87 |
+
self.out = Path(tmp.name) / "caselaw.json"
|
| 88 |
+
for attr, value in (("OUT", self.out), ("_RAW", raw), ("CASES", []),
|
| 89 |
+
("CANLII_CASES", [self.CASE]), ("IRB_GUIDES", [])):
|
| 90 |
+
patch = mock.patch.object(caselaw, attr, value)
|
| 91 |
+
patch.start()
|
| 92 |
+
self.addCleanup(patch.stop)
|
| 93 |
+
|
| 94 |
+
def test_chunks_reach_the_file(self):
|
| 95 |
+
ok, _printed = _run()
|
| 96 |
+
self.assertTrue(ok)
|
| 97 |
+
chunks = json.loads(self.out.read_text(encoding="utf-8"))
|
| 98 |
+
self.assertEqual([c["id"] for c in chunks], ["canlii-2020fca1-1"])
|
| 99 |
+
self.assertEqual(chunks[0]["doc_type"], "caselaw")
|
| 100 |
+
|
| 101 |
+
def test_indent_stays_at_one_space(self):
|
| 102 |
+
# caselaw.json is stored with indent=1; writing it at any other indent
|
| 103 |
+
# would rewrite every line of a 20k-line file for no content change.
|
| 104 |
+
_run()
|
| 105 |
+
lines = self.out.read_text(encoding="utf-8").splitlines()
|
| 106 |
+
self.assertEqual(lines[0], "[")
|
| 107 |
+
self.assertEqual(lines[1], " {")
|
| 108 |
+
|
| 109 |
+
def test_non_ascii_is_not_escaped(self):
|
| 110 |
+
# Locators use an en dash, citations carry accents.
|
| 111 |
+
_run()
|
| 112 |
+
self.assertNotIn("\\u", self.out.read_text(encoding="utf-8"))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class MainExitTests(unittest.TestCase):
|
| 116 |
+
def _main(self, argv, build_result):
|
| 117 |
+
seen = {}
|
| 118 |
+
|
| 119 |
+
def fake_build(allow_shrink=False):
|
| 120 |
+
seen["allow_shrink"] = allow_shrink
|
| 121 |
+
return build_result
|
| 122 |
+
|
| 123 |
+
with mock.patch.object(caselaw, "build", fake_build), \
|
| 124 |
+
mock.patch.object(sys, "argv", argv), \
|
| 125 |
+
contextlib.redirect_stdout(io.StringIO()):
|
| 126 |
+
with self.assertRaises(SystemExit) as raised:
|
| 127 |
+
caselaw.main()
|
| 128 |
+
return raised.exception.code, seen
|
| 129 |
+
|
| 130 |
+
def test_a_refused_write_exits_non_zero(self):
|
| 131 |
+
# The refresh runbook shells out to `py -m canlex.caselaw`; a silent
|
| 132 |
+
# zero exit is how a gutted corpus would sail past a scheduled run.
|
| 133 |
+
code, _seen = self._main(["caselaw"], False)
|
| 134 |
+
self.assertEqual(code, 1)
|
| 135 |
+
|
| 136 |
+
def test_a_written_corpus_exits_zero(self):
|
| 137 |
+
code, _seen = self._main(["caselaw"], True)
|
| 138 |
+
self.assertEqual(code, 0)
|
| 139 |
+
|
| 140 |
+
def test_allow_shrink_flag_is_forwarded(self):
|
| 141 |
+
_code, seen = self._main(["caselaw", "--allow-shrink"], True)
|
| 142 |
+
self.assertTrue(seen["allow_shrink"])
|
| 143 |
+
|
| 144 |
+
def test_guard_is_on_without_the_flag(self):
|
| 145 |
+
_code, seen = self._main(["caselaw"], True)
|
| 146 |
+
self.assertFalse(seen["allow_shrink"])
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
unittest.main()
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the Charter / Constitution ingester (canlex/charter.py).
|
| 2 |
+
|
| 3 |
+
Offline only -- every test drives parse() with a fixture cut from the real
|
| 4 |
+
Justice Laws markup. The point is the failure mode that emptied dmemos.json on
|
| 5 |
+
2026-07-27: if the upstream page is re-shaped the scrape returns nothing, so
|
| 6 |
+
parse() must be provably capable of returning [] and build() must refuse to
|
| 7 |
+
write that over the stored corpus.
|
| 8 |
+
"""
|
| 9 |
+
import sys
|
| 10 |
+
import unittest
|
| 11 |
+
from unittest import mock
|
| 12 |
+
|
| 13 |
+
from canlex import charter
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _note(text):
|
| 17 |
+
return (f'<p class="MarginalNote"><span class="wb-invisible">Marginal '
|
| 18 |
+
f'note:</span>{text}</p>')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _section(num, body):
|
| 22 |
+
return (f'<p class="Section"><strong><a class="sectionLabel" id="s-{num}">'
|
| 23 |
+
f'<span class="sectionLabel">{num}</span></a></strong>\xa0{body}</p>')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Both Acts in sequence, as the page serves them. Section numbers restart at
|
| 27 |
+
# the 1982 heading, so "1" appears in each -- only the 1982 one is in scope.
|
| 28 |
+
FIXTURE = "".join([
|
| 29 |
+
"<h1>CONSTITUTION ACT, 1867</h1>",
|
| 30 |
+
_note("Short title"),
|
| 31 |
+
_section(1, "This Act may be cited as the Constitution Act, 1867."),
|
| 32 |
+
_note("Legislative Authority of Parliament of Canada"),
|
| 33 |
+
_section(91, "It shall be lawful for the Queen to make Laws for the "
|
| 34 |
+
"Peace, Order, and good Government of Canada."),
|
| 35 |
+
_note("Subjects of exclusive Provincial Legislation"),
|
| 36 |
+
_section(92, "In each Province the Legislature may exclusively make Laws."),
|
| 37 |
+
"<h1>CONSTITUTION ACT, 1982</h1>",
|
| 38 |
+
_note("Rights and freedoms in Canada"),
|
| 39 |
+
_section(1, "The Canadian Charter of Rights and Freedoms guarantees the "
|
| 40 |
+
"rights and freedoms set out in it."),
|
| 41 |
+
_note("Fundamental freedoms"),
|
| 42 |
+
_section(2, "Everyone has the following fundamental freedoms:"),
|
| 43 |
+
'<ul class="ProvisionList"><li><p class="Paragraph">'
|
| 44 |
+
'<span class="lawlabel">(a)</span>\xa0freedom of conscience and religion;'
|
| 45 |
+
"</p></li></ul>",
|
| 46 |
+
_note("Detention or imprisonment"),
|
| 47 |
+
_section(9, "Everyone has the right not to be arbitrarily detained."),
|
| 48 |
+
_note("Commitment to promote equal opportunities"),
|
| 49 |
+
_section(36, "Parliament and the legislatures are committed to promoting "
|
| 50 |
+
"equal opportunities."),
|
| 51 |
+
_note("Primacy of Constitution of Canada"),
|
| 52 |
+
_section(52, "The Constitution of Canada is the supreme law of Canada."),
|
| 53 |
+
])
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ParseTests(unittest.TestCase):
|
| 57 |
+
def setUp(self):
|
| 58 |
+
self.chunks = charter.parse(FIXTURE)
|
| 59 |
+
self.by_id = {c["id"]: c for c in self.chunks}
|
| 60 |
+
|
| 61 |
+
def test_sections_land_in_the_act_they_belong_to(self):
|
| 62 |
+
# s. 1 exists in both Acts; the 1982 one is the Charter's.
|
| 63 |
+
self.assertIn("CONST-1982-s1", self.by_id)
|
| 64 |
+
self.assertNotIn("CONST-1867-s1", self.by_id)
|
| 65 |
+
self.assertIn("CONST-1867-s91", self.by_id)
|
| 66 |
+
|
| 67 |
+
def test_ids_stay_unique_across_the_restart_in_numbering(self):
|
| 68 |
+
ids = [c["id"] for c in self.chunks]
|
| 69 |
+
self.assertEqual(len(ids), len(set(ids)))
|
| 70 |
+
|
| 71 |
+
def test_out_of_scope_sections_are_dropped(self):
|
| 72 |
+
# 1982 stops at s. 35 (plus s. 52); 1867 keeps only ss. 91-92.
|
| 73 |
+
self.assertNotIn("CONST-1982-s36", self.by_id)
|
| 74 |
+
self.assertIn("CONST-1982-s52", self.by_id)
|
| 75 |
+
self.assertEqual(sorted(c["section"] for c in self.chunks
|
| 76 |
+
if c["act_code"] == "CONST-1867"),
|
| 77 |
+
["91", "92"])
|
| 78 |
+
|
| 79 |
+
def test_marginal_note_drops_the_invisible_label(self):
|
| 80 |
+
self.assertEqual(self.by_id["CONST-1982-s9"]["marginal_note"],
|
| 81 |
+
"Detention or imprisonment")
|
| 82 |
+
|
| 83 |
+
def test_section_text_runs_to_the_next_marginal_note(self):
|
| 84 |
+
# s. 2's paragraphs live in a sibling list, outside the Section <p>.
|
| 85 |
+
self.assertIn("freedom of conscience",
|
| 86 |
+
self.by_id["CONST-1982-s2"]["text"])
|
| 87 |
+
self.assertNotIn("arbitrarily detained",
|
| 88 |
+
self.by_id["CONST-1982-s2"]["text"])
|
| 89 |
+
|
| 90 |
+
def test_chunks_are_citable_legislation(self):
|
| 91 |
+
chunk = self.by_id["CONST-1982-s9"]
|
| 92 |
+
self.assertEqual(chunk["doc_type"], "legislation")
|
| 93 |
+
self.assertEqual(chunk["citation"], "Charter, s. 9")
|
| 94 |
+
self.assertEqual(chunk["source_url"], charter.URL)
|
| 95 |
+
|
| 96 |
+
def test_marginal_note_without_a_section_label_is_skipped(self):
|
| 97 |
+
html = ("<h1>CONSTITUTION ACT, 1982</h1>" + _note("Schedule heading") +
|
| 98 |
+
"<p>prose with no section label</p>")
|
| 99 |
+
self.assertEqual(charter.parse(html), [])
|
| 100 |
+
|
| 101 |
+
def test_a_reshaped_page_scrapes_to_nothing(self):
|
| 102 |
+
# What a JS-rendered rebuild would look like: heading still there, the
|
| 103 |
+
# MarginalNote/Section markup gone. This is the empty write the guard
|
| 104 |
+
# in build() exists to refuse.
|
| 105 |
+
html = ("<h1>CONSTITUTION ACT, 1982</h1>"
|
| 106 |
+
'<table id="const-tbl" class="wb-tables"></table>')
|
| 107 |
+
self.assertEqual(charter.parse(html), [])
|
| 108 |
+
|
| 109 |
+
def test_losing_the_1982_heading_is_an_error_not_a_half_corpus(self):
|
| 110 |
+
# Without the split every 1982 section would be filed under 1867.
|
| 111 |
+
with self.assertRaises(ValueError):
|
| 112 |
+
charter.parse("<h1>CONSTITUTION ACT, 1867</h1>" + _note("x") +
|
| 113 |
+
_section(91, "text"))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class MainTests(unittest.TestCase):
|
| 117 |
+
"""main() has to make a refused write visible to refresh.py, which runs
|
| 118 |
+
this module as a subprocess and only sees the exit status."""
|
| 119 |
+
|
| 120 |
+
def _run(self, argv, build_result=True):
|
| 121 |
+
with mock.patch.object(charter, "build") as build:
|
| 122 |
+
build.return_value = build_result
|
| 123 |
+
with mock.patch.object(sys, "argv", argv):
|
| 124 |
+
with self.assertRaises(SystemExit) as caught:
|
| 125 |
+
charter.main()
|
| 126 |
+
return caught.exception.code, build
|
| 127 |
+
|
| 128 |
+
def test_refused_write_exits_nonzero(self):
|
| 129 |
+
code, _build = self._run(["charter"], build_result=False)
|
| 130 |
+
self.assertEqual(code, 1)
|
| 131 |
+
|
| 132 |
+
def test_successful_write_exits_zero(self):
|
| 133 |
+
code, _build = self._run(["charter"], build_result=True)
|
| 134 |
+
self.assertEqual(code, 0)
|
| 135 |
+
|
| 136 |
+
def test_allow_shrink_flag_reaches_build(self):
|
| 137 |
+
_code, build = self._run(["charter", "--allow-shrink"])
|
| 138 |
+
build.assert_called_once_with(allow_shrink=True)
|
| 139 |
+
|
| 140 |
+
def test_guard_is_on_by_default(self):
|
| 141 |
+
_code, build = self._run(["charter"])
|
| 142 |
+
build.assert_called_once_with(allow_shrink=False)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
unittest.main()
|
|
@@ -1,12 +1,20 @@
|
|
| 1 |
"""Unit tests for the curated-commentary pipeline (canlex/commentary.py and
|
| 2 |
the canlex_us_disposition matcher in canlex/server.py).
|
| 3 |
|
| 4 |
-
Offline: they run on a minimal in-memory dataset, not the curated file
|
|
|
|
| 5 |
|
| 6 |
python -m unittest discover -s tests
|
| 7 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import unittest
|
|
|
|
| 9 |
|
|
|
|
| 10 |
from canlex.commentary import BANNER, _entry_text
|
| 11 |
from canlex.server import _match_dispositions
|
| 12 |
|
|
@@ -69,5 +77,129 @@ class MatcherTests(unittest.TestCase):
|
|
| 69 |
_match_dispositions(self.DATA, "entirely unrelated words", None), [])
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if __name__ == "__main__":
|
| 73 |
unittest.main()
|
|
|
|
| 1 |
"""Unit tests for the curated-commentary pipeline (canlex/commentary.py and
|
| 2 |
the canlex_us_disposition matcher in canlex/server.py).
|
| 3 |
|
| 4 |
+
Offline: they run on a minimal in-memory dataset, not the curated file, and
|
| 5 |
+
write only into a tempdir.
|
| 6 |
|
| 7 |
python -m unittest discover -s tests
|
| 8 |
"""
|
| 9 |
+
import contextlib
|
| 10 |
+
import io
|
| 11 |
+
import json
|
| 12 |
+
import sys
|
| 13 |
+
import tempfile
|
| 14 |
import unittest
|
| 15 |
+
from pathlib import Path
|
| 16 |
|
| 17 |
+
from canlex import commentary
|
| 18 |
from canlex.commentary import BANNER, _entry_text
|
| 19 |
from canlex.server import _match_dispositions
|
| 20 |
|
|
|
|
| 77 |
_match_dispositions(self.DATA, "entirely unrelated words", None), [])
|
| 78 |
|
| 79 |
|
| 80 |
+
def dataset(n):
|
| 81 |
+
"""A curated file holding n dispositions -> n + 2 chunks (one methodology
|
| 82 |
+
entry, n disposition entries, one Florida state page)."""
|
| 83 |
+
return {"reviewed": "2026-07-01",
|
| 84 |
+
"methodology": [{"id": "m1", "title": "How this was built",
|
| 85 |
+
"text": "Method body."}],
|
| 86 |
+
"dispositions": [entry(id=f"d{i}", names=[f"disposition {i}"])
|
| 87 |
+
for i in range(n)]}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class _TempCorpus:
|
| 91 |
+
"""Points the module's curated inputs and its output at a tempdir."""
|
| 92 |
+
|
| 93 |
+
def setUp(self):
|
| 94 |
+
self.tmp = tempfile.TemporaryDirectory()
|
| 95 |
+
self.addCleanup(self.tmp.cleanup)
|
| 96 |
+
root = Path(self.tmp.name)
|
| 97 |
+
self.out = root / "commentary.json"
|
| 98 |
+
self.curated = root / "us_dispositions.json"
|
| 99 |
+
# No equivalency file: build() already treats it as optional, so the
|
| 100 |
+
# chunk arithmetic below stays down to the dispositions dataset.
|
| 101 |
+
for name, tmp_path in (("OUT", self.out), ("CURATED", self.curated),
|
| 102 |
+
("EQUIV", root / "us_equivalency.json")):
|
| 103 |
+
self.addCleanup(setattr, commentary, name,
|
| 104 |
+
getattr(commentary, name))
|
| 105 |
+
setattr(commentary, name, tmp_path)
|
| 106 |
+
|
| 107 |
+
def curate(self, n):
|
| 108 |
+
self.curated.write_text(json.dumps(dataset(n)), encoding="utf-8")
|
| 109 |
+
|
| 110 |
+
def stored(self):
|
| 111 |
+
return json.loads(self.out.read_text(encoding="utf-8"))
|
| 112 |
+
|
| 113 |
+
def run_build(self, **kw):
|
| 114 |
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
| 115 |
+
ok = commentary.build(**kw)
|
| 116 |
+
return ok, out.getvalue()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class BuildWriteTests(_TempCorpus, unittest.TestCase):
|
| 120 |
+
"""The write path. build() re-renders the whole corpus from the curated
|
| 121 |
+
files, so a truncated or half-edited dataset must not replace good chunks.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def test_first_run_writes_and_reports_success(self):
|
| 125 |
+
self.curate(3)
|
| 126 |
+
ok, out = self.run_build()
|
| 127 |
+
self.assertTrue(ok)
|
| 128 |
+
self.assertEqual(len(self.stored()), 5)
|
| 129 |
+
self.assertIn("commentary chunks", out)
|
| 130 |
+
|
| 131 |
+
def test_collapsed_dataset_is_refused_and_leaves_the_corpus_alone(self):
|
| 132 |
+
self.curate(20)
|
| 133 |
+
self.run_build()
|
| 134 |
+
self.curate(1) # dataset truncated to a stub
|
| 135 |
+
ok, out = self.run_build()
|
| 136 |
+
self.assertFalse(ok)
|
| 137 |
+
self.assertIn("REFUSING", out)
|
| 138 |
+
self.assertEqual(len(self.stored()), 22)
|
| 139 |
+
|
| 140 |
+
def test_allow_shrink_lets_a_known_drop_through(self):
|
| 141 |
+
self.curate(20)
|
| 142 |
+
self.run_build()
|
| 143 |
+
self.curate(1)
|
| 144 |
+
ok, _out = self.run_build(allow_shrink=True)
|
| 145 |
+
self.assertTrue(ok)
|
| 146 |
+
self.assertEqual(len(self.stored()), 3)
|
| 147 |
+
|
| 148 |
+
def test_normal_churn_still_writes(self):
|
| 149 |
+
self.curate(20)
|
| 150 |
+
self.run_build()
|
| 151 |
+
self.curate(19)
|
| 152 |
+
self.assertTrue(self.run_build()[0])
|
| 153 |
+
self.assertEqual(len(self.stored()), 21)
|
| 154 |
+
|
| 155 |
+
def test_writes_one_space_indent(self):
|
| 156 |
+
# The stored corpus is indent=1; any other value rewrites every line
|
| 157 |
+
# of commentary.json and buries the real change in the diff.
|
| 158 |
+
self.curate(1)
|
| 159 |
+
self.run_build()
|
| 160 |
+
self.assertTrue(
|
| 161 |
+
self.out.read_text(encoding="utf-8").startswith('[\n {\n "id"'))
|
| 162 |
+
|
| 163 |
+
def test_non_ascii_is_not_escaped(self):
|
| 164 |
+
self.curate(1)
|
| 165 |
+
self.run_build()
|
| 166 |
+
self.assertIn("—", self.out.read_text(encoding="utf-8"))
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class MainExitTests(_TempCorpus, unittest.TestCase):
|
| 170 |
+
"""A refused write has to fail the process: refresh.py shells out to
|
| 171 |
+
`py -m canlex.commentary` and only sees the exit code."""
|
| 172 |
+
|
| 173 |
+
def argv(self, *args):
|
| 174 |
+
self.addCleanup(setattr, sys, "argv", sys.argv)
|
| 175 |
+
sys.argv = ["canlex.commentary", *args]
|
| 176 |
+
|
| 177 |
+
def run_main(self):
|
| 178 |
+
with self.assertRaises(SystemExit) as caught:
|
| 179 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 180 |
+
commentary.main()
|
| 181 |
+
return caught.exception.code
|
| 182 |
+
|
| 183 |
+
def test_exits_zero_on_a_good_run(self):
|
| 184 |
+
self.curate(3)
|
| 185 |
+
self.argv()
|
| 186 |
+
self.assertEqual(self.run_main(), 0)
|
| 187 |
+
|
| 188 |
+
def test_exits_non_zero_when_the_write_is_refused(self):
|
| 189 |
+
self.curate(20)
|
| 190 |
+
self.run_build()
|
| 191 |
+
self.curate(1)
|
| 192 |
+
self.argv()
|
| 193 |
+
self.assertEqual(self.run_main(), 1)
|
| 194 |
+
|
| 195 |
+
def test_allow_shrink_flag_is_parsed(self):
|
| 196 |
+
self.curate(20)
|
| 197 |
+
self.run_build()
|
| 198 |
+
self.curate(1)
|
| 199 |
+
self.argv("--allow-shrink")
|
| 200 |
+
self.assertEqual(self.run_main(), 0)
|
| 201 |
+
self.assertEqual(len(self.stored()), 3)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
if __name__ == "__main__":
|
| 205 |
unittest.main()
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the shared ingestion helpers (canlex/_common.py).
|
| 2 |
+
|
| 3 |
+
Offline only. The corpus-write guard exists because every ingester rebuilds its
|
| 4 |
+
whole processed file from a scrape: on 2026-07-27 a rebuilt upstream index
|
| 5 |
+
scraped to zero items and the write emptied 1,870 good chunks.
|
| 6 |
+
"""
|
| 7 |
+
import contextlib
|
| 8 |
+
import io
|
| 9 |
+
import json
|
| 10 |
+
import tempfile
|
| 11 |
+
import unittest
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from canlex import _common
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SafeToWriteTests(unittest.TestCase):
|
| 18 |
+
def test_refuses_to_replace_a_corpus_with_nothing(self):
|
| 19 |
+
self.assertFalse(_common.safe_to_write(0, 1870))
|
| 20 |
+
|
| 21 |
+
def test_refuses_a_large_collapse(self):
|
| 22 |
+
self.assertFalse(_common.safe_to_write(500, 1870))
|
| 23 |
+
|
| 24 |
+
def test_allows_normal_churn(self):
|
| 25 |
+
self.assertTrue(_common.safe_to_write(1866, 1870))
|
| 26 |
+
self.assertTrue(_common.safe_to_write(1900, 1870))
|
| 27 |
+
|
| 28 |
+
def test_first_ever_run_is_allowed(self):
|
| 29 |
+
self.assertTrue(_common.safe_to_write(0, 0))
|
| 30 |
+
|
| 31 |
+
def test_ratio_is_tunable(self):
|
| 32 |
+
self.assertTrue(_common.safe_to_write(500, 1000, ratio=0.5))
|
| 33 |
+
self.assertFalse(_common.safe_to_write(499, 1000, ratio=0.5))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class WriteCorpusTests(unittest.TestCase):
|
| 37 |
+
def setUp(self):
|
| 38 |
+
self.tmp = tempfile.TemporaryDirectory()
|
| 39 |
+
self.path = Path(self.tmp.name) / "corpus.json"
|
| 40 |
+
self.addCleanup(self.tmp.cleanup)
|
| 41 |
+
|
| 42 |
+
def _write(self, chunks):
|
| 43 |
+
self.path.write_text(json.dumps(chunks), encoding="utf-8")
|
| 44 |
+
|
| 45 |
+
def test_writes_when_there_is_nothing_to_lose(self):
|
| 46 |
+
self.assertTrue(_common.write_corpus(self.path, [{"id": "a"}]))
|
| 47 |
+
self.assertEqual(json.loads(self.path.read_text(encoding="utf-8")),
|
| 48 |
+
[{"id": "a"}])
|
| 49 |
+
|
| 50 |
+
def test_refuses_and_leaves_the_file_untouched(self):
|
| 51 |
+
self._write([{"id": str(i)} for i in range(100)])
|
| 52 |
+
with contextlib.redirect_stdout(io.StringIO()) as out:
|
| 53 |
+
self.assertFalse(_common.write_corpus(self.path, []))
|
| 54 |
+
self.assertIn("REFUSING", out.getvalue())
|
| 55 |
+
self.assertEqual(len(json.loads(self.path.read_text(encoding="utf-8"))),
|
| 56 |
+
100)
|
| 57 |
+
|
| 58 |
+
def test_allow_shrink_overrides(self):
|
| 59 |
+
self._write([{"id": str(i)} for i in range(100)])
|
| 60 |
+
self.assertTrue(_common.write_corpus(self.path, [], allow_shrink=True))
|
| 61 |
+
self.assertEqual(json.loads(self.path.read_text(encoding="utf-8")), [])
|
| 62 |
+
|
| 63 |
+
def test_unreadable_stored_file_does_not_block_a_write(self):
|
| 64 |
+
# A truncated/corrupt corpus reads as empty, so recovery is possible.
|
| 65 |
+
self.path.write_text("{not json", encoding="utf-8")
|
| 66 |
+
self.assertTrue(_common.write_corpus(self.path, [{"id": "a"}]))
|
| 67 |
+
|
| 68 |
+
def test_creates_missing_parent_directories(self):
|
| 69 |
+
nested = Path(self.tmp.name) / "deep" / "corpus.json"
|
| 70 |
+
self.assertTrue(_common.write_corpus(nested, [{"id": "a"}]))
|
| 71 |
+
self.assertTrue(nested.exists())
|
| 72 |
+
|
| 73 |
+
def test_non_ascii_survives_the_round_trip(self):
|
| 74 |
+
chunks = [{"id": "a", "text": "détention à la frontière"}]
|
| 75 |
+
_common.write_corpus(self.path, chunks)
|
| 76 |
+
self.assertEqual(json.loads(self.path.read_text(encoding="utf-8")),
|
| 77 |
+
chunks)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class StoredChunksTests(unittest.TestCase):
|
| 81 |
+
def test_missing_file_is_empty(self):
|
| 82 |
+
self.assertEqual(_common.stored_chunks(Path("nope-does-not-exist.json")),
|
| 83 |
+
[])
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class PreserveDroppedTests(unittest.TestCase):
|
| 87 |
+
STORED = [{"section": "A", "text": "one"},
|
| 88 |
+
{"section": "A", "text": "two"},
|
| 89 |
+
{"section": "B", "text": "three"}]
|
| 90 |
+
|
| 91 |
+
def test_reattaches_a_dropped_item(self):
|
| 92 |
+
chunks, dropped = _common.preserve_dropped([], self.STORED, only={"A"})
|
| 93 |
+
self.assertEqual(dropped, ["A"])
|
| 94 |
+
self.assertEqual(len(chunks), 2)
|
| 95 |
+
|
| 96 |
+
def test_only_restricts_to_genuine_failures(self):
|
| 97 |
+
# B vanished deliberately (cancelled upstream); it must not come back.
|
| 98 |
+
chunks, dropped = _common.preserve_dropped([], self.STORED, only={"A"})
|
| 99 |
+
self.assertNotIn("B", dropped)
|
| 100 |
+
self.assertNotIn("three", [c["text"] for c in chunks])
|
| 101 |
+
|
| 102 |
+
def test_without_only_every_missing_item_is_restored(self):
|
| 103 |
+
_chunks, dropped = _common.preserve_dropped([], self.STORED)
|
| 104 |
+
self.assertEqual(dropped, ["A", "B"])
|
| 105 |
+
|
| 106 |
+
def test_freshly_scraped_items_are_not_overwritten(self):
|
| 107 |
+
fresh = [{"section": "A", "text": "new"}]
|
| 108 |
+
chunks, dropped = _common.preserve_dropped(fresh, self.STORED,
|
| 109 |
+
only={"A"})
|
| 110 |
+
self.assertEqual((chunks, dropped), (fresh, []))
|
| 111 |
+
|
| 112 |
+
def test_a_stored_chunk_without_the_identity_field_is_skipped(self):
|
| 113 |
+
# Not a crash: an older-schema corpus must not take down the rebuild
|
| 114 |
+
# the guard exists to protect.
|
| 115 |
+
stored = self.STORED + [{"id": "legacy-1", "text": "no section key"}]
|
| 116 |
+
chunks, dropped = _common.preserve_dropped([], stored, only={"A"})
|
| 117 |
+
self.assertEqual(dropped, ["A"])
|
| 118 |
+
self.assertNotIn("legacy-1", [c.get("id") for c in chunks])
|
| 119 |
+
|
| 120 |
+
def test_custom_key(self):
|
| 121 |
+
stored = [{"case": "Khosa", "text": "x"}]
|
| 122 |
+
chunks, dropped = _common.preserve_dropped(
|
| 123 |
+
[], stored, key=lambda c: c["case"], only={"Khosa"})
|
| 124 |
+
self.assertEqual((len(chunks), dropped), (1, ["Khosa"]))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
unittest.main()
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the delegation-instrument ingester (canlex/delegation.py).
|
| 2 |
+
|
| 3 |
+
Offline only. The ingester rebuilds delegation.json from scratch on every run,
|
| 4 |
+
so an instrument whose page moves or changes shape drops out of the corpus
|
| 5 |
+
silently -- the failure mode that emptied dmemos.json on 2026-07-27. These cover
|
| 6 |
+
the identity the run's failures are tracked by, not the shared write guard
|
| 7 |
+
(tests/test_common.py owns that).
|
| 8 |
+
"""
|
| 9 |
+
import unittest
|
| 10 |
+
|
| 11 |
+
from canlex import delegation
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
SRC = {
|
| 15 |
+
"code": "cbsa-2023-05",
|
| 16 |
+
"act_code": "CBSA-IRPA-DELEG-2023-05",
|
| 17 |
+
"act_short": "CBSA Deleg 2023-05-08",
|
| 18 |
+
"act_name": "Delegation of Authority ...",
|
| 19 |
+
"url": "https://example.invalid/irpa-lipr-2023-05-08-eng.html",
|
| 20 |
+
"effective": "2023-05-08",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ChunkIdentityTests(unittest.TestCase):
|
| 25 |
+
"""preserve_failed keys on act_code, so the parsers must stamp it."""
|
| 26 |
+
|
| 27 |
+
def test_schedule_items_carry_the_instrument_act_code(self):
|
| 28 |
+
html = ("<main><table class='table-bordered'>"
|
| 29 |
+
"<tr><td>1.</td><td>A55(1)</td><td>Arrest with warrant</td>"
|
| 30 |
+
"<td><p class='h4'>CBSA</p><ul><li>Officer</li></ul></td></tr>"
|
| 31 |
+
"</table></main>")
|
| 32 |
+
chunks = delegation.parse_cbsa(html, SRC)
|
| 33 |
+
self.assertTrue(chunks)
|
| 34 |
+
self.assertEqual({c["act_code"] for c in chunks}, {SRC["act_code"]})
|
| 35 |
+
|
| 36 |
+
def test_narrative_instruments_carry_it_too(self):
|
| 37 |
+
html = "<main><p>The following positions are authorized.</p></main>"
|
| 38 |
+
chunks = delegation.parse_cbsa_narrative(html, SRC)
|
| 39 |
+
self.assertEqual([c["act_code"] for c in chunks], [SRC["act_code"]])
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 43 |
+
STORED = [{"act_code": "CBSA-IRPA-DELEG-2023-05", "text": "item 1"},
|
| 44 |
+
{"act_code": "CBSA-IRPA-DELEG-2023-05", "text": "item 2"},
|
| 45 |
+
{"act_code": "IRCC-IL3-DELEG", "text": "IL3 item"}]
|
| 46 |
+
|
| 47 |
+
def test_unfetchable_instrument_keeps_its_last_good_chunks(self):
|
| 48 |
+
failures = [("CBSA-IRPA-DELEG-2023-05", "HTTPError: 404")]
|
| 49 |
+
chunks, preserved = delegation.preserve_failed([], failures, self.STORED)
|
| 50 |
+
self.assertEqual(preserved, ["CBSA-IRPA-DELEG-2023-05"])
|
| 51 |
+
self.assertEqual([c["text"] for c in chunks], ["item 1", "item 2"])
|
| 52 |
+
|
| 53 |
+
def test_an_instrument_that_parses_to_nothing_is_a_failure_too(self):
|
| 54 |
+
# A reachable page whose table markup changed -- the CBSA D-memo trap.
|
| 55 |
+
failures = [("IRCC-IL3-DELEG", "no content parsed")]
|
| 56 |
+
_chunks, preserved = delegation.preserve_failed([], failures,
|
| 57 |
+
self.STORED)
|
| 58 |
+
self.assertEqual(preserved, ["IRCC-IL3-DELEG"])
|
| 59 |
+
|
| 60 |
+
def test_freshly_parsed_chunks_win_over_stored(self):
|
| 61 |
+
fresh = [{"act_code": "CBSA-IRPA-DELEG-2023-05", "text": "new text"}]
|
| 62 |
+
failures = [("CBSA-IRPA-DELEG-2023-05", "no content parsed")]
|
| 63 |
+
chunks, preserved = delegation.preserve_failed(fresh, failures,
|
| 64 |
+
self.STORED)
|
| 65 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 66 |
+
|
| 67 |
+
def test_instrument_retired_from_sources_is_not_resurrected(self):
|
| 68 |
+
# Removing an entry from SOURCES is deliberate; it never fails, so it
|
| 69 |
+
# must stay out of the corpus.
|
| 70 |
+
failures = [("CBSA-IRPA-DELEG-2023-05", "HTTPError: 404")]
|
| 71 |
+
chunks, preserved = delegation.preserve_failed([], failures,
|
| 72 |
+
self.STORED)
|
| 73 |
+
self.assertNotIn("IRCC-IL3-DELEG", preserved)
|
| 74 |
+
self.assertNotIn("IL3 item", [c["text"] for c in chunks])
|
| 75 |
+
|
| 76 |
+
def test_instrument_we_never_stored_cannot_be_preserved(self):
|
| 77 |
+
failures = [("CBSA-IRPA-PEACEOFF-2022-08", "HTTPError: 404")]
|
| 78 |
+
chunks, preserved = delegation.preserve_failed([], failures,
|
| 79 |
+
self.STORED)
|
| 80 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 81 |
+
|
| 82 |
+
def test_a_clean_run_preserves_nothing(self):
|
| 83 |
+
fresh = [{"act_code": "IRCC-IL3-DELEG", "text": "x"}]
|
| 84 |
+
chunks, preserved = delegation.preserve_failed(fresh, [], self.STORED)
|
| 85 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
unittest.main()
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the NJC directive ingester (canlex/directive.py). Offline only.
|
| 2 |
+
|
| 3 |
+
The ingester is a full rebuild, so anything a run fails to parse disappears from
|
| 4 |
+
directives.json -- the shape of the 2026-07-27 D-memo loss, one directive at a
|
| 5 |
+
time. These cover which directives get their last-good chunks back, and the
|
| 6 |
+
act_code identity that decision hangs on.
|
| 7 |
+
"""
|
| 8 |
+
import unittest
|
| 9 |
+
|
| 10 |
+
from canlex import directive
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 14 |
+
STORED = [{"act_code": "d4", "text": "relocation"},
|
| 15 |
+
{"act_code": "d4", "text": "part two"},
|
| 16 |
+
{"act_code": "fsd-dse", "text": "foreign service"}]
|
| 17 |
+
|
| 18 |
+
def test_unparseable_directive_keeps_its_last_good_chunks(self):
|
| 19 |
+
failures = [("d4", "NJC Relocation Directive", "no content parsed")]
|
| 20 |
+
chunks, preserved = directive.preserve_failed([], failures, self.STORED)
|
| 21 |
+
self.assertEqual(preserved, ["d4"])
|
| 22 |
+
self.assertEqual(len(chunks), 2)
|
| 23 |
+
|
| 24 |
+
def test_freshly_parsed_chunks_win_over_stored(self):
|
| 25 |
+
fresh = [{"act_code": "d4", "text": "new text"}]
|
| 26 |
+
failures = [("d4", "NJC Relocation Directive", "404")]
|
| 27 |
+
chunks, preserved = directive.preserve_failed(fresh, failures,
|
| 28 |
+
self.STORED)
|
| 29 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 30 |
+
|
| 31 |
+
def test_directive_dropped_from_the_index_is_not_resurrected(self):
|
| 32 |
+
# Not in this run and not a failure: the NJC retired it on purpose.
|
| 33 |
+
chunks, preserved = directive.preserve_failed([], [], self.STORED)
|
| 34 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 35 |
+
|
| 36 |
+
def test_directive_we_never_stored_cannot_be_preserved(self):
|
| 37 |
+
failures = [("d99", "Brand New Directive", "404")]
|
| 38 |
+
chunks, preserved = directive.preserve_failed([], failures, self.STORED)
|
| 39 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 40 |
+
|
| 41 |
+
def test_a_retitled_directive_is_still_matched(self):
|
| 42 |
+
# Identity is the URL slug, so an NJC reword of the index title (which
|
| 43 |
+
# is all `failures` used to carry) does not lose the stored chunks.
|
| 44 |
+
failures = [("fsd-dse", "Foreign Service Directives (2026 edition)",
|
| 45 |
+
"no content parsed")]
|
| 46 |
+
chunks, preserved = directive.preserve_failed([], failures, self.STORED)
|
| 47 |
+
self.assertEqual(preserved, ["fsd-dse"])
|
| 48 |
+
self.assertEqual([c["text"] for c in chunks], ["foreign service"])
|
| 49 |
+
|
| 50 |
+
def test_each_failed_directive_is_restored_once(self):
|
| 51 |
+
failures = [("d4", "Relocation", "404"),
|
| 52 |
+
("fsd-dse", "Foreign Service Directives", "404")]
|
| 53 |
+
chunks, preserved = directive.preserve_failed([], failures, self.STORED)
|
| 54 |
+
self.assertEqual(preserved, ["d4", "fsd-dse"])
|
| 55 |
+
self.assertEqual(len(chunks), 3)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class FailureIdentityTests(unittest.TestCase):
|
| 59 |
+
"""The failure tuple's code must be the one parse_directive stamps on
|
| 60 |
+
chunks, or preservation silently matches nothing."""
|
| 61 |
+
|
| 62 |
+
URL = "https://www.njc-cnm.gc.ca/directive/d99/en"
|
| 63 |
+
HTML = ("<main><h2>1.1 Purpose</h2><p>The purpose of this directive.</p>"
|
| 64 |
+
"</main>")
|
| 65 |
+
|
| 66 |
+
def test_parsed_chunks_carry_the_code_from_the_url(self):
|
| 67 |
+
chunks = directive.parse_directive(self.HTML, self.URL, "Test Directive",
|
| 68 |
+
"2026-01-01")
|
| 69 |
+
self.assertTrue(chunks)
|
| 70 |
+
self.assertEqual({c["act_code"] for c in chunks}, {"d99"})
|
| 71 |
+
|
| 72 |
+
def test_the_ingest_loop_derives_the_same_code(self):
|
| 73 |
+
code = directive._CODE.search(self.URL).group(1)
|
| 74 |
+
chunks = directive.parse_directive(self.HTML, self.URL, "Test Directive",
|
| 75 |
+
"")
|
| 76 |
+
stored = [dict(c) for c in chunks]
|
| 77 |
+
_chunks, preserved = directive.preserve_failed(
|
| 78 |
+
[], [(code, "Test Directive", "404")], stored)
|
| 79 |
+
self.assertEqual(preserved, [code])
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
unittest.main()
|
|
@@ -83,20 +83,5 @@ class PreserveFailedTests(unittest.TestCase):
|
|
| 83 |
self.assertEqual((chunks, preserved), (fresh, []))
|
| 84 |
|
| 85 |
|
| 86 |
-
class ShrinkGuardTests(unittest.TestCase):
|
| 87 |
-
def test_refuses_to_replace_a_corpus_with_nothing(self):
|
| 88 |
-
self.assertFalse(dmemo.safe_to_write(0, 1870))
|
| 89 |
-
|
| 90 |
-
def test_refuses_a_large_collapse(self):
|
| 91 |
-
self.assertFalse(dmemo.safe_to_write(500, 1870))
|
| 92 |
-
|
| 93 |
-
def test_allows_normal_churn(self):
|
| 94 |
-
self.assertTrue(dmemo.safe_to_write(1866, 1870))
|
| 95 |
-
self.assertTrue(dmemo.safe_to_write(1900, 1870))
|
| 96 |
-
|
| 97 |
-
def test_first_ever_run_is_allowed(self):
|
| 98 |
-
self.assertTrue(dmemo.safe_to_write(0, 0))
|
| 99 |
-
|
| 100 |
-
|
| 101 |
if __name__ == "__main__":
|
| 102 |
unittest.main()
|
|
|
|
| 83 |
self.assertEqual((chunks, preserved), (fresh, []))
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
if __name__ == "__main__":
|
| 87 |
unittest.main()
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the ENF manual ingester (canlex/enf.py). Offline only.
|
| 2 |
+
|
| 3 |
+
The ingester rebuilds enf.json from a fresh probe of the canada.ca dam
|
| 4 |
+
directory, so a renamed or unreachable chapter silently disappears from the
|
| 5 |
+
corpus -- the failure mode that emptied dmemos.json on 2026-07-27. These cover
|
| 6 |
+
the identity preserve_failed keys on and the failed-set it derives; the write
|
| 7 |
+
guard itself is covered by tests/test_common.py.
|
| 8 |
+
"""
|
| 9 |
+
import subprocess
|
| 10 |
+
import unittest
|
| 11 |
+
import urllib.error
|
| 12 |
+
|
| 13 |
+
from canlex import enf
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _pages(n, headings=7):
|
| 17 |
+
"""A stand-in for pypdf's per-page text: a cover page plus numbered
|
| 18 |
+
headings, enough of them to take the heading-segmentation path."""
|
| 19 |
+
body = "Body text for this section. " * 8
|
| 20 |
+
return [f"ENF {n}\nLast updated: 2024-03-12\n",
|
| 21 |
+
"\n".join(f"{i}. Heading number {i}\n{body}"
|
| 22 |
+
for i in range(1, headings + 1))]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ChapterIdentityTests(unittest.TestCase):
|
| 26 |
+
"""preserve_failed keys on act_code, so the generator must keep emitting
|
| 27 |
+
exactly the identity the failed-set is built from."""
|
| 28 |
+
|
| 29 |
+
def test_every_chunk_of_a_chapter_shares_one_act_code(self):
|
| 30 |
+
chunks = enf._chunks_for(5, _pages(5), "https://x/enf05-eng.pdf")
|
| 31 |
+
self.assertTrue(chunks)
|
| 32 |
+
self.assertEqual({c["act_code"] for c in chunks}, {"ENF-5"})
|
| 33 |
+
|
| 34 |
+
def test_failed_set_matches_the_generated_act_code(self):
|
| 35 |
+
stored = enf._chunks_for(5, _pages(5), "https://x/enf05-eng.pdf")
|
| 36 |
+
chunks, preserved = enf.preserve_failed([], [(5, "fetch failed")],
|
| 37 |
+
stored)
|
| 38 |
+
self.assertEqual(preserved, ["ENF-5"])
|
| 39 |
+
self.assertEqual(len(chunks), len(stored))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 43 |
+
STORED = [{"act_code": "ENF-14", "text": "rehabilitation"},
|
| 44 |
+
{"act_code": "ENF-14", "text": "part two"},
|
| 45 |
+
{"act_code": "ENF-9", "text": "judicial review"}]
|
| 46 |
+
|
| 47 |
+
def test_unfetchable_chapter_keeps_its_last_good_chunks(self):
|
| 48 |
+
chunks, preserved = enf.preserve_failed(
|
| 49 |
+
[], [(14, "fetch failed: HTTPError: 404")], self.STORED)
|
| 50 |
+
self.assertEqual(preserved, ["ENF-14"])
|
| 51 |
+
self.assertEqual([c["text"] for c in chunks],
|
| 52 |
+
["rehabilitation", "part two"])
|
| 53 |
+
|
| 54 |
+
def test_chapter_dropped_on_purpose_is_not_resurrected(self):
|
| 55 |
+
# ENF 9 was not probed this run (removed from _CHAPTERS), so it never
|
| 56 |
+
# failed and must not come back.
|
| 57 |
+
chunks, preserved = enf.preserve_failed(
|
| 58 |
+
[], [(14, "no text layer and no enf14-ocr.txt")], self.STORED)
|
| 59 |
+
self.assertNotIn("ENF-9", preserved)
|
| 60 |
+
self.assertNotIn("judicial review", [c["text"] for c in chunks])
|
| 61 |
+
|
| 62 |
+
def test_freshly_parsed_chunks_win_over_stored(self):
|
| 63 |
+
fresh = [{"act_code": "ENF-14", "text": "new text"}]
|
| 64 |
+
chunks, preserved = enf.preserve_failed(
|
| 65 |
+
fresh, [(14, "parse failed: PdfReadError: x")], self.STORED)
|
| 66 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 67 |
+
|
| 68 |
+
def test_chapter_we_never_had_cannot_be_preserved(self):
|
| 69 |
+
chunks, preserved = enf.preserve_failed([], [(31, "fetch failed")],
|
| 70 |
+
self.STORED)
|
| 71 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 72 |
+
|
| 73 |
+
def test_a_clean_run_preserves_nothing(self):
|
| 74 |
+
fresh = [{"act_code": "ENF-14", "text": "x"}]
|
| 75 |
+
chunks, preserved = enf.preserve_failed(fresh, [], self.STORED)
|
| 76 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 77 |
+
|
| 78 |
+
def test_every_failing_chapter_is_preserved_independently(self):
|
| 79 |
+
stored = self.STORED + [{"act_code": "ENF-3", "text": "hearings"}]
|
| 80 |
+
_chunks, preserved = enf.preserve_failed(
|
| 81 |
+
[], [(3, "fetch failed"), (14, "parse failed")], stored)
|
| 82 |
+
self.assertEqual(preserved, ["ENF-14", "ENF-3"]) # sorted by identity
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class MissingUpstreamTests(unittest.TestCase):
|
| 86 |
+
"""A 404 is this module's retirement signal, not a failure to preserve
|
| 87 |
+
through -- _CHAPTERS is a blind range(1, 41) probe, so most numbers 404 on
|
| 88 |
+
a healthy run and a chapter IRCC withdraws starts 404ing too."""
|
| 89 |
+
|
| 90 |
+
def _http_error(self, code):
|
| 91 |
+
# HTTPError is file-like; close it or the suite emits ResourceWarnings.
|
| 92 |
+
exc = urllib.error.HTTPError("http://x", code, "boom", {}, None)
|
| 93 |
+
self.addCleanup(exc.close)
|
| 94 |
+
return exc
|
| 95 |
+
|
| 96 |
+
def test_http_404_is_a_retirement(self):
|
| 97 |
+
self.assertTrue(enf.is_missing_upstream(self._http_error(404)))
|
| 98 |
+
|
| 99 |
+
def test_other_http_statuses_are_real_failures(self):
|
| 100 |
+
for code in (403, 500, 502, 503):
|
| 101 |
+
self.assertFalse(enf.is_missing_upstream(self._http_error(code)))
|
| 102 |
+
|
| 103 |
+
def test_network_errors_are_real_failures(self):
|
| 104 |
+
self.assertFalse(enf.is_missing_upstream(urllib.error.URLError("dns")))
|
| 105 |
+
self.assertFalse(enf.is_missing_upstream(TimeoutError("timed out")))
|
| 106 |
+
|
| 107 |
+
def test_powershell_404_is_read_off_stderr(self):
|
| 108 |
+
# The canada.ca path shells out, so the status only appears in prose.
|
| 109 |
+
exc = subprocess.CalledProcessError(1, "powershell")
|
| 110 |
+
exc.stderr = ("Invoke-WebRequest : The remote server returned an "
|
| 111 |
+
"error: (404) Not Found.")
|
| 112 |
+
self.assertTrue(enf.is_missing_upstream(exc))
|
| 113 |
+
|
| 114 |
+
def test_powershell_bytes_stderr_is_decoded(self):
|
| 115 |
+
exc = subprocess.CalledProcessError(1, "powershell")
|
| 116 |
+
exc.stderr = b"error: (404) Not Found."
|
| 117 |
+
self.assertTrue(enf.is_missing_upstream(exc))
|
| 118 |
+
|
| 119 |
+
def test_powershell_other_failure_is_preserved_through(self):
|
| 120 |
+
exc = subprocess.CalledProcessError(1, "powershell")
|
| 121 |
+
exc.stderr = "The underlying connection was closed: TLS failure."
|
| 122 |
+
self.assertFalse(enf.is_missing_upstream(exc))
|
| 123 |
+
|
| 124 |
+
def test_no_stderr_at_all_counts_as_a_failure(self):
|
| 125 |
+
# Unknown cause -> keep the stored chapter; losing in-force guidance
|
| 126 |
+
# is the worse error.
|
| 127 |
+
self.assertFalse(enf.is_missing_upstream(RuntimeError("who knows")))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
unittest.main()
|
|
@@ -4,9 +4,13 @@ Offline: builds a tiny Justice Laws-shaped XML document and parses it, so no
|
|
| 4 |
network or real corpus is touched. Focuses on the per-section in-force date and
|
| 5 |
repeal-status metadata.
|
| 6 |
"""
|
|
|
|
|
|
|
|
|
|
| 7 |
import tempfile
|
| 8 |
import unittest
|
| 9 |
from pathlib import Path
|
|
|
|
| 10 |
|
| 11 |
from canlex import ingest
|
| 12 |
|
|
@@ -113,5 +117,56 @@ class RepealStatusTests(unittest.TestCase):
|
|
| 113 |
self.assertEqual(_parse()["25"]["status"], "in force")
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
if __name__ == "__main__":
|
| 117 |
unittest.main()
|
|
|
|
| 4 |
network or real corpus is touched. Focuses on the per-section in-force date and
|
| 5 |
repeal-status metadata.
|
| 6 |
"""
|
| 7 |
+
import contextlib
|
| 8 |
+
import io
|
| 9 |
+
import json
|
| 10 |
import tempfile
|
| 11 |
import unittest
|
| 12 |
from pathlib import Path
|
| 13 |
+
from unittest import mock
|
| 14 |
|
| 15 |
from canlex import ingest
|
| 16 |
|
|
|
|
| 117 |
self.assertEqual(_parse()["25"]["status"], "in force")
|
| 118 |
|
| 119 |
|
| 120 |
+
class WriteGuardTests(unittest.TestCase):
|
| 121 |
+
"""A Justice Laws schema change would parse an Act down to a handful of
|
| 122 |
+
sections; the write must refuse rather than gut the statute. The guard
|
| 123 |
+
itself is covered by tests/test_common.py -- these pin the wiring."""
|
| 124 |
+
|
| 125 |
+
def setUp(self):
|
| 126 |
+
self.tmp = tempfile.TemporaryDirectory()
|
| 127 |
+
self.dir = Path(self.tmp.name)
|
| 128 |
+
self.addCleanup(self.tmp.cleanup)
|
| 129 |
+
|
| 130 |
+
def _run(self, parsed, stored=None, **kwargs):
|
| 131 |
+
target = self.dir / "C-46.json"
|
| 132 |
+
if stored is not None:
|
| 133 |
+
target.write_text(json.dumps(stored), encoding="utf-8")
|
| 134 |
+
with mock.patch.object(ingest, "PROCESSED_DIR", self.dir), \
|
| 135 |
+
mock.patch.object(ingest, "fetch_xml",
|
| 136 |
+
return_value=Path("unused.xml")), \
|
| 137 |
+
mock.patch.object(ingest, "parse_legislation",
|
| 138 |
+
return_value=parsed), \
|
| 139 |
+
contextlib.redirect_stdout(io.StringIO()):
|
| 140 |
+
return ingest.ingest("C-46", **kwargs), target
|
| 141 |
+
|
| 142 |
+
def test_a_collapsed_act_is_refused_and_the_file_survives(self):
|
| 143 |
+
stored = [{"id": str(i)} for i in range(2184)]
|
| 144 |
+
with self.assertRaises(RuntimeError):
|
| 145 |
+
self._run([{"id": "only-one"}], stored=stored)
|
| 146 |
+
kept = json.loads((self.dir / "C-46.json").read_text(encoding="utf-8"))
|
| 147 |
+
self.assertEqual(len(kept), 2184)
|
| 148 |
+
|
| 149 |
+
def test_allow_shrink_lets_a_understood_drop_through(self):
|
| 150 |
+
self._run([{"id": "a"}], stored=[{"id": str(i)} for i in range(100)],
|
| 151 |
+
allow_shrink=True)
|
| 152 |
+
kept = json.loads((self.dir / "C-46.json").read_text(encoding="utf-8"))
|
| 153 |
+
self.assertEqual(len(kept), 1)
|
| 154 |
+
|
| 155 |
+
def test_a_normal_run_writes(self):
|
| 156 |
+
chunks = [{"id": str(i)} for i in range(2184)]
|
| 157 |
+
_result, target = self._run(chunks, stored=chunks)
|
| 158 |
+
self.assertEqual(len(json.loads(target.read_text(encoding="utf-8"))),
|
| 159 |
+
2184)
|
| 160 |
+
|
| 161 |
+
def test_first_ever_ingest_writes(self):
|
| 162 |
+
_result, target = self._run([{"id": "a"}])
|
| 163 |
+
self.assertTrue(target.exists())
|
| 164 |
+
|
| 165 |
+
def test_refusal_leaves_no_temp_file_behind(self):
|
| 166 |
+
with self.assertRaises(RuntimeError):
|
| 167 |
+
self._run([{"id": "a"}], stored=[{"id": str(i)} for i in range(100)])
|
| 168 |
+
self.assertEqual(list(self.dir.glob("*.tmp")), [])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
if __name__ == "__main__":
|
| 172 |
unittest.main()
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the IRB Chairperson's Guidelines ingester. Offline only.
|
| 2 |
+
|
| 3 |
+
build() rebuilds irb_guidelines.json from a fresh fetch of three canada.ca
|
| 4 |
+
pages, so a template change or a dropped connection would delete a guideline
|
| 5 |
+
outright -- the failure mode that emptied dmemos.json on 2026-07-27. The write
|
| 6 |
+
guard itself is covered by tests/test_common.py; these cover this module's
|
| 7 |
+
identity and failed-set.
|
| 8 |
+
"""
|
| 9 |
+
import unittest
|
| 10 |
+
|
| 11 |
+
from canlex import _common, irb_guidelines
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class GuidelineIdentityTests(unittest.TestCase):
|
| 15 |
+
STORED = [{"act_code": "IRB-G4", "text": "gender considerations"},
|
| 16 |
+
{"act_code": "IRB-G4", "text": "part two"},
|
| 17 |
+
{"act_code": "IRB-G8", "text": "vulnerable persons"}]
|
| 18 |
+
|
| 19 |
+
def _preserve(self, chunks, failed):
|
| 20 |
+
return _common.preserve_dropped(chunks, self.STORED,
|
| 21 |
+
key=lambda c: c["act_code"],
|
| 22 |
+
only=failed)
|
| 23 |
+
|
| 24 |
+
def test_a_failed_guideline_keeps_its_stored_chunks(self):
|
| 25 |
+
chunks, preserved = self._preserve([], {"IRB-G4"})
|
| 26 |
+
self.assertEqual(preserved, ["IRB-G4"])
|
| 27 |
+
self.assertEqual(len(chunks), 2)
|
| 28 |
+
|
| 29 |
+
def test_a_guideline_that_succeeded_is_not_resurrected(self):
|
| 30 |
+
chunks, preserved = self._preserve(
|
| 31 |
+
[{"act_code": "IRB-G4", "text": "fresh"}], {"IRB-G4"})
|
| 32 |
+
self.assertEqual(preserved, [])
|
| 33 |
+
self.assertEqual(len(chunks), 1)
|
| 34 |
+
|
| 35 |
+
def test_a_guideline_dropped_on_purpose_stays_gone(self):
|
| 36 |
+
# G8 is absent from this run and never failed -- it left GUIDELINES.
|
| 37 |
+
_chunks, preserved = self._preserve([], {"IRB-G4"})
|
| 38 |
+
self.assertNotIn("IRB-G8", preserved)
|
| 39 |
+
|
| 40 |
+
def test_act_code_matches_what_build_emits(self):
|
| 41 |
+
# Pins the identity: the failed-set is built from g["num"], so the
|
| 42 |
+
# two must agree or preservation silently never matches.
|
| 43 |
+
for g in irb_guidelines.GUIDELINES:
|
| 44 |
+
self.assertRegex(f"IRB-G{g['num']}", r"^IRB-G\w+$")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the IRCC PDI ingester (canlex/pdi.py). Offline only.
|
| 2 |
+
|
| 3 |
+
Covers the two ways this ingester can quietly lose guidance: a sub-page that
|
| 4 |
+
fails to fetch disappearing from a full rebuild, and an upstream page whose
|
| 5 |
+
markup changes shape parsing to nothing -- the 2026-07-27 D-memo failure mode,
|
| 6 |
+
which here is caught by the corpus-write guard rather than by preservation.
|
| 7 |
+
"""
|
| 8 |
+
import unittest
|
| 9 |
+
|
| 10 |
+
from canlex import pdi
|
| 11 |
+
|
| 12 |
+
PRCARD = ("https://www.canada.ca/en/immigration-refugees-citizenship/corporate/"
|
| 13 |
+
"publications-manuals/operational-bulletins-manuals/"
|
| 14 |
+
"permanent-residence/card/apply.html")
|
| 15 |
+
RENEW = PRCARD.replace("apply.html", "renew.html")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _chunk(url, text, chunk_id="pdi-prcard-1-0"):
|
| 19 |
+
return {"id": chunk_id, "source_url": url, "text": text}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 23 |
+
STORED = [_chunk(PRCARD, "how to apply", "pdi-prcard-1-0"),
|
| 24 |
+
_chunk(PRCARD, "supporting documents", "pdi-prcard-1-1"),
|
| 25 |
+
_chunk(RENEW, "how to renew", "pdi-prcard-2-0")]
|
| 26 |
+
|
| 27 |
+
def test_unfetchable_page_keeps_its_last_good_chunks(self):
|
| 28 |
+
chunks, preserved = pdi.preserve_failed(
|
| 29 |
+
[], [(PRCARD, "HTTPError: 404")], self.STORED)
|
| 30 |
+
self.assertEqual(preserved, [PRCARD])
|
| 31 |
+
self.assertEqual([c["text"] for c in chunks],
|
| 32 |
+
["how to apply", "supporting documents"])
|
| 33 |
+
|
| 34 |
+
def test_page_dropped_from_the_index_is_not_resurrected(self):
|
| 35 |
+
# RENEW is gone from this run but never errored -- IRCC retired it, and
|
| 36 |
+
# retired instructions must not come back as if they were current.
|
| 37 |
+
fresh = [_chunk(PRCARD, "how to apply")]
|
| 38 |
+
chunks, preserved = pdi.preserve_failed(fresh, [], self.STORED)
|
| 39 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 40 |
+
|
| 41 |
+
def test_freshly_scraped_page_wins_over_the_stored_copy(self):
|
| 42 |
+
fresh = [_chunk(PRCARD, "how to apply (2026 wording)")]
|
| 43 |
+
chunks, preserved = pdi.preserve_failed(
|
| 44 |
+
fresh, [(PRCARD, "HTTPError: 404")], self.STORED)
|
| 45 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 46 |
+
|
| 47 |
+
def test_page_we_never_stored_cannot_be_preserved(self):
|
| 48 |
+
new_url = PRCARD.replace("apply.html", "replace.html")
|
| 49 |
+
chunks, preserved = pdi.preserve_failed(
|
| 50 |
+
[], [(new_url, "TimeoutError: ")], self.STORED)
|
| 51 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 52 |
+
|
| 53 |
+
def test_failed_identity_is_the_page_url_not_the_error_text(self):
|
| 54 |
+
# failures are (url, why) pairs; only the url identifies the page.
|
| 55 |
+
chunks, preserved = pdi.preserve_failed(
|
| 56 |
+
[], [(PRCARD, PRCARD)], self.STORED)
|
| 57 |
+
self.assertEqual(preserved, [PRCARD])
|
| 58 |
+
self.assertEqual(len(chunks), 2)
|
| 59 |
+
|
| 60 |
+
def test_first_ever_run_has_nothing_to_preserve(self):
|
| 61 |
+
fresh = [_chunk(PRCARD, "how to apply")]
|
| 62 |
+
chunks, preserved = pdi.preserve_failed(
|
| 63 |
+
fresh, [(RENEW, "HTTPError: 404")], [])
|
| 64 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class PreservedIdCollisionTests(unittest.TestCase):
|
| 68 |
+
def test_preserved_ids_are_uniquified_against_this_run(self):
|
| 69 |
+
# A chunk id embeds the page's position in the index listing, so when a
|
| 70 |
+
# page drops out the ids shift and a preserved chunk can collide with a
|
| 71 |
+
# live one. Duplicate ids orphan chunks in the embeddings loader.
|
| 72 |
+
stored = [_chunk(RENEW, "how to renew", "pdi-prcard-2-0")]
|
| 73 |
+
fresh = [_chunk(PRCARD, "how to apply", "pdi-prcard-2-0")]
|
| 74 |
+
chunks, _preserved = pdi.preserve_failed(
|
| 75 |
+
fresh, [(RENEW, "HTTPError: 404")], stored)
|
| 76 |
+
pdi.uniquify_ids(chunks)
|
| 77 |
+
self.assertEqual(len({c["id"] for c in chunks}), len(chunks))
|
| 78 |
+
self.assertEqual(chunks[0]["id"], "pdi-prcard-2-0")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class PageChunkTests(unittest.TestCase):
|
| 82 |
+
PAGE = ("<main><h1>Permanent resident card</h1>"
|
| 83 |
+
"<time property='dateModified'>2026-05-04</time>"
|
| 84 |
+
"<h2>Eligibility</h2><p>" + "An applicant must be a PR. " * 8 +
|
| 85 |
+
"</p><h2>Fees</h2><p>" + "The fee is fifty dollars. " * 8 +
|
| 86 |
+
"</p></main>")
|
| 87 |
+
SRC = {"code": "pdi-prcard", "short": "PDI PR Card", "name": "PDI — PR card",
|
| 88 |
+
"index": "https://x/card.html", "scope": "/card"}
|
| 89 |
+
|
| 90 |
+
def test_headings_become_chunks_tagged_with_their_page(self):
|
| 91 |
+
chunks = pdi._page_chunks(self.SRC, "https://x/card.html", self.PAGE, 0)
|
| 92 |
+
self.assertEqual([c["marginal_note"] for c in chunks],
|
| 93 |
+
["Eligibility", "Fees"])
|
| 94 |
+
self.assertEqual({c["source_url"] for c in chunks},
|
| 95 |
+
{"https://x/card.html"})
|
| 96 |
+
self.assertTrue(all(c["doc_type"] == "memorandum" for c in chunks))
|
| 97 |
+
|
| 98 |
+
def test_reshaped_page_yields_nothing_to_write(self):
|
| 99 |
+
# What canada.ca would serve if the instructions moved behind a script:
|
| 100 |
+
# the parse succeeds and produces zero chunks, which is why the write
|
| 101 |
+
# has to be guarded rather than trusted.
|
| 102 |
+
reshaped = "<main><h1>Permanent resident card</h1><div id='app'></div></main>"
|
| 103 |
+
self.assertEqual(
|
| 104 |
+
pdi._page_chunks(self.SRC, "https://x/card.html", reshaped, 0), [])
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
unittest.main()
|
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the Customs Tariff Schedule ingester. Offline only.
|
| 2 |
+
|
| 3 |
+
The corpus is two chapters (98 and 99), so a single chapter that fetches badly
|
| 4 |
+
or parses to nothing is a 30-70% collapse -- the same shape of failure that
|
| 5 |
+
emptied dmemos.json on 2026-07-27. These cover how the module decides a chapter
|
| 6 |
+
failed, how it hangs on to the last-good copy of one that did, and that a
|
| 7 |
+
refused write reaches the shell as a non-zero exit.
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
import unittest
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from unittest import mock
|
| 15 |
+
|
| 16 |
+
from canlex import tariff_schedule as ts
|
| 17 |
+
|
| 18 |
+
CH98_HTML = ("<main><h2>Notes</h2><p>1. This Chapter applies.</p>"
|
| 19 |
+
"<table><tr><th>Tariff Item</th></tr>"
|
| 20 |
+
"<tr><td>98.01</td><td></td><td>Conveyances</td></tr>"
|
| 21 |
+
"<tr><td>9801.10.10</td><td>10</td><td>Vehicles</td>"
|
| 22 |
+
"<td>-</td><td>Free</td><td>Free</td></tr>"
|
| 23 |
+
"</table></main>")
|
| 24 |
+
CH99_HTML = ("<main><table><tr><th>Tariff Item</th></tr>"
|
| 25 |
+
"<tr><td>99.01</td><td></td><td>Temporary importations</td></tr>"
|
| 26 |
+
"<tr><td>9901.00.00</td><td></td><td>Fishing equipment</td>"
|
| 27 |
+
"<td>-</td><td>Free</td><td></td></tr>"
|
| 28 |
+
"</table></main>")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ChapterIdentityTests(unittest.TestCase):
|
| 32 |
+
def test_every_chunk_of_a_chapter_shares_one_identity(self):
|
| 33 |
+
# Nothing else on a chunk names its chapter, so preserve_failed keys on
|
| 34 |
+
# `part`; the Notes chunk and the heading chunks must agree on it.
|
| 35 |
+
chunks = ts.parse_chapter(CH98_HTML, ts.SOURCES["ch98"])
|
| 36 |
+
self.assertEqual({c["part"] for c in chunks}, {ts._chapter_part("98")})
|
| 37 |
+
self.assertEqual(len(chunks), 2) # Notes + heading 98.01
|
| 38 |
+
|
| 39 |
+
def test_part_matches_what_the_stored_corpus_carries(self):
|
| 40 |
+
self.assertEqual(ts._chapter_part("99"), "Schedule, Chapter 99")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ParseCollapseTests(unittest.TestCase):
|
| 44 |
+
"""A chapter that parses to nothing is the reshaped-page signature."""
|
| 45 |
+
|
| 46 |
+
def test_page_without_main_yields_nothing(self):
|
| 47 |
+
self.assertEqual(ts.parse_chapter("<div><table></table></div>",
|
| 48 |
+
ts.SOURCES["ch98"]), [])
|
| 49 |
+
|
| 50 |
+
def test_js_rendered_table_yields_nothing(self):
|
| 51 |
+
# An empty table whose rows arrive by script -- what CBSA did to the
|
| 52 |
+
# D-memo index; the ingester must not read it as a valid chapter.
|
| 53 |
+
self.assertEqual(
|
| 54 |
+
ts.parse_chapter('<main><table id="tariff"></table></main>',
|
| 55 |
+
ts.SOURCES["ch99"]), [])
|
| 56 |
+
|
| 57 |
+
def test_notes_survive_a_missing_table(self):
|
| 58 |
+
# A partial parse is not a failure -- the shrink guard, not
|
| 59 |
+
# preserve_failed, is what catches those.
|
| 60 |
+
chunks = ts.parse_chapter("<main><h2>Notes</h2><p>1. Applies.</p></main>",
|
| 61 |
+
ts.SOURCES["ch99"])
|
| 62 |
+
self.assertEqual([c["section"] for c in chunks], ["Sch-Ch99-Notes"])
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class PreserveFailedTests(unittest.TestCase):
|
| 66 |
+
STORED = [{"part": "Schedule, Chapter 98", "section": "Sch-Ch98-Notes"},
|
| 67 |
+
{"part": "Schedule, Chapter 98", "section": "Sch-98.01"},
|
| 68 |
+
{"part": "Schedule, Chapter 99", "section": "Sch-99.01"}]
|
| 69 |
+
|
| 70 |
+
def test_failed_chapter_keeps_its_last_good_chunks(self):
|
| 71 |
+
chunks, preserved = ts.preserve_failed(
|
| 72 |
+
[{"part": "Schedule, Chapter 99", "section": "Sch-99.01"}],
|
| 73 |
+
[("98", "HTTPError: 404")], self.STORED)
|
| 74 |
+
self.assertEqual(preserved, ["Schedule, Chapter 98"])
|
| 75 |
+
self.assertEqual(len(chunks), 3)
|
| 76 |
+
|
| 77 |
+
def test_empty_parse_is_a_failure_and_is_preserved(self):
|
| 78 |
+
# The reason string is never read; only the chapter identity is.
|
| 79 |
+
chunks, preserved = ts.preserve_failed([], [("99", "no chunks parsed")],
|
| 80 |
+
self.STORED)
|
| 81 |
+
self.assertEqual(preserved, ["Schedule, Chapter 99"])
|
| 82 |
+
self.assertEqual([c["section"] for c in chunks], ["Sch-99.01"])
|
| 83 |
+
|
| 84 |
+
def test_chapter_that_did_not_fail_is_not_resurrected(self):
|
| 85 |
+
# Chapter 98 simply is not in this run's output and did not error, so
|
| 86 |
+
# whatever it used to hold stays gone.
|
| 87 |
+
chunks, preserved = ts.preserve_failed(
|
| 88 |
+
[{"part": "Schedule, Chapter 99", "section": "Sch-99.01"}],
|
| 89 |
+
[], self.STORED)
|
| 90 |
+
self.assertEqual(preserved, [])
|
| 91 |
+
self.assertEqual(len(chunks), 1)
|
| 92 |
+
|
| 93 |
+
def test_fresh_chunks_win_over_the_stored_copy(self):
|
| 94 |
+
fresh = [{"part": "Schedule, Chapter 98", "section": "Sch-98.02"}]
|
| 95 |
+
chunks, preserved = ts.preserve_failed(fresh, [("98", "boom")],
|
| 96 |
+
self.STORED)
|
| 97 |
+
self.assertEqual((chunks, preserved), (fresh, []))
|
| 98 |
+
|
| 99 |
+
def test_first_ever_run_has_nothing_to_preserve(self):
|
| 100 |
+
chunks, preserved = ts.preserve_failed([], [("98", "boom")], [])
|
| 101 |
+
self.assertEqual((chunks, preserved), ([], []))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class BuildTests(unittest.TestCase):
|
| 105 |
+
"""build() end to end with the network stubbed out."""
|
| 106 |
+
|
| 107 |
+
def setUp(self):
|
| 108 |
+
tmp = tempfile.TemporaryDirectory()
|
| 109 |
+
self.addCleanup(tmp.cleanup)
|
| 110 |
+
self.out = Path(tmp.name) / "tariff_schedule.json"
|
| 111 |
+
patch = mock.patch.object(ts, "OUT", self.out)
|
| 112 |
+
patch.start()
|
| 113 |
+
self.addCleanup(patch.stop)
|
| 114 |
+
|
| 115 |
+
def _fetch_returning(self, pages):
|
| 116 |
+
"""Stub _fetch: url -> canned HTML, or an exception to raise."""
|
| 117 |
+
def fetch(url, dest):
|
| 118 |
+
page = pages[url]
|
| 119 |
+
if isinstance(page, Exception):
|
| 120 |
+
raise page
|
| 121 |
+
return page
|
| 122 |
+
return fetch
|
| 123 |
+
|
| 124 |
+
def _pages(self, ch98=CH98_HTML, ch99=CH99_HTML):
|
| 125 |
+
return {ts.SOURCES["ch98"]["url"]: ch98,
|
| 126 |
+
ts.SOURCES["ch99"]["url"]: ch99}
|
| 127 |
+
|
| 128 |
+
def _build(self, pages, **kwargs):
|
| 129 |
+
with mock.patch.object(ts, "_fetch", self._fetch_returning(pages)), \
|
| 130 |
+
mock.patch("builtins.print"):
|
| 131 |
+
return ts.build(**kwargs)
|
| 132 |
+
|
| 133 |
+
def _stored(self):
|
| 134 |
+
return json.loads(self.out.read_text(encoding="utf-8"))
|
| 135 |
+
|
| 136 |
+
def test_first_run_writes_with_the_existing_indent(self):
|
| 137 |
+
self.assertTrue(self._build(self._pages()))
|
| 138 |
+
text = self.out.read_text(encoding="utf-8")
|
| 139 |
+
# indent=1: changing it would rewrite every line of the data file.
|
| 140 |
+
self.assertTrue(text.startswith("[\n {\n"), text[:20])
|
| 141 |
+
self.assertEqual(len(self._stored()), 3) # ch98 Notes + 98.01 + 99.01
|
| 142 |
+
|
| 143 |
+
def test_unreachable_chapter_falls_back_to_the_stored_copy(self):
|
| 144 |
+
self.assertTrue(self._build(self._pages()))
|
| 145 |
+
ok = self._build(self._pages(ch98=OSError("connection reset")))
|
| 146 |
+
self.assertTrue(ok)
|
| 147 |
+
self.assertEqual(len(self._stored()), 3)
|
| 148 |
+
self.assertIn("Schedule, Chapter 98",
|
| 149 |
+
{c["part"] for c in self._stored()})
|
| 150 |
+
|
| 151 |
+
def test_reshaped_chapter_falls_back_to_the_stored_copy(self):
|
| 152 |
+
self.assertTrue(self._build(self._pages()))
|
| 153 |
+
self.assertTrue(self._build(self._pages(ch99="<main></main>")))
|
| 154 |
+
self.assertEqual(len(self._stored()), 3)
|
| 155 |
+
|
| 156 |
+
def test_collapse_that_cannot_be_preserved_is_refused(self):
|
| 157 |
+
# A chapter that still parses, just to far fewer chunks: no failure to
|
| 158 |
+
# preserve, so the shrink guard is the only thing standing in the way.
|
| 159 |
+
self.out.write_text(json.dumps(
|
| 160 |
+
[{"part": "Schedule, Chapter 98", "id": f"old-{i}"}
|
| 161 |
+
for i in range(20)] +
|
| 162 |
+
[{"part": "Schedule, Chapter 99", "id": f"old9-{i}"}
|
| 163 |
+
for i in range(20)]), encoding="utf-8")
|
| 164 |
+
self.assertFalse(self._build(self._pages()))
|
| 165 |
+
self.assertEqual(len(self._stored()), 40) # untouched
|
| 166 |
+
|
| 167 |
+
def test_allow_shrink_lets_a_reviewed_collapse_through(self):
|
| 168 |
+
self.out.write_text(json.dumps([{"part": "Schedule, Chapter 98",
|
| 169 |
+
"id": f"old-{i}"} for i in range(40)]),
|
| 170 |
+
encoding="utf-8")
|
| 171 |
+
self.assertTrue(self._build(self._pages(), allow_shrink=True))
|
| 172 |
+
self.assertEqual(len(self._stored()), 3)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class MainTests(unittest.TestCase):
|
| 176 |
+
def _main_with(self, argv, result):
|
| 177 |
+
calls = []
|
| 178 |
+
|
| 179 |
+
def stub(allow_shrink=False):
|
| 180 |
+
calls.append(allow_shrink)
|
| 181 |
+
return result
|
| 182 |
+
|
| 183 |
+
saved_argv, sys.argv = sys.argv, argv
|
| 184 |
+
with mock.patch.object(ts, "build", stub):
|
| 185 |
+
try:
|
| 186 |
+
with self.assertRaises(SystemExit) as exit_info:
|
| 187 |
+
ts.main()
|
| 188 |
+
finally:
|
| 189 |
+
sys.argv = saved_argv
|
| 190 |
+
return calls, exit_info.exception.code
|
| 191 |
+
|
| 192 |
+
def test_guard_is_on_unless_asked_otherwise(self):
|
| 193 |
+
calls, code = self._main_with(["prog"], True)
|
| 194 |
+
self.assertEqual((calls, code), ([False], 0))
|
| 195 |
+
|
| 196 |
+
def test_allow_shrink_flag_reaches_build(self):
|
| 197 |
+
calls, code = self._main_with(["prog", "--allow-shrink"], True)
|
| 198 |
+
self.assertEqual((calls, code), ([True], 0))
|
| 199 |
+
|
| 200 |
+
def test_refused_write_exits_non_zero(self):
|
| 201 |
+
_calls, code = self._main_with(["prog"], False)
|
| 202 |
+
self.assertEqual(code, 1)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
unittest.main()
|