Beemer Claude Opus 5 (1M context) commited on
Commit
6ce7899
·
1 Parent(s): 43ab322

Guard every ingester against the corpus-wipe failure mode

Browse files

Every 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 CHANGED
@@ -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
- The ingester also refuses to write a corpus smaller than 80% of the stored one
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 recovered from git. Any ingester that rebuilds a whole file from
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
 
canlex/_common.py CHANGED
@@ -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
 
canlex/agreement.py CHANGED
@@ -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, split_lines
 
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 main():
146
- force = "--force" in sys.argv
147
- all_chunks = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
158
- OUT_FILE.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
159
- encoding="utf-8")
 
 
 
 
 
 
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__":
canlex/amps.py CHANGED
@@ -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 build(limit=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
114
- encoding="utf-8")
115
- print(f"amps: {len(chunks)} contraventions -> {out.name}"
116
- + (f" ({len(failed)} failed: {failed[:5]})" if failed else ""))
117
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
  if __name__ == "__main__":
121
- build()
 
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()
canlex/benefits.py CHANGED
@@ -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 build():
286
- all_chunks = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
303
- OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
304
- encoding="utf-8")
 
 
 
 
 
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
- build()
 
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()
canlex/caselaw.py CHANGED
@@ -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
- all_chunks = []
 
 
 
 
 
 
 
 
 
 
 
 
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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
907
- OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
908
- encoding="utf-8")
 
 
 
 
 
 
 
 
 
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
- build()
 
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()
canlex/charter.py CHANGED
@@ -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 build():
55
- html = fetch_cached(URL, RAW / "FullText.html").decode("utf-8", "replace")
 
 
 
 
 
 
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
- build()
 
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()
canlex/commentary.py CHANGED
@@ -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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
253
- OUT.write_text(json.dumps(chunks, ensure_ascii=False, indent=1),
254
- encoding="utf-8")
 
 
 
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
- build()
 
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()
canlex/delegation.py CHANGED
@@ -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, uniquify_ids
 
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 build():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
470
- encoding="utf-8")
 
 
 
 
 
 
471
  print(f"\n{len(all_chunks)} delegation chunks from {len(SOURCES)} "
472
  f"instrument(s) -> {OUT}")
 
 
 
 
 
473
 
474
 
475
  if __name__ == "__main__":
476
- build()
 
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()
canlex/directive.py CHANGED
@@ -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, split_lines
 
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 main():
164
- force = "--force" in sys.argv
165
- limit = next((int(a.split("=", 1)[1]) for a in sys.argv[1:]
166
- if a.startswith("--limit=")), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
194
- OUT_FILE.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
195
- encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
196
  print(f"\n{len(all_chunks)} chunks from {len(directives) - len(failures)} "
197
  f"directives -> {OUT_FILE.name}")
198
- for title, why in failures:
199
- print(f" FAILED {title}: {why}")
 
 
 
 
 
 
 
 
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__":
canlex/dmemo.py CHANGED
@@ -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, split_lines
 
 
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. Cancelled memos never reach here: they are filtered
274
- out before fetching. Returns (chunks, preserved memo numbers).
 
275
  """
276
- have = {c["section"].upper() for c in chunks}
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
- number = match.group(1).upper() if match else ""
284
- if number and number not in have and number in by_memo:
285
- have.add(number)
286
- preserved.append(number)
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, stored)
 
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 allow_shrink and not safe_to_write(len(all_chunks), len(stored)):
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
canlex/enf.py CHANGED
@@ -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, split_lines, uniquify_ids
 
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
- def build(chapters=_CHAPTERS):
173
- all_chunks, skipped = [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
218
- encoding="utf-8")
 
 
 
 
 
 
 
 
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
- build()
 
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()
canlex/ingest.py CHANGED
@@ -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
- out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
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
- out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
446
- encoding="utf-8")
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__":
canlex/irb_guidelines.py CHANGED
@@ -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, split_lines, uniquify_ids
 
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
- out.write_text(json.dumps(chunks, ensure_ascii=False, indent=2),
122
- encoding="utf-8")
 
 
 
 
 
 
 
123
  print(f"irb_guidelines: {len(chunks)} chunks -> {out.name}")
124
  return chunks
125
 
126
 
 
 
 
 
 
 
 
127
  if __name__ == "__main__":
128
- build()
 
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()
canlex/pdi.py CHANGED
@@ -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, split_lines, uniquify_ids
 
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 build():
102
- all_chunks = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- out = PROCESSED_DIR / "pdi.json"
134
- out.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=2),
135
- encoding="utf-8")
136
- print(f"pdi: {len(all_chunks)} chunks -> {out.name}")
137
  return all_chunks
138
 
139
 
 
 
 
 
 
 
 
140
  if __name__ == "__main__":
141
- build()
 
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()
canlex/tariff_schedule.py CHANGED
@@ -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": f"Schedule, Chapter {chapter}",
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": f"Schedule, Chapter {chapter}",
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 build():
225
- all_chunks = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
237
- OUT.write_text(json.dumps(all_chunks, ensure_ascii=False, indent=1),
238
- encoding="utf-8")
239
- print(f"\n{len(all_chunks)} tariff-schedule chunks from {len(SOURCES)} "
240
- f"chapter(s) -> {OUT}")
 
 
 
 
 
 
 
 
 
 
241
 
242
 
243
  if __name__ == "__main__":
244
- build()
 
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()
tests/test_agreement.py ADDED
@@ -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()
tests/test_amps.py ADDED
@@ -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()
tests/test_benefits.py CHANGED
@@ -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()
tests/test_caselaw.py ADDED
@@ -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()
tests/test_charter.py ADDED
@@ -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()
tests/test_commentary.py CHANGED
@@ -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()
tests/test_common.py ADDED
@@ -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()
tests/test_delegation.py ADDED
@@ -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()
tests/test_directive.py ADDED
@@ -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()
tests/test_dmemo.py CHANGED
@@ -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()
tests/test_enf.py ADDED
@@ -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()
tests/test_ingest.py CHANGED
@@ -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()
tests/test_irb_guidelines.py ADDED
@@ -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()
tests/test_pdi.py ADDED
@@ -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()
tests/test_tariff_schedule.py ADDED
@@ -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()