= limit:
break
return results
def web_search(
*,
query: str | None,
legislation_type: str | None,
search_field: str,
bill_status: str | None,
sort_by: str | None,
limit: int,
timeout: int,
) -> dict[str, Any]:
params: dict[str, str] = {
"per_page": str(clamp_limit(limit)),
"search_field": search_field,
}
if query:
params["search_term"] = query
if legislation_type:
params["legislation_type"] = legislation_type
if legislation_type == "bill":
params["search_for"] = "bill"
if bill_status:
params["bill_status"] = bill_status
if sort_by:
params["sort_by"] = sort_by
url = BASE_URL + "/items/?" + urllib.parse.urlencode(params)
html_text, final_url = fetch_text(url, timeout=timeout)
return {
"source": "legislation.govt.nz web search",
"source_url": final_url,
"total": parse_total(html_text),
"results": parse_search_cards(html_text, limit=limit),
}
def api_key() -> str | None:
return os.environ.get("LEGISLATION_NZ_API_KEY") or os.environ.get("PCO_API_KEY")
def api_search(
*,
query: str | None,
legislation_type: str | None,
search_field: str,
sort_by: str | None,
limit: int,
timeout: int,
) -> dict[str, Any]:
key = api_key()
if not key:
raise CliError(
"PCO developer API key is required; request one from contact@pco.govt.nz and set LEGISLATION_NZ_API_KEY",
code="api_key_required",
source_url=f"{API_BASE_URL}/v0/works/",
)
params: dict[str, str] = {
"per_page": str(clamp_limit(limit)),
"search_field": search_field,
}
if query:
params["search_term"] = query
if legislation_type:
params["legislation_type"] = API_TYPE[legislation_type]
if sort_by:
params["sort_by"] = sort_by
url = f"{API_BASE_URL}/v0/works/?" + urllib.parse.urlencode(params)
data, final_url = fetch_json(url, timeout=timeout, extra_headers={"X-Api-Key": key})
rows = []
for item in data.get("results", [])[:limit]:
latest = item.get("latest_matching_version") or {}
formats = latest.get("formats") or []
rows.append(
{
"title": latest.get("title"),
"url": next((f.get("url") for f in formats if f.get("type") == "html"), None),
"work_id": item.get("work_id"),
"version_id": latest.get("version_id"),
"type": item.get("legislation_type"),
"status": item.get("legislation_status"),
"is_latest_version": latest.get("is_latest_version"),
"formats": formats,
}
)
return {
"source": "api.legislation.govt.nz v0 works",
"source_url": final_url,
"total": data.get("total"),
"results": rows,
}
def parse_version_cards(html_text: str, *, limit: int) -> list[dict[str, Any]]:
versions: list[dict[str, Any]] = []
starts = [m.start() for m in re.finditer(r'
', html_text)]
for index, start in enumerate(starts[:limit]):
end = starts[index + 1] if index + 1 < len(starts) else min(len(html_text), start + 7000)
block = html_text[start:end]
id_match = re.search(r'id="version-([^"]+)"', block)
anchor = re.search(
r'
]+class="[^"]*\blink-visited-style\b[^"]*"[^>]+href="([^"]+)"[^>]*>\s*'
r']*class="[^"]*\blegislation-card__title\b[^"]*"[^>]*>(.*?)
',
block,
flags=re.I | re.S,
)
date_value = id_match.group(1) if id_match else None
status = "latest" if "Latest version" in normalise_text(block) else "previous"
amendments = []
for amendment in re.findall(
r']+class="[^"]*\bincorporated-amendments?\b[^"]*"[^>]*>(.*?)
',
block,
flags=re.I | re.S,
):
text = normalise_text(amendment)
if text:
amendments.append(text)
versions.append(
{
"as_at": date_value,
"title": normalise_text(anchor.group(2)) if anchor else None,
"url": to_absolute(anchor.group(1)) if anchor else None,
"status": status,
"pdf_url": to_absolute(re.search(r'href="([^"]+\.pdf)"', block).group(1))
if re.search(r'href="([^"]+\.pdf)"', block)
else None,
"incorporated_amendments": amendments,
}
)
return versions
def version_list_url(ref: dict[str, str], root: ET.Element, *, limit: int) -> str:
as_at = root.attrib.get("date.as.at") or ref.get("version")
if not as_at or as_at == "latest" or as_at == "nulldate":
as_at = ref.get("version", "latest")
per_page = min(max(limit, 1), 50)
base = canonical_url(ref, version=as_at, fmt="html").rstrip("/")
return f"{base}/versions/?per_page={per_page}"
def rss_feed(params: dict[str, str], *, timeout: int) -> tuple[str, bytes | None]:
url = BASE_URL + "/api_keys/?" + urllib.parse.urlencode(params)
body, final_url, content_type = fetch_bytes(
url,
timeout=timeout,
accept="application/json,application/atom+xml,application/xml,text/xml,*/*;q=0.8",
method="POST",
)
if "xml" in content_type or body.lstrip().startswith(b" list[dict[str, Any]]:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise CliError("could not parse official RSS/Atom feed", code="upstream_parse_error") from exc
ns = {"atom": "http://www.w3.org/2005/Atom"}
entries: list[dict[str, Any]] = []
for entry in root.findall("atom:entry", ns):
link = entry.find("atom:link", ns)
url = link.attrib.get("href") if link is not None else entry.findtext("atom:id", default="", namespaces=ns)
published = entry.findtext("atom:published", default=None, namespaces=ns)
updated = entry.findtext("atom:updated", default=None, namespaces=ns)
content = entry.findtext("atom:content", default="", namespaces=ns)
ref = ref_from_url(url or "")
entries.append(
{
"title": normalise_text(entry.findtext("atom:title", default="", namespaces=ns)),
"url": url,
"work_id": work_id(ref) if ref else None,
"type": ref["type"] if ref else None,
"published": published,
"updated": updated,
"summary": normalise_text(content),
}
)
return entries
def output(data: dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(data, indent=2, ensure_ascii=False))
return
command = data.get("command")
if command == "get-act":
act = data["act"]
print(f"{act.get('title')} ({act.get('year')} No {act.get('number')})")
print(f"as at: {act.get('as_at') or '-'} | source: {act.get('source_url')}")
print(f"sections shown: {len(data.get('sections', []))} of {data.get('sections_total', 0)}")
for row in data.get("sections", [])[:20]:
label = row.get("label") or "-"
heading = row.get("heading") or ""
print(f"- {label}: {heading}")
elif command == "get-section":
act = data["act"]
section = data["section"]
print(f"{act.get('title')} section {section.get('label')}: {section.get('heading')}")
print(section.get("text") or "")
print(f"source: {section.get('source_url')}")
elif command in {"search", "list-bills", "updates"}:
print(f"{command}: {data.get('total', len(data.get('results', [])))} result(s)")
for item in data.get("results", [])[: data.get("limit", DEFAULT_LIMIT)]:
date = item.get("as_at") or item.get("published") or item.get("updated") or "-"
print(f"- {item.get('title')} [{date}]")
if item.get("url"):
print(f" {item['url']}")
elif command == "versions":
act = data["act"]
print(f"{act.get('title')}: {len(data.get('versions', []))} version(s)")
for item in data.get("versions", []):
print(f"- {item.get('as_at')}: {item.get('status')} {item.get('url') or ''}")
elif command == "sources":
print("legislation-nz official sources:")
for item in data["sources"]:
print(f"- {item['name']}: {item['url']}")
print(f" {item['note']}")
else:
print(json.dumps(data, indent=2, ensure_ascii=False))
def output_error(exc: CliError, args: argparse.Namespace) -> None:
if getattr(args, "json", False):
print(
json.dumps(
{
"command": getattr(args, "command", None),
"status": "error",
"error": exc.code,
"message": str(exc),
"source_url": exc.source_url,
},
indent=2,
ensure_ascii=False,
)
)
return
detail = f"legislation-nz: {exc.code}: {exc}"
if exc.source_url:
detail += f" (source: {exc.source_url})"
print(detail, file=sys.stderr)
def cmd_get_act(args: argparse.Namespace) -> None:
root, ref, xml_url = load_xml(args.act_id, version=args.version, timeout=args.timeout)
act = act_metadata(root, ref, xml_url)
section_limit = None if args.all_sections else args.sections_limit
sections, total = section_rows(root, limit=section_limit)
output(
{
"command": "get-act",
"status": "ok",
"retrieved_at": now_iso(),
"source": "legislation.govt.nz XML format",
"act": act,
"sections_total": total,
"sections_truncated": section_limit is not None and total > len(sections),
"sections": sections,
},
args.json,
)
def cmd_get_section(args: argparse.Namespace) -> None:
root, ref, xml_url = load_xml(args.act_id, version=args.version, timeout=args.timeout)
act = act_metadata(root, ref, xml_url)
prov = find_section(root, args.section)
if prov is None:
raise CliError(f"section {args.section} not found in {act.get('title') or args.act_id}", code="not_found", source_url=act["source_url"])
output(
{
"command": "get-section",
"status": "ok",
"retrieved_at": now_iso(),
"source": "legislation.govt.nz XML format",
"act": act,
"section": section_payload(prov, act, root),
},
args.json,
)
def cmd_search(args: argparse.Namespace) -> None:
legislation_type = type_from_user(args.type)
source = args.source
if source == "auto":
source = "api" if api_key() else "web"
if source == "api":
data = api_search(
query=args.query,
legislation_type=legislation_type,
search_field=args.field,
sort_by=args.sort,
limit=args.limit,
timeout=args.timeout,
)
else:
data = web_search(
query=args.query,
legislation_type=legislation_type,
search_field=args.field,
bill_status=None,
sort_by=args.sort,
limit=args.limit,
timeout=args.timeout,
)
output(
{
"command": "search",
"status": "ok",
"retrieved_at": now_iso(),
"query": args.query,
"type": legislation_type,
"field": args.field,
"limit": args.limit,
**data,
},
args.json,
)
def cmd_versions(args: argparse.Namespace) -> None:
root, ref, xml_url = load_xml(args.act_id, version=args.version, timeout=args.timeout)
act = act_metadata(root, ref, xml_url)
url = version_list_url(ref, root, limit=args.limit)
html_text, final_url = fetch_text(url, timeout=args.timeout)
versions = parse_version_cards(html_text, limit=args.limit)
output(
{
"command": "versions",
"status": "ok",
"retrieved_at": now_iso(),
"source": "legislation.govt.nz versions page",
"source_url": final_url,
"act": act,
"limit": args.limit,
"versions": versions,
},
args.json,
)
def cmd_updates(args: argparse.Namespace) -> None:
since_dt = parse_iso_or_none(args.since)
if since_dt is None:
raise CliError("--since must be an ISO date or datetime, for example 2026-07-01", code="bad_input")
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=dt.timezone.utc)
params = {"id": "search"}
legislation_type = type_from_user(args.type)
if legislation_type:
params["legislation_type"] = legislation_type
feed_url, body = rss_feed(params, timeout=args.timeout)
final_url = feed_url
if body is None:
body, final_url, _ = fetch_bytes(feed_url, timeout=args.timeout, accept="application/atom+xml,application/xml,text/xml,*/*;q=0.8")
entries = parse_atom_feed(body)
filtered = []
for entry in entries:
updated = parse_iso_or_none(entry.get("updated")) or parse_iso_or_none(entry.get("published"))
if updated and updated >= since_dt:
filtered.append(entry)
if len(filtered) >= args.limit:
break
output(
{
"command": "updates",
"status": "ok",
"retrieved_at": now_iso(),
"source": "legislation.govt.nz generated Atom feed",
"source_url": final_url,
"since": args.since,
"type": legislation_type,
"limit": args.limit,
"total": len(filtered),
"results": filtered,
},
args.json,
)
def cmd_list_bills(args: argparse.Namespace) -> None:
data = web_search(
query=args.query,
legislation_type="bill",
search_field="title",
bill_status="current" if args.current else None,
sort_by=args.sort,
limit=args.limit,
timeout=args.timeout,
)
output(
{
"command": "list-bills",
"status": "ok",
"retrieved_at": now_iso(),
"current_only": args.current,
"query": args.query,
"limit": args.limit,
**data,
},
args.json,
)
def cmd_sources(args: argparse.Namespace) -> None:
output(
{
"command": "sources",
"status": "ok",
"retrieved_at": now_iso(),
"sources": [
{
"name": "New Zealand Legislation website",
"url": BASE_URL,
"note": "Official home for New Zealand Acts, Bills, and secondary legislation; keyless HTML and XML format URLs are used by this skill.",
},
{
"name": "PCO developer API",
"url": f"{API_BASE_URL}/docs/",
"note": "Supported API with /v0/works, /v0/works/{work_id}/versions, and /v0/versions/{version_id}; requires a free API key requested from contact@pco.govt.nz.",
},
{
"name": "Official web feeds",
"url": f"{BASE_URL}/learn-more/find-and-use-legislation/keep-up-to-date-with-legislation/",
"note": "Official generated Atom feeds for updates; this skill requests a feed URL through the public website endpoint.",
},
{
"name": "Bulk XML dataset catalogue",
"url": "https://catalogue.data.govt.nz/dataset/new-zealand-legislation",
"note": "Catalogue entry for the whole-corpus XML directory and related resources; availability may be affected by bot protection.",
},
],
},
args.json,
)
def add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="network timeout in seconds (default: 10)")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Query official New Zealand legislation sources")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("get-act", help="fetch Act metadata and section headings from official XML")
p.add_argument("act_id", help="year/number, work_id, version_id, or legislation.govt.nz URL")
p.add_argument("--version", default=None, help="version date YYYY-MM-DD, or latest")
p.add_argument("--sections-limit", type=int, default=200, help="maximum section headings to include")
p.add_argument("--all-sections", action="store_true", help="include all section headings")
add_common(p)
p.set_defaults(func=cmd_get_act)
p = sub.add_parser("get-section", help="fetch one section from official XML")
p.add_argument("act_id", help="year/number, work_id, version_id, or legislation.govt.nz URL")
p.add_argument("section", help="section label, for example 5 or 83ZE")
p.add_argument("--version", default=None, help="version date YYYY-MM-DD, or latest")
add_common(p)
p.set_defaults(func=cmd_get_section)
p = sub.add_parser("search", help="search official legislation records")
p.add_argument("query", help="search keywords")
p.add_argument("--type", choices=["act", "bill", "regulation", "secondary-legislation"], default=None)
p.add_argument("--field", choices=["title", "content"], default="title")
p.add_argument("--sort", choices=["relevance", "title_asc", "title_desc", "year_asc", "year_desc", "most_recently_updated"], default=None)
p.add_argument("--source", choices=["auto", "web", "api"], default="auto", help="auto uses API when LEGISLATION_NZ_API_KEY is set, otherwise web")
p.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
add_common(p)
p.set_defaults(func=cmd_search)
p = sub.add_parser("versions", help="list point-in-time versions for a work")
p.add_argument("act_id", help="year/number, work_id, version_id, or legislation.govt.nz URL")
p.add_argument("--version", default=None, help="starting version date, or latest")
p.add_argument("--limit", type=int, default=25)
add_common(p)
p.set_defaults(func=cmd_versions)
p = sub.add_parser("updates", help="list official feed updates since a date")
p.add_argument("--since", required=True, help="ISO date/datetime, for example 2026-07-01")
p.add_argument("--type", choices=["act", "bill", "regulation", "secondary-legislation"], default=None)
p.add_argument("--limit", type=int, default=20)
add_common(p)
p.set_defaults(func=cmd_updates)
p = sub.add_parser("list-bills", help="list Bills currently before Parliament")
p.add_argument("query", nargs="?", default=None, help="optional title search")
p.add_argument("--all-statuses", dest="current", action="store_false", help="include enacted and terminated Bills too")
p.add_argument("--sort", choices=["relevance", "title_asc", "title_desc", "year_asc", "year_desc", "most_recently_updated"], default="most_recently_updated")
p.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
add_common(p)
p.set_defaults(func=cmd_list_bills, current=True)
p = sub.add_parser("sources", aliases=["datasets"], help="describe official data sources and caveats")
add_common(p)
p.set_defaults(func=cmd_sources)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except CliError as exc:
output_error(exc, args)
raise SystemExit(1)
except KeyboardInterrupt:
print("legislation-nz: interrupted", file=sys.stderr)
raise SystemExit(130)
if __name__ == "__main__":
main()