#!/usr/bin/env python3 """Derive the C-44 amendment graph from the official S.C. 2017, c. 20 table of provisions.""" import re, html, json, sys SRC = "https://laws-lois.justice.gc.ca/eng/AnnualStatutes/2017_20/page-1.html" def lines_from(path): t = open(path, encoding="utf-8", errors="replace").read() t = re.sub(r"<[^>]+>", "\n", t) t = html.unescape(t) return [l.strip() for l in t.split("\n") if l.strip()] SECTION = re.compile(r"^(\d+(?:\.\d+)?)\s*-$") PART = re.compile(r"^PART (\d+)$") DIVISION = re.compile(r"^DIVISION (\d+)$") # structural headings that are not Act names HEADING = re.compile( r"^(Amendments? to the( Act)?|Enactment of( Act)?|Consequential(and Related)?" r"|Consequential Amendments?|Consequential and Related Amendments?|Related Amendments? to the" r"|Transitional Provisions?|Coming into Force|Coordinating Amendments?|Coordinating Amendment" r"|Terminology( Changes)?|Application|Repeal|Short Title|Various Measures|Schedule.*|-)$" ) def parse(path): L = lines_from(path) # window: the table of provisions for the Act itself try: start = next(i for i, l in enumerate(L) if l == "Budget Implementation Act, 2017, No. 1" and i > 400) except StopIteration: sys.exit("could not locate table of provisions") end = next(i for i, l in enumerate(L) if l == "SCHEDULE 1" and i > start) W = L[start:end] out = [] part = division = None part_title = division_title = None mode = None # 'enact' | 'amend' | None pending = [] # accumulating a multi-line Act name last_section = None def flush(section): nonlocal pending if not pending: return name = " ".join(pending).strip() name = re.sub(r"\s+,", ",", name) name = re.sub(r"\s{2,}", " ", name).strip(" ,") pending = [] if not name or not re.search(r"(Act|Code|Regulations)\b", name): return out.append({ "act": name, "relation": "enacted" if mode == "enact" else "amended", "part": part, "part_title": part_title, "division": division, "division_title": division_title, "first_section": section, }) i = 0 while i < len(W): l = W[i] if PART.match(l): flush(last_section) part = int(PART.match(l).group(1)); part_title = None; division = division_title = None mode = None # part title = following non-structural lines until a section marker j = i + 1; buf = [] while j < len(W) and not SECTION.match(W[j]): if W[j] != "-" and not HEADING.match(W[j]): buf.append(W[j]) elif HEADING.match(W[j]) and re.match(r"^Amendments? to the", W[j]): mode = "amend" j += 1 part_title = " ".join(buf).strip() i = j; continue if DIVISION.match(l): flush(last_section) division = int(DIVISION.match(l).group(1)); mode = None j = i + 1; buf = [] while j < len(W) and not SECTION.match(W[j]): if W[j] != "-": buf.append(W[j]) j += 1 division_title = " ".join(buf).strip() i = j; continue if SECTION.match(l): last_section = SECTION.match(l).group(1) flush(last_section) i += 1; continue if HEADING.match(l): flush(last_section) if re.match(r"^Enactment of", l): mode = "enact" elif re.match(r"^(Amendments? to the|Related Amendments? to the|Consequential)", l): mode = "amend" # a heading can carry the Act name on the same line: "Amendments to the Act" -> generic i += 1; continue pending.append(l) i += 1 flush(last_section) # Division-level Act names (e.g. DIVISION 18 - Canada Infrastructure Bank Act) are the # division title; capture those as the division's subject Act. divisions = {} part = None for i, l in enumerate(W): if DIVISION.match(l): n = int(DIVISION.match(l).group(1)) j = i + 1; buf = [] while j < len(W) and not SECTION.match(W[j]): if W[j] != "-": buf.append(W[j]) j += 1 divisions[n] = " ".join(buf).strip() return out, divisions, SRC def patch_enacted_divisions(acts, divisions, path): """A division whose only heading is the generic "Enactment of Act" carries the enacted Act's name in the DIVISION title itself (e.g. DIVISION 18 - Canada Infrastructure Bank Act), so it is never picked up as a following line.""" L = lines_from(path) have = {(a["act"], a["division"]) for a in acts} for n, title in divisions.items(): n = int(n) if not re.search(r"\bAct$", title or ""): continue for i, l in enumerate(L): if l != f"DIVISION {n}": continue window = L[i:i + 12] if "Enactment of Act" in window and (title, n) not in have: sec = next((x[:-1].strip() for x in window if re.match(r"^\d+(\.\d+)?\s*-$", x)), None) acts.append({"act": title, "relation": "enacted", "part": 4, "part_title": "Various Measures", "division": n, "division_title": title, "first_section": sec}) have.add((title, n)) break return acts if __name__ == "__main__": acts, divisions, src = parse(sys.argv[1]) acts = patch_enacted_divisions(acts, divisions, sys.argv[1]) # dedupe, keep first occurrence and merge relation (enacted wins) seen = {} for a in acts: k = (a["act"], a["part"], a["division"]) if k not in seen: seen[k] = a elif a["relation"] == "enacted": seen[k] = a acts = list(seen.values()) acts.sort(key=lambda a: (a["part"] or 0, a["division"] or 0, a["act"])) json.dump({"source": src, "divisions": divisions, "acts": acts}, open(sys.argv[2], "w"), indent=1, ensure_ascii=False) print(f"{len(acts)} act references across {len(divisions)} divisions -> {sys.argv[2]}") enacted = [a["act"] for a in acts if a["relation"] == "enacted"] print("ENACTED:", enacted)