rentready 0.7.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.
- rentready/__init__.py +575 -0
- rentready/__main__.py +3 -0
- rentready/hosted.py +425 -0
- rentready/pack.py +174 -0
- rentready/watch.py +332 -0
- rentready-0.7.0.dist-info/METADATA +209 -0
- rentready-0.7.0.dist-info/RECORD +11 -0
- rentready-0.7.0.dist-info/WHEEL +5 -0
- rentready-0.7.0.dist-info/entry_points.txt +2 -0
- rentready-0.7.0.dist-info/licenses/LICENSE +9 -0
- rentready-0.7.0.dist-info/top_level.txt +1 -0
rentready/__init__.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rentready: is this rental ready for England's landlord database ("Register your rental property")?
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
rentready check POSTCODE [--gas=YYYY-MM-DD] [--eicr=YYYY-MM-DD] [--epc=YYYY-MM-DD] [--epc-rating=C]
|
|
6
|
+
[--tenancy-start=YYYY-MM-DD] [--no-gas] [--properties=N] [--today=YYYY-MM-DD]
|
|
7
|
+
[--region="West Midlands"] [--json]
|
|
8
|
+
rentready portfolio FILE.csv check every property in a CSV (columns: name, postcode, gas, no_gas, eicr, epc,
|
|
9
|
+
epc_rating, tenancy_start, region); one table, total fee, earliest deadline
|
|
10
|
+
rentready calendar FILE.csv|POSTCODE [check flags] [--reminders=60,14] [--out=rentready.ics]
|
|
11
|
+
write an iCalendar file: window opens, deadline, each certificate expiry,
|
|
12
|
+
with reminders; import into Google/Apple/Outlook calendar
|
|
13
|
+
rentready watch FILE.csv re-check monthly and raise the alarm only when something changes: a certificate
|
|
14
|
+
comes within 60 days of expiring, a window opens, a deadline nears, a property
|
|
15
|
+
stops being ready; remembers the last run in a GitHub issue (Actions) or --state=FILE
|
|
16
|
+
rentready hosted run|replay|launchd the paid monthly watch with no repo: polls Polar for subscribers, emails
|
|
17
|
+
the first report within the hour, alerts on change, a monthly digest otherwise;
|
|
18
|
+
`replay` re-runs a stored report from its stored inputs (see rentready/hosted.py)
|
|
19
|
+
rentready pack FILE.csv [--organisation] [--out=pack.md] [--json]
|
|
20
|
+
the registration pack: every question the service asks, pre-filled from the CSV,
|
|
21
|
+
GATHER where it is missing, and each file to scan with a name; flags certificates
|
|
22
|
+
to renew before you upload (optional columns: see rentready/pack.py)
|
|
23
|
+
rentready regions print the rollout timetable
|
|
24
|
+
rentready mcp run as an MCP server over stdio (tools: check_property, check_portfolio, make_calendar,
|
|
25
|
+
registration_pack)
|
|
26
|
+
|
|
27
|
+
Zero dependencies. Region comes from postcodes.io (no key) unless --region is given.
|
|
28
|
+
Timetable, fee and required information: Private Rented Sector Database Regulations 2026 as summarised by
|
|
29
|
+
NRLA and HCR Law (Oct 2026). Dates are the regulations' as published; check gov.uk before relying on them.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
import urllib.parse
|
|
36
|
+
import urllib.request
|
|
37
|
+
from datetime import date, datetime, timedelta, timezone
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
__version__ = "0.7.0"
|
|
41
|
+
|
|
42
|
+
FEE_PER_PROPERTY_GBP = 65
|
|
43
|
+
|
|
44
|
+
# region -> (requirement starts, registration deadline, midnight)
|
|
45
|
+
ROLLOUT: Dict[str, Dict[str, str]] = {
|
|
46
|
+
"West Midlands": {"opens": "2026-12-15", "deadline": "2027-03-14"},
|
|
47
|
+
"East of England": {"opens": "2027-01-15", "deadline": "2027-04-14"},
|
|
48
|
+
"East Midlands": {"opens": "2027-02-15", "deadline": "2027-05-14"},
|
|
49
|
+
"South East": {"opens": "2027-03-15", "deadline": "2027-06-14"},
|
|
50
|
+
"Yorkshire and The Humber": {"opens": "2027-04-15", "deadline": "2027-07-14"},
|
|
51
|
+
"North West": {"opens": "2027-05-15", "deadline": "2027-08-14"},
|
|
52
|
+
"North East": {"opens": "2027-06-15", "deadline": "2027-09-14"},
|
|
53
|
+
"London": {"opens": "2027-07-15", "deadline": "2027-10-14"},
|
|
54
|
+
"South West": {"opens": "2027-08-15", "deadline": "2027-11-14"},
|
|
55
|
+
}
|
|
56
|
+
REGION_ALIASES = {"yorkshire and humber": "Yorkshire and The Humber", "yorkshire": "Yorkshire and The Humber",
|
|
57
|
+
"east": "East of England", "greater london": "London"}
|
|
58
|
+
|
|
59
|
+
UPLOADS = [
|
|
60
|
+
"Latest gas safety record (CP12) and its issue date, if the property has gas",
|
|
61
|
+
"EICR (or EIC if no EICR yet) and its expiry date",
|
|
62
|
+
"Latest EPC, and any MEES exemption; if the EPC has lapsed, the start date of the current tenancy",
|
|
63
|
+
]
|
|
64
|
+
DETAILS = [
|
|
65
|
+
"Landlord: name, date of birth, residential address, phone, email (companies: entity type, registered address, Companies House number or all directors)",
|
|
66
|
+
"Property: address, ownership type, freeholder or superior landlord, dwelling type, bedrooms, currently let?",
|
|
67
|
+
"Tenancy: rent, bills included?, payment frequency, occupiers, households, HMO licence status, furnishing",
|
|
68
|
+
"Each joint landlord needs their own landlord entry linked to the one property entry; an agent cannot register for you",
|
|
69
|
+
]
|
|
70
|
+
PENALTIES = [
|
|
71
|
+
"Up to £7,000 civil penalty for letting or marketing without an active entry, omitting the reference from adverts, or not updating within 28 days",
|
|
72
|
+
"Up to £40,000 for a repeat breach; rent repayment orders for false information",
|
|
73
|
+
"No possession order from the court while the landlord or dwelling entry is inactive (anti-social behaviour cases excepted)",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _d(s: Optional[str]) -> Optional[date]:
|
|
78
|
+
if not s:
|
|
79
|
+
return None
|
|
80
|
+
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def lookup_region(postcode: str, timeout: float = 8.0) -> Dict[str, Any]:
|
|
84
|
+
"""Region and country for a postcode via postcodes.io. Returns {region, country, postcode} or {error}."""
|
|
85
|
+
pc = urllib.parse.quote(postcode.strip())
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(f"https://api.postcodes.io/postcodes/{pc}", timeout=timeout) as r:
|
|
88
|
+
data = json.loads(r.read().decode())
|
|
89
|
+
except Exception as e: # network or 404
|
|
90
|
+
return {"error": f"postcode lookup failed: {type(e).__name__}: {e}"}
|
|
91
|
+
res = data.get("result") or {}
|
|
92
|
+
return {"postcode": res.get("postcode", postcode), "region": res.get("region"), "country": res.get("country")}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalise_region(region: Optional[str]) -> Optional[str]:
|
|
96
|
+
if not region:
|
|
97
|
+
return None
|
|
98
|
+
r = region.strip()
|
|
99
|
+
if r in ROLLOUT:
|
|
100
|
+
return r
|
|
101
|
+
low = r.lower()
|
|
102
|
+
if low in REGION_ALIASES:
|
|
103
|
+
return REGION_ALIASES[low]
|
|
104
|
+
for k in ROLLOUT:
|
|
105
|
+
if k.lower() == low:
|
|
106
|
+
return k
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_property(postcode: str = "", *, region: Optional[str] = None, gas: Optional[str] = None, no_gas: bool = False,
|
|
111
|
+
eicr: Optional[str] = None, epc: Optional[str] = None, epc_rating: Optional[str] = None,
|
|
112
|
+
tenancy_start: Optional[str] = None, properties: int = 1, today: Optional[str] = None) -> Dict[str, Any]:
|
|
113
|
+
t = _d(today) or date.today()
|
|
114
|
+
out: Dict[str, Any] = {"version": __version__, "today": t.isoformat(), "postcode": postcode or None,
|
|
115
|
+
"findings": [], "actions": [], "fee_gbp_per_year": FEE_PER_PROPERTY_GBP * max(1, int(properties))}
|
|
116
|
+
reg = normalise_region(region)
|
|
117
|
+
country = None
|
|
118
|
+
if not reg and postcode:
|
|
119
|
+
lk = lookup_region(postcode)
|
|
120
|
+
if "error" in lk:
|
|
121
|
+
out["findings"].append({"status": "WARN", "check": "region", "detail": lk["error"] + "; pass --region to continue"})
|
|
122
|
+
else:
|
|
123
|
+
country = lk.get("country")
|
|
124
|
+
reg = normalise_region(lk.get("region"))
|
|
125
|
+
out["postcode"] = lk.get("postcode")
|
|
126
|
+
out["region"] = reg
|
|
127
|
+
if country and country != "England":
|
|
128
|
+
out["findings"].append({"status": "INFO", "check": "scope", "detail": f"{country}: the England database does not apply; Wales and Scotland have their own landlord registration schemes"})
|
|
129
|
+
out["ready"] = None
|
|
130
|
+
return out
|
|
131
|
+
if not reg:
|
|
132
|
+
out["findings"].append({"status": "FAIL", "check": "region", "detail": "region unknown; registration window cannot be computed"})
|
|
133
|
+
out["ready"] = False
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
opens, deadline = _d(ROLLOUT[reg]["opens"]), _d(ROLLOUT[reg]["deadline"])
|
|
137
|
+
out["window"] = {"opens": opens.isoformat(), "deadline": deadline.isoformat(),
|
|
138
|
+
"days_to_deadline": (deadline - t).days, "service_opens_everywhere": "2026-12-15"}
|
|
139
|
+
if t > deadline:
|
|
140
|
+
out["findings"].append({"status": "FAIL", "check": "registration deadline", "detail": f"{reg} deadline {deadline} has passed; register now, penalties up to £7,000 and no possession orders apply"})
|
|
141
|
+
elif t >= opens:
|
|
142
|
+
out["findings"].append({"status": "WARN", "check": "registration deadline", "detail": f"{reg} window is open: register by {deadline} ({(deadline - t).days} days)"})
|
|
143
|
+
else:
|
|
144
|
+
out["findings"].append({"status": "PASS", "check": "registration deadline", "detail": f"{reg} requirement starts {opens}, deadline {deadline}; you can register from 2026-12-15"})
|
|
145
|
+
|
|
146
|
+
# Certificates: each must be in date at the deadline and ideally at registration.
|
|
147
|
+
def cert(name: str, issued: Optional[str], years: int, missing_msg: str, what: str) -> None:
|
|
148
|
+
if issued is None:
|
|
149
|
+
out["findings"].append({"status": "FAIL", "check": name, "detail": missing_msg})
|
|
150
|
+
out["actions"].append(what)
|
|
151
|
+
return
|
|
152
|
+
d0 = _d(issued)
|
|
153
|
+
try:
|
|
154
|
+
expiry = d0.replace(year=d0.year + years)
|
|
155
|
+
except ValueError: # 29 Feb
|
|
156
|
+
expiry = d0.replace(year=d0.year + years, day=28)
|
|
157
|
+
days = (expiry - t).days
|
|
158
|
+
out.setdefault("expiries", {})[name] = expiry.isoformat()
|
|
159
|
+
if expiry <= t:
|
|
160
|
+
out["findings"].append({"status": "FAIL", "check": name, "detail": f"expired {expiry}"})
|
|
161
|
+
out["actions"].append(f"Renew now: {what}")
|
|
162
|
+
elif expiry <= deadline:
|
|
163
|
+
out["findings"].append({"status": "WARN", "check": name, "detail": f"expires {expiry}, before the {deadline} deadline ({days} days)"})
|
|
164
|
+
out["actions"].append(f"Book before {expiry}: {what}")
|
|
165
|
+
else:
|
|
166
|
+
out["findings"].append({"status": "PASS", "check": name, "detail": f"valid to {expiry}"})
|
|
167
|
+
|
|
168
|
+
if no_gas:
|
|
169
|
+
out["findings"].append({"status": "PASS", "check": "gas safety record", "detail": "no gas supply declared; not required"})
|
|
170
|
+
else:
|
|
171
|
+
cert("gas safety record", gas, 1, "no gas safety record date given (annual CP12 is required and uploaded)", "annual gas safety check by a Gas Safe engineer; upload the record and issue date")
|
|
172
|
+
cert("EICR", eicr, 5, "no EICR date given (five-yearly report is required and uploaded with its expiry)", "EICR by a qualified electrician; upload the report and expiry date")
|
|
173
|
+
cert("EPC", epc, 10, "no EPC date given (upload the latest EPC; if lapsed, give the current tenancy start date)", "EPC assessment; upload the certificate")
|
|
174
|
+
if epc and epc_rating:
|
|
175
|
+
r = epc_rating.strip().upper()[:1]
|
|
176
|
+
if r in ("F", "G"):
|
|
177
|
+
out["findings"].append({"status": "FAIL", "check": "EPC rating", "detail": f"rating {r} is below the current E minimum (MEES); register an exemption or improve"})
|
|
178
|
+
elif r in ("D", "E"):
|
|
179
|
+
out["findings"].append({"status": "INFO", "check": "EPC rating", "detail": f"rating {r} meets today's E minimum; the proposed C minimum for new tenancies from 2028 and all tenancies from 2030 would not be met"})
|
|
180
|
+
else:
|
|
181
|
+
out["findings"].append({"status": "PASS", "check": "EPC rating", "detail": f"rating {r}"})
|
|
182
|
+
if tenancy_start:
|
|
183
|
+
out["tenancy_start"] = tenancy_start
|
|
184
|
+
|
|
185
|
+
out["uploads"] = UPLOADS
|
|
186
|
+
out["details_required"] = DETAILS
|
|
187
|
+
out["penalties"] = PENALTIES
|
|
188
|
+
out["renewal"] = "annual, on the anniversary of the first property registered (one date for the whole portfolio); later properties pay pro rata"
|
|
189
|
+
fails = [f for f in out["findings"] if f["status"] == "FAIL"]
|
|
190
|
+
out["ready"] = not fails
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_report(r: Dict[str, Any]) -> str:
|
|
195
|
+
lines = [f"rentready {r['version']} {r.get('postcode') or ''} region: {r.get('region') or 'unknown'}"]
|
|
196
|
+
w = r.get("window")
|
|
197
|
+
if w:
|
|
198
|
+
lines.append(f"Window: requirement starts {w['opens']}, deadline {w['deadline']} ({w['days_to_deadline']} days from {r['today']}); the service opens for everyone on {w['service_opens_everywhere']}")
|
|
199
|
+
for f in r["findings"]:
|
|
200
|
+
lines.append(f" {f['status']:<4} {f['check']}: {f['detail']}")
|
|
201
|
+
if r.get("actions"):
|
|
202
|
+
lines.append("Do next:")
|
|
203
|
+
lines += [f" - {a}" for a in r["actions"]]
|
|
204
|
+
if "fee_gbp_per_year" in r and r.get("region"):
|
|
205
|
+
lines.append(f"Fee: £{r['fee_gbp_per_year']} a year ({FEE_PER_PROPERTY_GBP} per property); renewal {r.get('renewal','')}")
|
|
206
|
+
if r.get("uploads"):
|
|
207
|
+
lines.append("Upload at registration (within 28 days of the entry):")
|
|
208
|
+
lines += [f" - {u}" for u in r["uploads"]]
|
|
209
|
+
if r.get("ready") is True:
|
|
210
|
+
lines.append("READY: nothing expires before your deadline. Gather the details above and register when your window opens.")
|
|
211
|
+
elif r.get("ready") is False:
|
|
212
|
+
lines.append("NOT READY: fix the FAIL lines first.")
|
|
213
|
+
return "\n".join(lines)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def format_regions() -> str:
|
|
217
|
+
rows = ["Region Requirement starts Deadline (midnight)"]
|
|
218
|
+
for k, v in ROLLOUT.items():
|
|
219
|
+
rows.append(f"{k:<26} {v['opens']:<19} {v['deadline']}")
|
|
220
|
+
rows.append(f"Registration opens for all of England on 2026-12-15. Fee £{FEE_PER_PROPERTY_GBP} per property per year.")
|
|
221
|
+
return "\n".join(rows)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Portfolio (many properties from a CSV) and calendar (.ics with reminders)
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
PORTFOLIO_COLUMNS = ["name", "postcode", "region", "gas", "no_gas", "eicr", "epc", "epc_rating", "tenancy_start"]
|
|
228
|
+
_COLUMN_ALIASES = {"address": "name", "property": "name", "label": "name", "post_code": "postcode", "post code": "postcode",
|
|
229
|
+
"gas_date": "gas", "gas safety": "gas", "gas_safety": "gas", "cp12": "gas", "eicr_date": "eicr",
|
|
230
|
+
"epc_date": "epc", "epc rating": "epc_rating", "rating": "epc_rating", "tenancy": "tenancy_start",
|
|
231
|
+
"tenancy start": "tenancy_start", "no gas": "no_gas", "nogas": "no_gas"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _norm_col(c: str) -> str:
|
|
235
|
+
c = (c or "").strip().lower().replace("-", "_")
|
|
236
|
+
return _COLUMN_ALIASES.get(c, c.replace(" ", "_"))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def read_portfolio(text: str) -> List[Dict[str, str]]:
|
|
240
|
+
"""Parse a portfolio from CSV text (header row required) or a JSON list of objects."""
|
|
241
|
+
import csv
|
|
242
|
+
import io
|
|
243
|
+
text = text.lstrip("\ufeff")
|
|
244
|
+
if text.lstrip().startswith("["):
|
|
245
|
+
rows = json.loads(text)
|
|
246
|
+
return [{_norm_col(k): ("" if v is None else str(v)) for k, v in r.items()} for r in rows]
|
|
247
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
248
|
+
out = []
|
|
249
|
+
for r in reader:
|
|
250
|
+
row = {_norm_col(k): (v or "").strip() for k, v in r.items() if k is not None}
|
|
251
|
+
if any(row.values()):
|
|
252
|
+
out.append(row)
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _truthy(v: Optional[str]) -> bool:
|
|
257
|
+
return str(v or "").strip().lower() in ("1", "true", "yes", "y", "x")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_portfolio(rows: List[Dict[str, str]], today: Optional[str] = None) -> Dict[str, Any]:
|
|
261
|
+
"""Run check_property for every row. Fee is £65 per property; the earliest deadline is the one to plan around."""
|
|
262
|
+
t = _d(today) or date.today()
|
|
263
|
+
props: List[Dict[str, Any]] = []
|
|
264
|
+
region_cache: Dict[str, Dict[str, Any]] = {}
|
|
265
|
+
for i, row in enumerate(rows, 1):
|
|
266
|
+
pc = (row.get("postcode") or "").strip()
|
|
267
|
+
region = row.get("region") or None
|
|
268
|
+
if pc and not region:
|
|
269
|
+
key = pc.upper().replace(" ", "")
|
|
270
|
+
if key not in region_cache:
|
|
271
|
+
region_cache[key] = lookup_region(pc)
|
|
272
|
+
lk = region_cache[key]
|
|
273
|
+
if "error" not in lk:
|
|
274
|
+
region = lk.get("region")
|
|
275
|
+
if lk.get("country") and lk["country"] != "England":
|
|
276
|
+
region = None
|
|
277
|
+
r = check_property(pc, region=region, gas=row.get("gas") or None, no_gas=_truthy(row.get("no_gas")),
|
|
278
|
+
eicr=row.get("eicr") or None, epc=row.get("epc") or None, epc_rating=row.get("epc_rating") or None,
|
|
279
|
+
tenancy_start=row.get("tenancy_start") or None, properties=1, today=t.isoformat())
|
|
280
|
+
if pc and not region and not row.get("region"):
|
|
281
|
+
lk = region_cache.get(pc.upper().replace(" ", ""), {})
|
|
282
|
+
if lk.get("country") and lk["country"] != "England":
|
|
283
|
+
r["findings"] = [{"status": "INFO", "check": "scope", "detail": f"{lk['country']}: the England database does not apply"}]
|
|
284
|
+
r["ready"] = None
|
|
285
|
+
r["name"] = row.get("name") or pc or f"property {i}"
|
|
286
|
+
nxt = _next_date(r)
|
|
287
|
+
r["next"] = nxt
|
|
288
|
+
props.append(r)
|
|
289
|
+
in_scope = [p for p in props if p.get("ready") is not None]
|
|
290
|
+
deadlines = sorted(p["window"]["deadline"] for p in in_scope if p.get("window"))
|
|
291
|
+
out = {"version": __version__, "today": t.isoformat(), "properties": props,
|
|
292
|
+
"count": len(props), "in_scope": len(in_scope),
|
|
293
|
+
"fee_gbp_per_year": FEE_PER_PROPERTY_GBP * len(in_scope),
|
|
294
|
+
"earliest_deadline": deadlines[0] if deadlines else None,
|
|
295
|
+
"ready": sum(1 for p in in_scope if p.get("ready") is True),
|
|
296
|
+
"not_ready": sum(1 for p in in_scope if p.get("ready") is False),
|
|
297
|
+
"renewal": "annual, on the anniversary of the first property registered (one date for the whole portfolio); later properties pay pro rata"}
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _next_date(r: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
|
302
|
+
"""The soonest dated thing for one property: a certificate expiry, the window opening or the deadline."""
|
|
303
|
+
t = _d(r.get("today")) or date.today()
|
|
304
|
+
cands: List[tuple] = []
|
|
305
|
+
for name, iso in (r.get("expiries") or {}).items():
|
|
306
|
+
cands.append((iso, f"{name} expires"))
|
|
307
|
+
w = r.get("window")
|
|
308
|
+
if w:
|
|
309
|
+
if _d(w["opens"]) >= t:
|
|
310
|
+
cands.append((w["opens"], "registration window opens"))
|
|
311
|
+
cands.append((w["deadline"], "registration deadline"))
|
|
312
|
+
cands = [c for c in cands if _d(c[0]) >= t] or cands
|
|
313
|
+
if not cands:
|
|
314
|
+
return None
|
|
315
|
+
iso, what = sorted(cands)[0]
|
|
316
|
+
return {"date": iso, "what": what, "days": (_d(iso) - t).days}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def format_portfolio(p: Dict[str, Any]) -> str:
|
|
320
|
+
lines = [f"rentready {p['version']} portfolio of {p['count']} ({p['in_scope']} in England) today {p['today']}"]
|
|
321
|
+
lines.append(f"{'Property':<28} {'Postcode':<9} {'Region':<24} {'Deadline':<11} {'Status':<9} Next")
|
|
322
|
+
for r in p["properties"]:
|
|
323
|
+
w = r.get("window") or {}
|
|
324
|
+
st = "READY" if r.get("ready") is True else "NOT READY" if r.get("ready") is False else "n/a"
|
|
325
|
+
nxt = r.get("next")
|
|
326
|
+
nxt_s = f"{nxt['date']} {nxt['what']} ({nxt['days']}d)" if nxt else ""
|
|
327
|
+
lines.append(f"{str(r['name'])[:28]:<28} {(r.get('postcode') or '')[:9]:<9} {(r.get('region') or 'unknown')[:24]:<24} {w.get('deadline', ''):<11} {st:<9} {nxt_s}")
|
|
328
|
+
for r in p["properties"]:
|
|
329
|
+
bad = [f for f in r["findings"] if f["status"] in ("FAIL", "WARN")]
|
|
330
|
+
if bad:
|
|
331
|
+
lines.append(f"{r['name']}:")
|
|
332
|
+
lines += [f" {f['status']:<4} {f['check']}: {f['detail']}" for f in bad]
|
|
333
|
+
lines.append(f"Fee: £{p['fee_gbp_per_year']} a year for {p['in_scope']} properties (£{FEE_PER_PROPERTY_GBP} each); renewal {p['renewal']}")
|
|
334
|
+
if p.get("earliest_deadline"):
|
|
335
|
+
lines.append(f"Earliest deadline: {p['earliest_deadline']}. {p['ready']} ready, {p['not_ready']} not ready.")
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def calendar_events(reports: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
340
|
+
"""Dated events for one or more check_property results: window opens, deadline, each certificate expiry."""
|
|
341
|
+
events: List[Dict[str, str]] = []
|
|
342
|
+
for r in reports:
|
|
343
|
+
name = r.get("name") or r.get("postcode") or "property"
|
|
344
|
+
w = r.get("window")
|
|
345
|
+
if w:
|
|
346
|
+
events.append({"date": w["opens"], "summary": f"Landlord database: register {name} (window opens)",
|
|
347
|
+
"description": f"{r.get('region')}: the registration requirement starts today; register at gov.uk Register your rental property. Deadline {w['deadline']}. Fee £{FEE_PER_PROPERTY_GBP} per property per year. Upload: " + "; ".join(UPLOADS), "property": name, "kind": "opens"})
|
|
348
|
+
events.append({"date": w["deadline"], "summary": f"Landlord database DEADLINE: {name}",
|
|
349
|
+
"description": f"{r.get('region')}: last day to register. After this: up to £7,000 penalty, no possession order.", "property": name, "kind": "deadline"})
|
|
350
|
+
for cert, iso in (r.get("expiries") or {}).items():
|
|
351
|
+
what = {"gas safety record": "book the annual Gas Safe check before this date and upload the new CP12",
|
|
352
|
+
"EICR": "book an electrician for the five-yearly EICR before this date and upload the new report",
|
|
353
|
+
"EPC": "book an EPC assessment before this date and upload the new certificate"}.get(cert, "renew before this date")
|
|
354
|
+
events.append({"date": iso, "summary": f"{cert} expires: {name}", "description": f"{what}. Update the property entry on the database within 28 days.", "property": name, "kind": cert})
|
|
355
|
+
events.sort(key=lambda e: (e["date"], e["property"], e["summary"]))
|
|
356
|
+
return events
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ics_escape(s: str) -> str:
|
|
360
|
+
return s.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _ics_fold(line: str) -> str:
|
|
364
|
+
out, b = [], line.encode("utf-8")
|
|
365
|
+
while len(b) > 73:
|
|
366
|
+
cut = 73
|
|
367
|
+
while cut > 0 and (b[cut] & 0xC0) == 0x80: # do not split a UTF-8 sequence
|
|
368
|
+
cut -= 1
|
|
369
|
+
out.append(b[:cut].decode("utf-8")); b = b" " + b[cut:]
|
|
370
|
+
out.append(b.decode("utf-8"))
|
|
371
|
+
return "\r\n".join(out)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def format_ics(events: List[Dict[str, str]], reminders: Optional[List[int]] = None, stamp: Optional[str] = None) -> str:
|
|
375
|
+
"""RFC 5545 calendar of all-day events with VALARMs N days before. Deterministic UIDs, so re-importing updates rather than duplicates."""
|
|
376
|
+
import hashlib
|
|
377
|
+
reminders = [60, 14] if reminders is None else reminders
|
|
378
|
+
ds = (stamp or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"))
|
|
379
|
+
out = ["BEGIN:VCALENDAR", "VERSION:2.0", f"PRODID:-//genzagents//rentready {__version__}//EN", "CALSCALE:GREGORIAN",
|
|
380
|
+
"METHOD:PUBLISH", "X-WR-CALNAME:rentready: landlord database and certificate dates"]
|
|
381
|
+
for e in events:
|
|
382
|
+
d = _d(e["date"])
|
|
383
|
+
uid = hashlib.sha1(f"{e['property']}|{e['kind']}|{e['date']}".encode()).hexdigest()[:20] + "@rentready"
|
|
384
|
+
out += ["BEGIN:VEVENT", f"UID:{uid}", f"DTSTAMP:{ds}", f"DTSTART;VALUE=DATE:{d.strftime('%Y%m%d')}",
|
|
385
|
+
f"DTEND;VALUE=DATE:{(d + timedelta(days=1)).strftime('%Y%m%d')}",
|
|
386
|
+
f"SUMMARY:{_ics_escape(e['summary'])}", f"DESCRIPTION:{_ics_escape(e['description'])}",
|
|
387
|
+
"URL:https://genzagents.github.io/rentready/", "TRANSP:TRANSPARENT"]
|
|
388
|
+
for n in reminders:
|
|
389
|
+
out += ["BEGIN:VALARM", "ACTION:DISPLAY", f"TRIGGER;VALUE=DURATION:-P{int(n)}D",
|
|
390
|
+
f"DESCRIPTION:{_ics_escape(e['summary'])} in {int(n)} days", "END:VALARM"]
|
|
391
|
+
out.append("END:VEVENT")
|
|
392
|
+
out.append("END:VCALENDAR")
|
|
393
|
+
return "\r\n".join(_ics_fold(l) for l in out) + "\r\n"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
MCP_TOOLS: List[Dict[str, Any]] = [{
|
|
397
|
+
"name": "check_property",
|
|
398
|
+
"description": "Is an English rental property ready for the landlord database (Register your rental property, Renters' Rights Act)? "
|
|
399
|
+
"Returns the region's registration window and deadline, whether the gas safety record, EICR and EPC will still be valid, "
|
|
400
|
+
"the £65/property fee, the upload checklist and the penalties.",
|
|
401
|
+
"inputSchema": {"type": "object", "properties": {
|
|
402
|
+
"postcode": {"type": "string", "description": "Property postcode (England)"},
|
|
403
|
+
"region": {"type": "string", "description": "Override region, e.g. 'London', if no postcode"},
|
|
404
|
+
"gas": {"type": "string", "description": "Gas safety record issue date YYYY-MM-DD"},
|
|
405
|
+
"no_gas": {"type": "boolean", "description": "True if the property has no gas supply"},
|
|
406
|
+
"eicr": {"type": "string", "description": "EICR issue date YYYY-MM-DD"},
|
|
407
|
+
"epc": {"type": "string", "description": "EPC issue date YYYY-MM-DD"},
|
|
408
|
+
"epc_rating": {"type": "string", "description": "EPC rating letter A-G"},
|
|
409
|
+
"tenancy_start": {"type": "string", "description": "Current tenancy start date YYYY-MM-DD"},
|
|
410
|
+
"properties": {"type": "integer", "description": "Number of properties, for the fee"},
|
|
411
|
+
}},
|
|
412
|
+
}, {
|
|
413
|
+
"name": "check_portfolio",
|
|
414
|
+
"description": "Check many English rental properties at once for the landlord database. Input is CSV text (header row: name, postcode, gas, no_gas, eicr, epc, epc_rating, tenancy_start, region) "
|
|
415
|
+
"or a JSON list of objects with those keys. Returns one line per property, the total £65/property fee, the earliest deadline and every FAIL/WARN.",
|
|
416
|
+
"inputSchema": {"type": "object", "required": ["portfolio"], "properties": {
|
|
417
|
+
"portfolio": {"type": "string", "description": "CSV text or JSON list of properties"},
|
|
418
|
+
"today": {"type": "string", "description": "Override today's date YYYY-MM-DD"},
|
|
419
|
+
}},
|
|
420
|
+
}, {
|
|
421
|
+
"name": "make_calendar",
|
|
422
|
+
"description": "Build an iCalendar (.ics) file for one property or a portfolio: registration window opens, registration deadline, and each gas/EICR/EPC expiry as all-day events with reminders. "
|
|
423
|
+
"Save the returned text as rentready.ics and import it into Google, Apple or Outlook calendar.",
|
|
424
|
+
"inputSchema": {"type": "object", "properties": {
|
|
425
|
+
"portfolio": {"type": "string", "description": "CSV text or JSON list of properties (omit for a single property)"},
|
|
426
|
+
"postcode": {"type": "string"}, "region": {"type": "string"}, "name": {"type": "string", "description": "Label for the property"},
|
|
427
|
+
"gas": {"type": "string"}, "no_gas": {"type": "boolean"}, "eicr": {"type": "string"}, "epc": {"type": "string"},
|
|
428
|
+
"reminder_days": {"type": "array", "items": {"type": "integer"}, "description": "Days before each event to alarm, default [60, 14]"},
|
|
429
|
+
}},
|
|
430
|
+
}, {
|
|
431
|
+
"name": "registration_pack",
|
|
432
|
+
"description": "Prepare the registration pack for England's 'Register your rental property' service: every question the service asks "
|
|
433
|
+
"(landlord, dwelling, tenancy, rent, health and safety), pre-filled from the portfolio where known and marked GATHER where not, "
|
|
434
|
+
"and each certificate to scan with a suggested file name, flagging any that must be renewed before upload. Returns a markdown checklist. "
|
|
435
|
+
"Extra optional columns: bedrooms, dwelling_type, ownership, let, occupants, households, licence, furnished, rent, rent_frequency, bills, freeholder, manager.",
|
|
436
|
+
"inputSchema": {"type": "object", "required": ["portfolio"], "properties": {
|
|
437
|
+
"portfolio": {"type": "string", "description": "CSV text or JSON list of properties"},
|
|
438
|
+
"organisation": {"type": "boolean", "description": "True if the landlord is a company, trust or other organisation"},
|
|
439
|
+
"today": {"type": "string", "description": "Override today's date YYYY-MM-DD"},
|
|
440
|
+
}},
|
|
441
|
+
}]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _mcp_send(obj: Dict[str, Any]) -> None:
|
|
445
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
446
|
+
sys.stdout.flush()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def mcp_serve() -> None:
|
|
450
|
+
"""Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies."""
|
|
451
|
+
for line in sys.stdin:
|
|
452
|
+
line = line.strip()
|
|
453
|
+
if not line:
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
msg = json.loads(line)
|
|
457
|
+
except Exception:
|
|
458
|
+
_mcp_send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
459
|
+
continue
|
|
460
|
+
method, mid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
|
|
461
|
+
if mid is None:
|
|
462
|
+
continue
|
|
463
|
+
try:
|
|
464
|
+
if method == "initialize":
|
|
465
|
+
result = {"protocolVersion": params.get("protocolVersion", "2025-06-18"), "capabilities": {"tools": {}},
|
|
466
|
+
"serverInfo": {"name": "rentready", "version": __version__}}
|
|
467
|
+
elif method == "ping":
|
|
468
|
+
result = {}
|
|
469
|
+
elif method == "tools/list":
|
|
470
|
+
result = {"tools": MCP_TOOLS}
|
|
471
|
+
elif method == "tools/call":
|
|
472
|
+
name, a = params.get("name"), params.get("arguments") or {}
|
|
473
|
+
if name == "check_property":
|
|
474
|
+
r = check_property(a.get("postcode", ""), region=a.get("region"), gas=a.get("gas"), no_gas=bool(a.get("no_gas")),
|
|
475
|
+
eicr=a.get("eicr"), epc=a.get("epc"), epc_rating=a.get("epc_rating"),
|
|
476
|
+
tenancy_start=a.get("tenancy_start"), properties=int(a.get("properties") or 1))
|
|
477
|
+
result = {"content": [{"type": "text", "text": format_report(r)}], "structuredContent": r, "isError": False}
|
|
478
|
+
elif name == "check_portfolio":
|
|
479
|
+
p = check_portfolio(read_portfolio(a["portfolio"]), today=a.get("today"))
|
|
480
|
+
result = {"content": [{"type": "text", "text": format_portfolio(p)}], "structuredContent": p, "isError": False}
|
|
481
|
+
elif name == "make_calendar":
|
|
482
|
+
if a.get("portfolio"):
|
|
483
|
+
reports = check_portfolio(read_portfolio(a["portfolio"]))["properties"]
|
|
484
|
+
else:
|
|
485
|
+
r = check_property(a.get("postcode", ""), region=a.get("region"), gas=a.get("gas"), no_gas=bool(a.get("no_gas")),
|
|
486
|
+
eicr=a.get("eicr"), epc=a.get("epc"))
|
|
487
|
+
r["name"] = a.get("name") or r.get("postcode") or "property"
|
|
488
|
+
reports = [r]
|
|
489
|
+
ev = calendar_events(reports)
|
|
490
|
+
ics = format_ics(ev, a.get("reminder_days"))
|
|
491
|
+
result = {"content": [{"type": "text", "text": ics}], "structuredContent": {"events": ev, "ics": ics}, "isError": False}
|
|
492
|
+
elif name == "registration_pack":
|
|
493
|
+
from rentready.pack import build_pack, format_pack
|
|
494
|
+
pk = build_pack(read_portfolio(a["portfolio"]), today=a.get("today"), organisation=bool(a.get("organisation")))
|
|
495
|
+
result = {"content": [{"type": "text", "text": format_pack(pk)}], "structuredContent": pk, "isError": False}
|
|
496
|
+
else:
|
|
497
|
+
raise KeyError(f"unknown tool {name}")
|
|
498
|
+
else:
|
|
499
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}})
|
|
500
|
+
continue
|
|
501
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "result": result})
|
|
502
|
+
except Exception as e:
|
|
503
|
+
_mcp_send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32603, "message": f"{type(e).__name__}: {e}"}})
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
507
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
508
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
509
|
+
print(__doc__.strip()); return 0
|
|
510
|
+
if argv[0] in ("-V", "--version"):
|
|
511
|
+
print(__version__); return 0
|
|
512
|
+
if argv[0] == "mcp":
|
|
513
|
+
mcp_serve(); return 0
|
|
514
|
+
if argv[0] == "regions":
|
|
515
|
+
print(format_regions()); return 0
|
|
516
|
+
if argv[0] == "watch":
|
|
517
|
+
from rentready.watch import main as watch_main
|
|
518
|
+
return watch_main(argv[1:])
|
|
519
|
+
if argv[0] == "hosted":
|
|
520
|
+
from rentready.hosted import main as hosted_main
|
|
521
|
+
return hosted_main(argv[1:])
|
|
522
|
+
if argv[0] == "pack":
|
|
523
|
+
from rentready.pack import build_pack, format_pack
|
|
524
|
+
files = [a for a in argv[1:] if not a.startswith("--")]
|
|
525
|
+
o = dict(a[2:].split("=", 1) if "=" in a else (a[2:], "1") for a in argv[1:] if a.startswith("--"))
|
|
526
|
+
if not files:
|
|
527
|
+
print("pack: give a portfolio CSV", file=sys.stderr); return 2
|
|
528
|
+
with open(files[0], encoding="utf-8") as fh:
|
|
529
|
+
pk = build_pack(read_portfolio(fh.read()), today=o.get("today"), organisation="organisation" in o)
|
|
530
|
+
text = json.dumps(pk, indent=2) if "json" in o else format_pack(pk)
|
|
531
|
+
if o.get("out"):
|
|
532
|
+
with open(o["out"], "w", encoding="utf-8") as fh:
|
|
533
|
+
fh.write(text + "\n")
|
|
534
|
+
print(f"wrote {o['out']}: {pk['ready_to_register']} of {pk['in_scope']} ready to register", file=sys.stderr)
|
|
535
|
+
else:
|
|
536
|
+
print(text)
|
|
537
|
+
return 0
|
|
538
|
+
if argv[0] not in ("check", "portfolio", "calendar"):
|
|
539
|
+
print(__doc__.strip(), file=sys.stderr); return 2
|
|
540
|
+
rest = argv[1:]
|
|
541
|
+
pos = [a for a in rest if not a.startswith("--")]
|
|
542
|
+
opts = dict(a[2:].split("=", 1) if "=" in a else (a[2:], "1") for a in rest if a.startswith("--"))
|
|
543
|
+
import os
|
|
544
|
+
if argv[0] == "portfolio" or (argv[0] == "calendar" and pos and os.path.isfile(pos[0])):
|
|
545
|
+
if not pos or not os.path.isfile(pos[0]):
|
|
546
|
+
print("portfolio: give a CSV file (columns: name, postcode, gas, no_gas, eicr, epc, epc_rating, tenancy_start, region)", file=sys.stderr); return 2
|
|
547
|
+
with open(pos[0], encoding="utf-8") as fh:
|
|
548
|
+
p = check_portfolio(read_portfolio(fh.read()), today=opts.get("today"))
|
|
549
|
+
if argv[0] == "portfolio":
|
|
550
|
+
print(json.dumps(p, indent=2) if "json" in opts else format_portfolio(p))
|
|
551
|
+
return 0 if p["not_ready"] == 0 else 1
|
|
552
|
+
reports = p["properties"]
|
|
553
|
+
else:
|
|
554
|
+
postcode = " ".join(pos)
|
|
555
|
+
r = check_property(postcode, region=opts.get("region"), gas=opts.get("gas"), no_gas="no-gas" in opts, eicr=opts.get("eicr"),
|
|
556
|
+
epc=opts.get("epc"), epc_rating=opts.get("epc-rating"), tenancy_start=opts.get("tenancy-start"),
|
|
557
|
+
properties=int(opts.get("properties", 1)), today=opts.get("today"))
|
|
558
|
+
if argv[0] == "check":
|
|
559
|
+
print(json.dumps(r, indent=2) if "json" in opts else format_report(r))
|
|
560
|
+
return 0 if r.get("ready") else 1
|
|
561
|
+
r["name"] = opts.get("name") or r.get("postcode") or "property"
|
|
562
|
+
reports = [r]
|
|
563
|
+
reminders = [int(x) for x in opts["reminders"].split(",") if x.strip()] if "reminders" in opts else None
|
|
564
|
+
ics = format_ics(calendar_events(reports), reminders)
|
|
565
|
+
if opts.get("out"):
|
|
566
|
+
with open(opts["out"], "w", encoding="utf-8", newline="") as fh:
|
|
567
|
+
fh.write(ics)
|
|
568
|
+
print(f"wrote {opts['out']}: {ics.count('BEGIN:VEVENT')} events", file=sys.stderr)
|
|
569
|
+
else:
|
|
570
|
+
sys.stdout.write(ics)
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
if __name__ == "__main__":
|
|
575
|
+
sys.exit(main())
|
rentready/__main__.py
ADDED