clearancewatch 0.1.0__tar.gz
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.
- clearancewatch-0.1.0/LICENSE +21 -0
- clearancewatch-0.1.0/PKG-INFO +99 -0
- clearancewatch-0.1.0/README.md +84 -0
- clearancewatch-0.1.0/pyproject.toml +24 -0
- clearancewatch-0.1.0/setup.cfg +4 -0
- clearancewatch-0.1.0/src/clearancewatch/__init__.py +16 -0
- clearancewatch-0.1.0/src/clearancewatch/audit.py +140 -0
- clearancewatch-0.1.0/src/clearancewatch/cli.py +71 -0
- clearancewatch-0.1.0/src/clearancewatch/model.py +110 -0
- clearancewatch-0.1.0/src/clearancewatch.egg-info/PKG-INFO +99 -0
- clearancewatch-0.1.0/src/clearancewatch.egg-info/SOURCES.txt +14 -0
- clearancewatch-0.1.0/src/clearancewatch.egg-info/dependency_links.txt +1 -0
- clearancewatch-0.1.0/src/clearancewatch.egg-info/entry_points.txt +2 -0
- clearancewatch-0.1.0/src/clearancewatch.egg-info/top_level.txt +1 -0
- clearancewatch-0.1.0/tests/test_audit.py +116 -0
- clearancewatch-0.1.0/tests/test_cli.py +60 -0
|
@@ -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,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,84 @@
|
|
|
1
|
+
# clearancewatch
|
|
2
|
+
|
|
3
|
+
**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.
|
|
4
|
+
|
|
5
|
+
Rules modelled from official sources (verified October 2026):
|
|
6
|
+
|
|
7
|
+
- **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))
|
|
8
|
+
- **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/))
|
|
9
|
+
|
|
10
|
+
> **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.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install clearancewatch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Jobs file
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
[
|
|
22
|
+
{
|
|
23
|
+
"name": "Kitchen reno",
|
|
24
|
+
"province": "ON",
|
|
25
|
+
"start": "2026-02-01",
|
|
26
|
+
"end": "2026-03-30",
|
|
27
|
+
"subs": [
|
|
28
|
+
{
|
|
29
|
+
"name": "Bob's Drywall",
|
|
30
|
+
"trade": "drywall",
|
|
31
|
+
"clearance": { "issuer": "WSIB", "number": "1234567", "issued": "2026-01-10", "expires": "2026-04-10" },
|
|
32
|
+
"insurance": { "provider": "Aviva", "policy": "GL-9", "coverage": "C$2M", "expires": "2026-12-31" },
|
|
33
|
+
"licences": [ { "name": "Fall protection", "expires": "2026-09-01" } ]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
clearancewatch audit jobs.json --on 2026-02-15 --within 30
|
|
44
|
+
clearancewatch renewals jobs.json --within 30 # just what's coming due
|
|
45
|
+
clearancewatch audit jobs.json --json # machine-readable
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
ERROR clearance_expires_during_job Kitchen reno / Bob's Drywall: clearance expires 2026-03-15 before job end 2026-03-30...
|
|
50
|
+
WARN renewal_due Kitchen reno / Bob's Drywall: insurance expires 2026-03-05 (within 30 days)
|
|
51
|
+
|
|
52
|
+
1 error(s), 1 warning(s) — FAIL
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Exit `1` when any error-severity finding exists (missing/expired clearance, lapsed coverage, expired insurance or licence) — CI- and cron-friendly.
|
|
56
|
+
|
|
57
|
+
## What it catches
|
|
58
|
+
|
|
59
|
+
| Finding | Severity |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `clearance_missing` / `clearance_expired` / `clearance_expires_during_job` | error |
|
|
62
|
+
| `clearance_cover_lapsed` (Alberta letter predates ongoing work) | warn |
|
|
63
|
+
| `issuer_mismatch` (ON job, WCB-AB clearance — or vice versa) | warn |
|
|
64
|
+
| `insurance_missing` / `insurance_expired` | error |
|
|
65
|
+
| `insurance_expires_during_job` | warn |
|
|
66
|
+
| `licence_expired` | error |
|
|
67
|
+
| `renewal_due` / `insurance_renewal_due` / `licence_renewal_due` (within `--within` days) | warn |
|
|
68
|
+
|
|
69
|
+
## Python API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import datetime as dt
|
|
73
|
+
from clearancewatch import Job, audit
|
|
74
|
+
|
|
75
|
+
job = Job.from_dict({...})
|
|
76
|
+
for finding in audit(job, on=dt.date(2026, 2, 15), horizon=30):
|
|
77
|
+
print(finding.severity, finding.code, finding.detail)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`clearancewatch renewals` over a weekly cron gives you the "call these three subs" list before a gap becomes a deeming problem.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT-style: use it, ship it, no warranty.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clearancewatch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Track subcontractor compliance per job: WSIB/WCB clearance coverage, insurance and licence expiry against the job calendar"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "calasdan-sketch" }]
|
|
13
|
+
keywords = ["wsib", "wcb", "clearance", "compliance", "construction", "trades", "insurance"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Topic :: Office/Business",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
clearancewatch = "clearancewatch.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
@@ -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
|
|
@@ -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,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/clearancewatch/__init__.py
|
|
5
|
+
src/clearancewatch/audit.py
|
|
6
|
+
src/clearancewatch/cli.py
|
|
7
|
+
src/clearancewatch/model.py
|
|
8
|
+
src/clearancewatch.egg-info/PKG-INFO
|
|
9
|
+
src/clearancewatch.egg-info/SOURCES.txt
|
|
10
|
+
src/clearancewatch.egg-info/dependency_links.txt
|
|
11
|
+
src/clearancewatch.egg-info/entry_points.txt
|
|
12
|
+
src/clearancewatch.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
14
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clearancewatch
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Audit-rule tests with pinned dates."""
|
|
2
|
+
import datetime as dt
|
|
3
|
+
|
|
4
|
+
from clearancewatch.audit import audit, renewals
|
|
5
|
+
from clearancewatch.model import Clearance, Insurance, Job, Licence, Subcontractor
|
|
6
|
+
|
|
7
|
+
D = dt.date.fromisoformat
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def on_job(subs, start="2026-02-01", end="2026-03-30", province="ON", name="Job A"):
|
|
11
|
+
return Job(name=name, province=province, start=D(start), end=D(end) if end else None, subs=subs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def on_sub(**kw):
|
|
15
|
+
base = dict(name="Bob's Drywall", province="ON",
|
|
16
|
+
clearance=Clearance(issuer="WSIB", number="123", issued=D("2026-01-10"),
|
|
17
|
+
expires=D("2026-04-10")),
|
|
18
|
+
insurance=Insurance(provider="Aviva", policy="P-1", expires=D("2026-12-31")))
|
|
19
|
+
base.update(kw)
|
|
20
|
+
return Subcontractor(**base)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def codes(findings):
|
|
24
|
+
return [f.code for f in findings]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_happy_path_clean():
|
|
28
|
+
findings = audit(on_job([on_sub()]), on=D("2026-02-15"))
|
|
29
|
+
assert findings == [], findings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_expiry_defaults_to_90_days_for_wsib():
|
|
33
|
+
c = Clearance(issuer="WSIB", issued=D("2026-01-10"))
|
|
34
|
+
assert c.effective_expiry() == D("2026-04-10")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_expired_clearance():
|
|
38
|
+
sub = on_sub(clearance=Clearance(issuer="WSIB", issued=D("2025-06-01"), expires=D("2025-08-29")))
|
|
39
|
+
findings = audit(on_job([sub]), on=D("2026-02-15"))
|
|
40
|
+
assert "clearance_expired" in codes(findings)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_clearance_expires_during_job():
|
|
44
|
+
sub = on_sub(clearance=Clearance(issuer="WSIB", issued=D("2026-01-20"), expires=D("2026-03-15")))
|
|
45
|
+
findings = audit(on_job([sub]), on=D("2026-02-15"))
|
|
46
|
+
assert "clearance_expires_during_job" in codes(findings)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_missing_clearance_and_insurance():
|
|
50
|
+
sub = Subcontractor(name="NoDocs", clearance=None, insurance=None)
|
|
51
|
+
found = codes(audit(on_job([sub]), on=D("2026-02-15")))
|
|
52
|
+
assert "clearance_missing" in found and "insurance_missing" in found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_renewal_due_within_horizon():
|
|
56
|
+
# expiry must sit after job end (else during-job error rightly fires first)
|
|
57
|
+
sub = on_sub(clearance=Clearance(issuer="WSIB", issued=D("2026-01-20"), expires=D("2026-03-10")))
|
|
58
|
+
job = on_job([sub], end="2026-03-05")
|
|
59
|
+
found = codes(audit(job, on=D("2026-02-15"), horizon=30))
|
|
60
|
+
assert "renewal_due" in found
|
|
61
|
+
assert "clearance_expired" not in found and "clearance_expires_during_job" not in found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_alberta_letter_covers_only_up_to_its_date():
|
|
65
|
+
sub = on_sub(province="AB",
|
|
66
|
+
clearance=Clearance(issuer="WCB-AB", number="L-9", issued=D("2026-01-05"),
|
|
67
|
+
covered_until=D("2026-03-05")))
|
|
68
|
+
job = on_job([sub], start="2026-01-10", end="2026-06-01", province="AB")
|
|
69
|
+
found = codes(audit(job, on=D("2026-02-15")))
|
|
70
|
+
assert "clearance_cover_lapsed" in found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_alberta_letter_current_passes():
|
|
74
|
+
sub = on_sub(province="AB",
|
|
75
|
+
clearance=Clearance(issuer="WCB-AB", issued=D("2026-03-01"), covered_until=D("2026-12-31")))
|
|
76
|
+
job = on_job([sub], start="2026-01-10", end="2026-06-01", province="AB")
|
|
77
|
+
assert audit(job, on=D("2026-02-15")) == []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_issuer_mismatch_warns():
|
|
81
|
+
sub = on_sub(clearance=Clearance(issuer="WCB-AB", issued=D("2026-01-05"), covered_until=D("2026-12-31")))
|
|
82
|
+
found = codes(audit(on_job([sub], province="ON"), on=D("2026-02-15")))
|
|
83
|
+
assert "issuer_mismatch" in found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_licence_expired_and_due():
|
|
87
|
+
sub = on_sub(licences=[Licence(name="309A electrical", expires=D("2025-12-01")),
|
|
88
|
+
Licence(name="Fall protection", expires=D("2026-03-14"))])
|
|
89
|
+
job = on_job([sub], end="2026-03-10") # due licence outlives the job, lands in the horizon
|
|
90
|
+
found = codes(audit(job, on=D("2026-02-15")))
|
|
91
|
+
assert "licence_expired" in found and "licence_renewal_due" in found
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_ongoing_job_uses_audit_date():
|
|
95
|
+
sub = on_sub(clearance=Clearance(issuer="WSIB", issued=D("2026-01-20"), expires=D("2026-03-15")))
|
|
96
|
+
job = on_job([sub], end=None) # ongoing
|
|
97
|
+
found = codes(audit(job, on=D("2026-04-01")))
|
|
98
|
+
assert "clearance_expired" in found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_insurance_during_job_is_warning():
|
|
102
|
+
sub = on_sub(insurance=Insurance(provider="Aviva", expires=D("2026-03-10")))
|
|
103
|
+
findings = audit(on_job([sub]), on=D("2026-02-15"))
|
|
104
|
+
f = next(x for x in findings if x.code == "insurance_expires_during_job")
|
|
105
|
+
assert f.severity == "warn"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_renewals_filter():
|
|
109
|
+
jobs = [
|
|
110
|
+
on_job([on_sub(clearance=Clearance(issuer="WSIB", issued=D("2026-01-20"), expires=D("2026-03-10")))],
|
|
111
|
+
end="2026-03-05"), # renewal due within 30 days of 02-15
|
|
112
|
+
on_job([on_sub()], name="Job B"), # nothing due
|
|
113
|
+
]
|
|
114
|
+
due = renewals(jobs, within=30, on=D("2026-02-15"))
|
|
115
|
+
assert all("due" in f.code or "lapsed" in f.code for f in due)
|
|
116
|
+
assert len(due) == 1
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""CLI tests."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from clearancewatch.cli import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_jobs(tmp, jobs):
|
|
8
|
+
p = tmp / "jobs.json"
|
|
9
|
+
p.write_text(json.dumps(jobs), encoding="utf-8")
|
|
10
|
+
return str(p)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GOOD = {
|
|
14
|
+
"name": "Kitchen reno",
|
|
15
|
+
"province": "ON",
|
|
16
|
+
"start": "2026-02-01",
|
|
17
|
+
"end": "2026-03-30",
|
|
18
|
+
"subs": [{
|
|
19
|
+
"name": "Bob's Drywall",
|
|
20
|
+
"clearance": {"issuer": "WSIB", "number": "123", "issued": "2026-01-10", "expires": "2026-04-10"},
|
|
21
|
+
"insurance": {"provider": "Aviva", "policy": "P-1", "expires": "2026-12-31"},
|
|
22
|
+
}],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_audit_clean(tmp_path, capsys):
|
|
27
|
+
assert main(["audit", write_jobs(tmp_path, [GOOD]), "--on", "2026-02-15"]) == 0
|
|
28
|
+
assert "PASS" in capsys.readouterr().out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_audit_errors_fail_with_json(tmp_path, capsys):
|
|
32
|
+
bad = dict(GOOD, subs=[{"name": "NoDocs"}])
|
|
33
|
+
code = main(["audit", write_jobs(tmp_path, [bad]), "--on", "2026-02-15", "--json"])
|
|
34
|
+
out = json.loads(capsys.readouterr().out)
|
|
35
|
+
assert code == 1 and out["errors"] >= 2
|
|
36
|
+
assert any(f["code"] == "clearance_missing" for f in out["findings"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_jobs_wrapper_dict(tmp_path, capsys):
|
|
40
|
+
p = tmp_path / "jobs.json"
|
|
41
|
+
p.write_text(json.dumps({"jobs": [GOOD]}), encoding="utf-8")
|
|
42
|
+
assert main(["audit", str(p), "--on", "2026-02-15"]) == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_renewals_only_due(tmp_path, capsys):
|
|
46
|
+
due = dict(GOOD, end="2026-02-28", # job ends before the expiry -> renewal_due, not during-job
|
|
47
|
+
subs=[dict(GOOD["subs"][0],
|
|
48
|
+
clearance={"issuer": "WSIB", "issued": "2026-01-20", "expires": "2026-03-10"})])
|
|
49
|
+
assert main(["renewals", write_jobs(tmp_path, [due, GOOD]), "--within", "30", "--on", "2026-02-15"]) == 0
|
|
50
|
+
out = capsys.readouterr().out
|
|
51
|
+
assert "renewal_due" in out and out.count("WARN") == 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_missing_file(tmp_path):
|
|
55
|
+
try:
|
|
56
|
+
main(["audit", str(tmp_path / "nope.json")])
|
|
57
|
+
raised = False
|
|
58
|
+
except SystemExit as e:
|
|
59
|
+
raised = True
|
|
60
|
+
assert raised
|