clearancewatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ """clearancewatch — per-job subcontractor compliance: clearances, insurance, licences.
2
+
3
+ Not legal advice. Rules modelled from official sources (see README), verified
4
+ October 2026: WSIB clearances are valid up to 90 days and a principal must hold
5
+ a valid clearance for the whole job; WCB Alberta clearance letters cover work up
6
+ to the date of the letter.
7
+ """
8
+
9
+ from .model import Clearance, Insurance, Job, Licence, Subcontractor
10
+ from .audit import Finding, audit, renewals
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "Job", "Subcontractor", "Clearance", "Insurance", "Licence",
15
+ "audit", "renewals", "Finding", "__version__",
16
+ ]
@@ -0,0 +1,140 @@
1
+ """Audit a job's subcontractors against the job calendar.
2
+
3
+ Severity model: things that expose the principal to deeming/penalty risk are
4
+ errors (missing or expired clearance, lapsed coverage, expired insurance or
5
+ licence); things that are still legal today but will bite soon are warnings
6
+ (expiring during the job, renewal horizon).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ from dataclasses import dataclass
12
+ from typing import List
13
+
14
+ from .model import Job, Subcontractor, WSIB_DEFAULT_VALID_DAYS
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Finding:
19
+ severity: str # error | warn
20
+ code: str
21
+ subject: str # "job / sub"
22
+ detail: str
23
+
24
+ def as_dict(self):
25
+ return {"severity": self.severity, "code": self.code,
26
+ "subject": self.subject, "detail": self.detail}
27
+
28
+
29
+ def _issuer_family(issuer: str) -> str:
30
+ u = (issuer or "").upper()
31
+ if "WSIB" in u:
32
+ return "ON"
33
+ if u.startswith("WCB") or "WORKERS" in u:
34
+ return "AB" if ("AB" in u or "ALBERTA" in u) else "WCB"
35
+ return ""
36
+
37
+
38
+ def _check_clearance(f: List[Finding], sub: Subcontractor, job: Job,
39
+ on: dt.date, horizon: int, valid_days: int) -> None:
40
+ who = f"{job.name} / {sub.name}"
41
+ c = sub.clearance
42
+ if c is None or (not c.issuer and not c.issued and not c.expires):
43
+ f.append(Finding("error", "clearance_missing", who,
44
+ "no clearance certificate on file"))
45
+ return
46
+
47
+ family = _issuer_family(c.issuer)
48
+ if job.province and family and family not in (job.province, "WCB"):
49
+ f.append(Finding("warn", "issuer_mismatch", who,
50
+ f"job is in {job.province} but clearance issuer is {c.issuer!r}"))
51
+
52
+ if family == "AB" or c.covered_until is not None:
53
+ # Alberta letters: cover work done up to the letter date
54
+ cover = c.covered_until or c.issued
55
+ if cover is None:
56
+ f.append(Finding("error", "clearance_no_coverage_dates", who,
57
+ "Alberta-style clearance needs issued/covered_until dates"))
58
+ return
59
+ horizon_date = job.end or on
60
+ if horizon_date > cover:
61
+ f.append(Finding("warn", "clearance_cover_lapsed", who,
62
+ f"letter covers work up to {cover.isoformat()}; work continues past it "
63
+ "(request a fresh clearance at the next installment)"))
64
+ return
65
+
66
+ expiry = c.effective_expiry(valid_days)
67
+ if expiry is None:
68
+ f.append(Finding("error", "clearance_no_expiry", who,
69
+ "clearance has neither expiry nor issue date"))
70
+ return
71
+ if expiry < on:
72
+ f.append(Finding("error", "clearance_expired", who,
73
+ f"clearance expired {expiry.isoformat()}"))
74
+ elif job.end and expiry < job.end:
75
+ f.append(Finding("error", "clearance_expires_during_job", who,
76
+ f"clearance expires {expiry.isoformat()} before job end {job.end.isoformat()}; "
77
+ "a principal must hold valid clearance for the whole job"))
78
+ elif (expiry - on).days <= horizon:
79
+ f.append(Finding("warn", "renewal_due", who,
80
+ f"clearance expires {expiry.isoformat()} (within {horizon} days)"))
81
+
82
+
83
+ def _check_insurance(f: List[Finding], sub: Subcontractor, job: Job,
84
+ on: dt.date, horizon: int) -> None:
85
+ who = f"{job.name} / {sub.name}"
86
+ ins = sub.insurance
87
+ if ins is None or not (ins.provider or ins.policy):
88
+ f.append(Finding("error", "insurance_missing", who, "no insurance on file"))
89
+ return
90
+ if ins.expires is None:
91
+ f.append(Finding("warn", "insurance_no_expiry", who, "insurance has no expiry date recorded"))
92
+ return
93
+ if ins.expires < on:
94
+ f.append(Finding("error", "insurance_expired", who,
95
+ f"insurance expired {ins.expires.isoformat()}"))
96
+ elif job.end and ins.expires < job.end:
97
+ f.append(Finding("warn", "insurance_expires_during_job", who,
98
+ f"insurance expires {ins.expires.isoformat()} before job end"))
99
+ elif (ins.expires - on).days <= horizon:
100
+ f.append(Finding("warn", "insurance_renewal_due", who,
101
+ f"insurance expires {ins.expires.isoformat()} (within {horizon} days)"))
102
+
103
+
104
+ def _check_licences(f: List[Finding], sub: Subcontractor, job: Job, on: dt.date, horizon: int) -> None:
105
+ who = f"{job.name} / {sub.name}"
106
+ for lic in sub.licences:
107
+ label = lic.name or "licence"
108
+ if lic.expires is None:
109
+ f.append(Finding("warn", "licence_no_expiry", who, f"{label}: no expiry recorded"))
110
+ continue
111
+ if lic.expires < on:
112
+ f.append(Finding("error", "licence_expired", who,
113
+ f"{label} expired {lic.expires.isoformat()}"))
114
+ elif job.end and lic.expires < job.end:
115
+ f.append(Finding("warn", "licence_expires_during_job", who,
116
+ f"{label} expires {lic.expires.isoformat()} before job end"))
117
+ elif (lic.expires - on).days <= horizon:
118
+ f.append(Finding("warn", "licence_renewal_due", who,
119
+ f"{label} expires {lic.expires.isoformat()} (within {horizon} days)"))
120
+
121
+
122
+ def audit(job: Job, on: dt.date | None = None, horizon: int = 30,
123
+ valid_days: int = WSIB_DEFAULT_VALID_DAYS) -> List[Finding]:
124
+ """All compliance findings for one job as of ``on`` (default: today)."""
125
+ on = on or dt.date.today()
126
+ out: List[Finding] = []
127
+ for sub in job.subs:
128
+ _check_clearance(out, sub, job, on, horizon, valid_days)
129
+ _check_insurance(out, sub, job, on, horizon)
130
+ _check_licences(out, sub, job, on, horizon)
131
+ return out
132
+
133
+
134
+ def renewals(jobs: List[Job], within: int = 30, on: dt.date | None = None) -> List[Finding]:
135
+ """Everything across jobs that needs a renewal touch within ``within`` days."""
136
+ on = on or dt.date.today()
137
+ out: List[Finding] = []
138
+ for job in jobs:
139
+ out.extend(f for f in audit(job, on=on, horizon=within) if "due" in f.code or "lapsed" in f.code)
140
+ return out
clearancewatch/cli.py ADDED
@@ -0,0 +1,71 @@
1
+ """clearancewatch CLI: audit jobs.json for compliance gaps."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime as dt
6
+ import json
7
+ import sys
8
+ from typing import List
9
+
10
+ from . import __version__
11
+ from .audit import audit, renewals
12
+ from .model import Job
13
+
14
+
15
+ def load_jobs(path: str) -> List[Job]:
16
+ try:
17
+ with open(path, encoding="utf-8") as fh:
18
+ data = json.load(fh)
19
+ except FileNotFoundError:
20
+ raise SystemExit(f"clearancewatch: not found: {path}")
21
+ except json.JSONDecodeError as e:
22
+ raise SystemExit(f"clearancewatch: invalid JSON: {e}")
23
+ if isinstance(data, dict):
24
+ data = data.get("jobs", [data])
25
+ return [Job.from_dict(d) for d in data]
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ ap = argparse.ArgumentParser(
30
+ prog="clearancewatch",
31
+ description="Audit subcontractor clearance/insurance/licence coverage against job calendars.",
32
+ )
33
+ ap.add_argument("--version", action="version", version=f"clearancewatch {__version__}")
34
+ sub = ap.add_subparsers(dest="cmd", required=True)
35
+
36
+ a = sub.add_parser("audit", help="full audit; exit 1 when any error-severity finding exists")
37
+ a.add_argument("jobs", help="jobs JSON file (list, or {\"jobs\": [...]})")
38
+ a.add_argument("--on", metavar="YYYY-MM-DD", help="audit date (default: today)")
39
+ a.add_argument("--within", type=int, default=30, help="renewal horizon in days (default 30)")
40
+ a.add_argument("--json", action="store_true")
41
+
42
+ r = sub.add_parser("renewals", help="only what's due soon; exit 1 if anything is due")
43
+ r.add_argument("jobs")
44
+ r.add_argument("--within", type=int, default=30)
45
+ r.add_argument("--on", metavar="YYYY-MM-DD")
46
+ r.add_argument("--json", action="store_true")
47
+
48
+ args = ap.parse_args(argv)
49
+ on = dt.date.fromisoformat(args.on) if args.on else None
50
+ jobs = load_jobs(args.jobs)
51
+
52
+ if args.cmd == "audit":
53
+ findings = [f for job in jobs for f in audit(job, on=on, horizon=args.within)]
54
+ else:
55
+ findings = renewals(jobs, within=args.within, on=on)
56
+
57
+ errors = [f for f in findings if f.severity == "error"]
58
+ warns = [f for f in findings if f.severity == "warn"]
59
+ if args.json:
60
+ print(json.dumps({"errors": len(errors), "warnings": len(warns),
61
+ "findings": [f.as_dict() for f in findings]}, indent=2))
62
+ else:
63
+ for f in findings:
64
+ print(f"{f.severity.upper():5} {f.code:28} {f.subject}: {f.detail}")
65
+ print(f"\n{len(errors)} error(s), {len(warns)} warning(s) — "
66
+ f"{'FAIL' if errors else 'PASS'}")
67
+ return 1 if errors else 0
68
+
69
+
70
+ if __name__ == "__main__": # pragma: no cover
71
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ """Data model: jobs, subcontractors, and the documents that keep them legal."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ WSIB_DEFAULT_VALID_DAYS = 90 # WSIB: clearance certificates valid up to 90 days
9
+
10
+
11
+ def parse_date(value: Optional[str]) -> Optional[dt.date]:
12
+ if value in (None, ""):
13
+ return None
14
+ return dt.date.fromisoformat(str(value))
15
+
16
+
17
+ @dataclass
18
+ class Clearance:
19
+ """A workers'-compensation clearance certificate.
20
+
21
+ Ontario (WSIB): has a real expiry — ``expires``, defaulting to issued +
22
+ 90 days when not stated.
23
+ Alberta (WCB): a clearance letter covers work done UP TO the date of the
24
+ letter — model that as ``covered_until`` (defaults to ``issued``).
25
+ """
26
+
27
+ issuer: str = "" # e.g. "WSIB", "WCB-AB"
28
+ number: str = ""
29
+ issued: Optional[dt.date] = None
30
+ expires: Optional[dt.date] = None
31
+ covered_until: Optional[dt.date] = None # Alberta-style letter coverage
32
+
33
+ @classmethod
34
+ def from_dict(cls, d: Dict[str, Any]) -> "Clearance":
35
+ return cls(issuer=d.get("issuer", ""), number=d.get("number", ""),
36
+ issued=parse_date(d.get("issued")), expires=parse_date(d.get("expires")),
37
+ covered_until=parse_date(d.get("covered_until")))
38
+
39
+ def effective_expiry(self, valid_days: int = WSIB_DEFAULT_VALID_DAYS) -> Optional[dt.date]:
40
+ if self.expires:
41
+ return self.expires
42
+ if self.issued and self.covered_until is None:
43
+ return self.issued + dt.timedelta(days=valid_days)
44
+ return self.covered_until or self.expires
45
+
46
+
47
+ @dataclass
48
+ class Insurance:
49
+ provider: str = ""
50
+ policy: str = ""
51
+ coverage: str = "" # e.g. "C$2M liability"
52
+ expires: Optional[dt.date] = None
53
+
54
+ @classmethod
55
+ def from_dict(cls, d: Dict[str, Any]) -> "Insurance":
56
+ return cls(provider=d.get("provider", ""), policy=d.get("policy", ""),
57
+ coverage=d.get("coverage", ""), expires=parse_date(d.get("expires")))
58
+
59
+
60
+ @dataclass
61
+ class Licence:
62
+ name: str = "" # e.g. "309A electrical"
63
+ number: str = ""
64
+ authority: str = "" # e.g. "TSSA", "ESA"
65
+ expires: Optional[dt.date] = None
66
+
67
+ @classmethod
68
+ def from_dict(cls, d: Dict[str, Any]) -> "Licence":
69
+ return cls(name=d.get("name", ""), number=d.get("number", ""),
70
+ authority=d.get("authority", ""), expires=parse_date(d.get("expires")))
71
+
72
+
73
+ @dataclass
74
+ class Subcontractor:
75
+ name: str
76
+ province: str = ""
77
+ trade: str = ""
78
+ clearance: Optional[Clearance] = None
79
+ insurance: Optional[Insurance] = None
80
+ licences: List[Licence] = field(default_factory=list)
81
+
82
+ @classmethod
83
+ def from_dict(cls, d: Dict[str, Any]) -> "Subcontractor":
84
+ return cls(
85
+ name=d.get("name", "unnamed sub"),
86
+ province=d.get("province", ""),
87
+ trade=d.get("trade", ""),
88
+ clearance=Clearance.from_dict(d["clearance"]) if d.get("clearance") else None,
89
+ insurance=Insurance.from_dict(d["insurance"]) if d.get("insurance") else None,
90
+ licences=[Licence.from_dict(x) for x in d.get("licences", [])],
91
+ )
92
+
93
+
94
+ @dataclass
95
+ class Job:
96
+ name: str
97
+ province: str = ""
98
+ start: Optional[dt.date] = None
99
+ end: Optional[dt.date] = None # None = ongoing
100
+ subs: List[Subcontractor] = field(default_factory=list)
101
+
102
+ @classmethod
103
+ def from_dict(cls, d: Dict[str, Any]) -> "Job":
104
+ return cls(
105
+ name=d.get("name", "unnamed job"),
106
+ province=d.get("province", ""),
107
+ start=parse_date(d.get("start")),
108
+ end=parse_date(d.get("end")),
109
+ subs=[Subcontractor.from_dict(s) for s in d.get("subs", d.get("subcontractors", []))],
110
+ )
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: clearancewatch
3
+ Version: 0.1.0
4
+ Summary: Track subcontractor compliance per job: WSIB/WCB clearance coverage, insurance and licence expiry against the job calendar
5
+ Author: calasdan-sketch
6
+ License-Expression: MIT
7
+ Keywords: wsib,wcb,clearance,compliance,construction,trades,insurance
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Environment :: Console
10
+ Classifier: Topic :: Office/Business
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # clearancewatch
17
+
18
+ **A principal must hold valid workers'-comp coverage for every sub — for the whole job.** `clearancewatch` audits subcontractor compliance documents against the job calendar and tells you what's expired, what expires mid-job, and what's coming up for renewal.
19
+
20
+ Rules modelled from official sources (verified October 2026):
21
+
22
+ - **Ontario WSIB**: clearance certificates are valid **up to 90 days**; WSIB states a principal must ensure a valid clearance is in place for the **full length of a job**. ([wsib.ca clearances](https://www.wsib.ca/en/businesses/premiums-and-payment/clearances))
23
+ - **Alberta WCB**: a clearance letter is valid for work done **up to the date of the letter** — WCB advises requesting one at hire and before each installment. ([wcb.ab.ca clearance letters](https://www.wcb.ab.ca/insurance-and-premiums/clearance-letters/))
24
+
25
+ > **This is not legal advice.** It's a date-tracking assistant built on the rules above; confirm obligations with your broker, WSIB/WCB, or counsel.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install clearancewatch
31
+ ```
32
+
33
+ ## Jobs file
34
+
35
+ ```json
36
+ [
37
+ {
38
+ "name": "Kitchen reno",
39
+ "province": "ON",
40
+ "start": "2026-02-01",
41
+ "end": "2026-03-30",
42
+ "subs": [
43
+ {
44
+ "name": "Bob's Drywall",
45
+ "trade": "drywall",
46
+ "clearance": { "issuer": "WSIB", "number": "1234567", "issued": "2026-01-10", "expires": "2026-04-10" },
47
+ "insurance": { "provider": "Aviva", "policy": "GL-9", "coverage": "C$2M", "expires": "2026-12-31" },
48
+ "licences": [ { "name": "Fall protection", "expires": "2026-09-01" } ]
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ clearancewatch audit jobs.json --on 2026-02-15 --within 30
59
+ clearancewatch renewals jobs.json --within 30 # just what's coming due
60
+ clearancewatch audit jobs.json --json # machine-readable
61
+ ```
62
+
63
+ ```
64
+ ERROR clearance_expires_during_job Kitchen reno / Bob's Drywall: clearance expires 2026-03-15 before job end 2026-03-30...
65
+ WARN renewal_due Kitchen reno / Bob's Drywall: insurance expires 2026-03-05 (within 30 days)
66
+
67
+ 1 error(s), 1 warning(s) — FAIL
68
+ ```
69
+
70
+ Exit `1` when any error-severity finding exists (missing/expired clearance, lapsed coverage, expired insurance or licence) — CI- and cron-friendly.
71
+
72
+ ## What it catches
73
+
74
+ | Finding | Severity |
75
+ |---|---|
76
+ | `clearance_missing` / `clearance_expired` / `clearance_expires_during_job` | error |
77
+ | `clearance_cover_lapsed` (Alberta letter predates ongoing work) | warn |
78
+ | `issuer_mismatch` (ON job, WCB-AB clearance — or vice versa) | warn |
79
+ | `insurance_missing` / `insurance_expired` | error |
80
+ | `insurance_expires_during_job` | warn |
81
+ | `licence_expired` | error |
82
+ | `renewal_due` / `insurance_renewal_due` / `licence_renewal_due` (within `--within` days) | warn |
83
+
84
+ ## Python API
85
+
86
+ ```python
87
+ import datetime as dt
88
+ from clearancewatch import Job, audit
89
+
90
+ job = Job.from_dict({...})
91
+ for finding in audit(job, on=dt.date(2026, 2, 15), horizon=30):
92
+ print(finding.severity, finding.code, finding.detail)
93
+ ```
94
+
95
+ `clearancewatch renewals` over a weekly cron gives you the "call these three subs" list before a gap becomes a deeming problem.
96
+
97
+ ## License
98
+
99
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,10 @@
1
+ clearancewatch/__init__.py,sha256=OuBGzzV4ee68RZyM5y_pSK4cLOGZEbPMgYwgPBCjTCg,627
2
+ clearancewatch/audit.py,sha256=B-wK7GR_iFETVHjVD-Kvo2A-ICwqM0_J8jT-jsZPWz8,6155
3
+ clearancewatch/cli.py,sha256=8CazgVqHAlqkiCZixYQNs9ndSlPixIw6EhcHyyrT44I,2707
4
+ clearancewatch/model.py,sha256=O6GwtTqkmjvleUJVHIj-jEhbUutCZ9gGPuF49Q9ybKw,3837
5
+ clearancewatch-0.1.0.dist-info/licenses/LICENSE,sha256=f409aSdfGpgy5kMR99eFaIPNwFDJfW41IpiNE_elcZ0,1072
6
+ clearancewatch-0.1.0.dist-info/METADATA,sha256=AB-JaaqhymAfYSbGBlxhA6npXrJHT38yC2-rME90Y4k,3837
7
+ clearancewatch-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ clearancewatch-0.1.0.dist-info/entry_points.txt,sha256=teTSw-29UThfWcYyWUsOaDe2p5u9hDACg5MiPtqFtBE,59
9
+ clearancewatch-0.1.0.dist-info/top_level.txt,sha256=Iz8KR5GNSwQmIliEamxT7UioJPjGpleXnFY01cYB6mc,15
10
+ clearancewatch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clearancewatch = clearancewatch.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 calasdan-sketch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ clearancewatch