parkingstudy 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,318 @@
1
+ """Occupancy counts by time beat: occupancy rates, peaks, capacity and space-hours.
2
+
3
+ Terminology follows common parking-study practice:
4
+
5
+ * **Occupancy rate** at a beat = vehicles counted / legal spaces available at
6
+ that beat. Illegally parked vehicles may push it above 1.0; this is kept and
7
+ flagged rather than clipped.
8
+ * **Effective capacity** = spaces x target occupancy. The default target of
9
+ 85 % is the widely used "85 percent rule" (Shoup, *The High Cost of Free
10
+ Parking*, APA Planners Press, 2005, ch. 12-13): above it drivers start
11
+ cruising for a vacant space. The target is a policy choice and configurable.
12
+ * **Practical capacity** (also called "effective supply") = spaces x an
13
+ efficiency factor per facility type, reflecting the cushion needed for
14
+ turnover, misparking and search. :data:`DEFAULT_PRACTICAL_FACTORS` holds
15
+ commonly quoted planning values (0.85 on-street, 0.90 off-street); they are
16
+ rules of thumb, not a standard, and are fully overridable.
17
+ * **Space-hours** of demand = sum over beats of vehicles x beat length. Supply
18
+ space-hours = sum over beats of available spaces x beat length.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime as _dt
24
+ from collections import OrderedDict
25
+ from dataclasses import dataclass
26
+ from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
27
+
28
+ from ._time import TimeLike, format_beat, minutes_between, parse_beat
29
+ from .errors import DataError
30
+ from .inventory import Inventory, check_grouping
31
+
32
+ #: Default target occupancy (Shoup's "85 percent rule").
33
+ DEFAULT_TARGET = 0.85
34
+
35
+ #: Practical-capacity efficiency factors by facility type. Planning rules of
36
+ #: thumb (not taken from a primary standard): override for your study.
37
+ DEFAULT_PRACTICAL_FACTORS: Mapping[str, float] = {"on_street": 0.85, "off_street": 0.90}
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Count:
42
+ """Vehicles counted on one block face at one beat.
43
+
44
+ ``space_type`` restricts the count to one inventory line; leave it empty when
45
+ the whole block face was counted.
46
+ """
47
+
48
+ block_face_id: str
49
+ beat: _dt.datetime
50
+ occupied: int
51
+ space_type: str = ""
52
+
53
+ def __post_init__(self) -> None:
54
+ object.__setattr__(self, "beat", parse_beat(self.beat))
55
+ if int(self.occupied) != self.occupied or self.occupied < 0:
56
+ raise DataError(f"{self.block_face_id}: occupied must be a non-negative integer")
57
+ object.__setattr__(self, "occupied", int(self.occupied))
58
+
59
+ @classmethod
60
+ def make(cls, block_face_id: str, beat: TimeLike, occupied: int, space_type: str = "") -> Count:
61
+ """Build a count, parsing ``beat`` from a string if needed."""
62
+ return cls(block_face_id, parse_beat(beat), occupied, space_type)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class OccupancyRow:
67
+ """Aggregated occupancy at one beat for one key (block face, zone or total)."""
68
+
69
+ key: str
70
+ beat: _dt.datetime
71
+ occupied: int
72
+ capacity: int
73
+
74
+ @property
75
+ def rate(self) -> Optional[float]:
76
+ """Occupied / capacity, or ``None`` when no space is available."""
77
+ return self.occupied / self.capacity if self.capacity else None
78
+
79
+ @property
80
+ def over_capacity(self) -> bool:
81
+ """More vehicles than legal spaces (illegal or double parking)."""
82
+ return self.occupied > self.capacity
83
+
84
+
85
+ def occupancy_by_beat(
86
+ inventory: Inventory, counts: Iterable[Count], by: str = "block_face"
87
+ ) -> List[OccupancyRow]:
88
+ """Occupancy per beat, aggregated to block faces, zones or the whole study area.
89
+
90
+ Capacity of each count is the inventory capacity of the counted block face
91
+ (or of its ``space_type`` line) *at that beat*, so spaces removed by an
92
+ ``unavailable`` window do not inflate the denominator. When aggregating,
93
+ only the block faces actually counted at a beat contribute capacity.
94
+ Rows are sorted by key then beat.
95
+ """
96
+ check_grouping(by)
97
+ seen = set()
98
+ occ: Dict[Tuple[str, _dt.datetime], List[int]] = OrderedDict()
99
+ for c in counts:
100
+ ident = (c.block_face_id, c.space_type, c.beat)
101
+ if ident in seen:
102
+ raise DataError(f"duplicate count for {c.block_face_id!r} at {c.beat}")
103
+ seen.add(ident)
104
+ cap = inventory.capacity(
105
+ block_face_id=c.block_face_id, space_type=c.space_type or None, at=c.beat
106
+ )
107
+ key = inventory.key_of(c.block_face_id, by)
108
+ acc = occ.setdefault((key, c.beat), [0, 0])
109
+ acc[0] += c.occupied
110
+ acc[1] += cap
111
+ rows = [OccupancyRow(k, b, v[0], v[1]) for (k, b), v in occ.items()]
112
+ rows.sort(key=lambda r: (r.key, r.beat))
113
+ return rows
114
+
115
+
116
+ def peak_occupancy(
117
+ rows: Iterable[OccupancyRow], basis: str = "vehicles"
118
+ ) -> Dict[str, OccupancyRow]:
119
+ """Peak row per key.
120
+
121
+ ``basis="vehicles"`` (default) takes the beat with the most parked vehicles,
122
+ the peak accumulation used for supply/demand comparison; ``basis="rate"``
123
+ takes the highest occupancy rate (they differ when capacity varies by time).
124
+ Ties go to the earliest beat.
125
+ """
126
+ if basis not in ("vehicles", "rate"):
127
+ raise ValueError("basis must be 'vehicles' or 'rate'")
128
+ best: Dict[str, OccupancyRow] = OrderedDict()
129
+ for row in sorted(rows, key=lambda r: (r.key, r.beat)):
130
+ cur = best.get(row.key)
131
+ if cur is None:
132
+ best[row.key] = row
133
+ continue
134
+ if basis == "vehicles":
135
+ if row.occupied > cur.occupied:
136
+ best[row.key] = row
137
+ else:
138
+ new = -1.0 if row.rate is None else row.rate
139
+ old = -1.0 if cur.rate is None else cur.rate
140
+ if new > old:
141
+ best[row.key] = row
142
+ return best
143
+
144
+
145
+ def effective_capacity(spaces: float, target: float = DEFAULT_TARGET) -> float:
146
+ """Spaces that can be occupied while meeting the target occupancy (``spaces * target``)."""
147
+ if not 0 < target <= 1:
148
+ raise ValueError("target must be in (0, 1]")
149
+ if spaces < 0:
150
+ raise ValueError("spaces must not be negative")
151
+ return spaces * target
152
+
153
+
154
+ def practical_capacity(
155
+ inventory: Inventory,
156
+ *,
157
+ factors: Optional[Mapping[str, float]] = None,
158
+ block_face_id: Optional[str] = None,
159
+ zone: Optional[str] = None,
160
+ at: Optional[_dt.datetime] = None,
161
+ ) -> float:
162
+ """Sum of available spaces x facility efficiency factor.
163
+
164
+ ``factors`` maps :attr:`InventoryRecord.facility` to a factor in (0, 1];
165
+ defaults to :data:`DEFAULT_PRACTICAL_FACTORS`.
166
+ """
167
+ table = DEFAULT_PRACTICAL_FACTORS if factors is None else factors
168
+ total = 0.0
169
+ for rec in inventory.select(block_face_id=block_face_id, zone=zone):
170
+ if rec.facility not in table:
171
+ raise DataError(
172
+ f"no practical-capacity factor for facility {rec.facility!r}; pass factors="
173
+ )
174
+ factor = table[rec.facility]
175
+ if not 0 < factor <= 1:
176
+ raise ValueError("practical-capacity factors must be in (0, 1]")
177
+ total += rec.available_at(at) * factor
178
+ return total
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class CapacityAssessment:
183
+ """Peak demand against effective and practical capacity for one key."""
184
+
185
+ key: str
186
+ spaces: int
187
+ peak_beat: _dt.datetime
188
+ peak_occupied: int
189
+ peak_rate: Optional[float]
190
+ target: float
191
+ effective_capacity: float
192
+ practical_capacity: float
193
+
194
+ @property
195
+ def surplus(self) -> float:
196
+ """Effective capacity minus peak vehicles (negative = deficit)."""
197
+ return self.effective_capacity - self.peak_occupied
198
+
199
+ @property
200
+ def above_target(self) -> bool:
201
+ """Peak occupancy exceeds the target occupancy."""
202
+ return self.peak_occupied > self.effective_capacity + 1e-9
203
+
204
+ def as_dict(self) -> Dict[str, object]:
205
+ """Plain-dict form for CSV / JSON output."""
206
+ return {
207
+ "key": self.key,
208
+ "spaces": self.spaces,
209
+ "peak_beat": format_beat(self.peak_beat),
210
+ "peak_occupied": self.peak_occupied,
211
+ "peak_rate": self.peak_rate,
212
+ "target": self.target,
213
+ "effective_capacity": self.effective_capacity,
214
+ "practical_capacity": self.practical_capacity,
215
+ "surplus": self.surplus,
216
+ "above_target": self.above_target,
217
+ }
218
+
219
+
220
+ def capacity_assessment(
221
+ inventory: Inventory,
222
+ counts: Iterable[Count],
223
+ *,
224
+ by: str = "zone",
225
+ target: float = DEFAULT_TARGET,
226
+ factors: Optional[Mapping[str, float]] = None,
227
+ ) -> List[CapacityAssessment]:
228
+ """Compare peak accumulation with effective and practical capacity per key.
229
+
230
+ Capacity values refer to the spaces available at the peak beat.
231
+ """
232
+ rows = occupancy_by_beat(inventory, counts, by=by)
233
+ out: List[CapacityAssessment] = []
234
+ for key, peak in peak_occupancy(rows, basis="vehicles").items():
235
+ if by == "block_face":
236
+ prac = practical_capacity(inventory, factors=factors, block_face_id=key, at=peak.beat)
237
+ elif by == "zone":
238
+ prac = practical_capacity(inventory, factors=factors, zone=key, at=peak.beat)
239
+ else:
240
+ prac = practical_capacity(inventory, factors=factors, at=peak.beat)
241
+ out.append(
242
+ CapacityAssessment(
243
+ key=key,
244
+ spaces=peak.capacity,
245
+ peak_beat=peak.beat,
246
+ peak_occupied=peak.occupied,
247
+ peak_rate=peak.rate,
248
+ target=target,
249
+ effective_capacity=effective_capacity(peak.capacity, target),
250
+ practical_capacity=prac,
251
+ )
252
+ )
253
+ return out
254
+
255
+
256
+ def accumulation_curve(
257
+ inventory: Inventory, counts: Iterable[Count], by: str = "total"
258
+ ) -> Dict[str, List[Tuple[_dt.datetime, int]]]:
259
+ """Parking accumulation (vehicles parked) through the survey, per key."""
260
+ curve: Dict[str, List[Tuple[_dt.datetime, int]]] = OrderedDict()
261
+ for row in occupancy_by_beat(inventory, counts, by=by):
262
+ curve.setdefault(row.key, []).append((row.beat, row.occupied))
263
+ return curve
264
+
265
+
266
+ def infer_beat_minutes(beats: Iterable[_dt.datetime]) -> float:
267
+ """Survey beat length: the smallest positive gap between distinct beat times."""
268
+ times = sorted(set(beats))
269
+ if len(times) < 2:
270
+ raise DataError("at least two distinct beats are needed to infer the beat length")
271
+ return min(minutes_between(a, b) for a, b in zip(times, times[1:]))
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class SpaceHours:
276
+ """Space-hour demand and supply for one key."""
277
+
278
+ key: str
279
+ demand: float
280
+ supply: float
281
+
282
+ @property
283
+ def utilization(self) -> Optional[float]:
284
+ """Demand / supply space-hours (time-averaged occupancy)."""
285
+ return self.demand / self.supply if self.supply else None
286
+
287
+ def as_dict(self) -> Dict[str, object]:
288
+ """Plain-dict form."""
289
+ return {
290
+ "key": self.key,
291
+ "demand_space_hours": self.demand,
292
+ "supply_space_hours": self.supply,
293
+ "utilization": self.utilization,
294
+ }
295
+
296
+
297
+ def space_hours(
298
+ inventory: Inventory,
299
+ counts: Sequence[Count],
300
+ *,
301
+ by: str = "zone",
302
+ beat_minutes: Optional[float] = None,
303
+ ) -> List[SpaceHours]:
304
+ """Space-hours of demand and supply, each beat representing ``beat_minutes``.
305
+
306
+ ``beat_minutes`` defaults to :func:`infer_beat_minutes` over the counts.
307
+ """
308
+ if beat_minutes is None:
309
+ beat_minutes = infer_beat_minutes(c.beat for c in counts)
310
+ if beat_minutes <= 0:
311
+ raise ValueError("beat_minutes must be positive")
312
+ hours = beat_minutes / 60.0
313
+ acc: Dict[str, List[float]] = OrderedDict()
314
+ for row in occupancy_by_beat(inventory, counts, by=by):
315
+ a = acc.setdefault(row.key, [0.0, 0.0])
316
+ a[0] += row.occupied * hours
317
+ a[1] += row.capacity * hours
318
+ return [SpaceHours(k, v[0], v[1]) for k, v in acc.items()]
@@ -0,0 +1,217 @@
1
+ """Demand-responsive pricing recommendations from occupancy bands.
2
+
3
+ Two ready-made policies are provided; every number is a configurable policy
4
+ parameter, so build your own :class:`PricingPolicy` for local currency, steps,
5
+ thresholds and caps.
6
+
7
+ :data:`SFMTA_POLICY` (default) follows SFMTA, *Demand-responsive parking pricing
8
+ policies: on-street parking meters and off-street lots* (5 December 2017),
9
+ section "On-street metered parking and metered lots":
10
+
11
+ * occupancy 80 % or above: hourly rate raised by 0.25;
12
+ * 60 % or above but below 80 %: not changed;
13
+ * below 60 %: lowered by 0.25;
14
+ * minimum rate 0.50 per hour, maximum 8.00 per hour (special events excluded),
15
+ changes no more often than once per four weeks.
16
+
17
+ :data:`SFPARK_PILOT_POLICY` reproduces the four-band table commonly reported for
18
+ the 2011-2013 SFpark pilot (80-100 %: +0.25, 60-80 %: hold, 30-60 %: -0.25,
19
+ below 30 %: -0.50, cap 6.00 per hour). It was taken from secondary summaries of
20
+ the SFMTA pilot evaluation, not checked against the primary table, and its
21
+ 0.25 floor is an assumption.
22
+
23
+ Band boundaries are lower-inclusive (80 % falls in the "raise" band), as in the
24
+ SFMTA wording "80 percent or above".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from dataclasses import dataclass, field
31
+ from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
32
+
33
+ from .errors import DataError
34
+ from .occupancy import OccupancyRow
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PriceBand:
39
+ """Occupancy band ``[lower, upper)`` and the rate change applied in it."""
40
+
41
+ lower: float
42
+ upper: float
43
+ change: float
44
+ label: str = ""
45
+
46
+ def contains(self, occupancy: float) -> bool:
47
+ """Whether ``occupancy`` lies in ``[lower, upper)``."""
48
+ return self.lower <= occupancy < self.upper
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PricingPolicy:
53
+ """Band table plus rate floor, cap and rounding increment.
54
+
55
+ Bands must be sorted, contiguous and cover ``[0, inf)``; make the last band
56
+ open-ended (``upper=math.inf``) so occupancies above 100 % (illegal parking)
57
+ still match.
58
+ """
59
+
60
+ bands: Tuple[PriceBand, ...]
61
+ min_rate: float = 0.0
62
+ max_rate: float = math.inf
63
+ rounding: float = 0.01
64
+ name: str = "custom"
65
+
66
+ def __post_init__(self) -> None:
67
+ bands = tuple(sorted(self.bands, key=lambda b: b.lower))
68
+ if not bands:
69
+ raise ValueError("a pricing policy needs at least one band")
70
+ if bands[0].lower > 0:
71
+ raise ValueError("the first band must start at 0")
72
+ for a, b in zip(bands, bands[1:]):
73
+ if not math.isclose(a.upper, b.lower):
74
+ raise ValueError(f"bands must be contiguous ({a.upper} != {b.lower})")
75
+ if not math.isinf(bands[-1].upper):
76
+ raise ValueError("the last band must be open-ended (upper=math.inf)")
77
+ if self.min_rate < 0 or self.max_rate < self.min_rate:
78
+ raise ValueError("need 0 <= min_rate <= max_rate")
79
+ if self.rounding <= 0:
80
+ raise ValueError("rounding must be positive")
81
+ object.__setattr__(self, "bands", bands)
82
+
83
+ def band_for(self, occupancy: float) -> PriceBand:
84
+ """Band containing an occupancy rate (0-1 scale)."""
85
+ if occupancy < 0 or math.isnan(occupancy):
86
+ raise DataError(f"invalid occupancy {occupancy!r}")
87
+ for band in self.bands:
88
+ if band.contains(occupancy):
89
+ return band
90
+ raise DataError(f"occupancy {occupancy!r} not covered by any band") # pragma: no cover
91
+
92
+
93
+ #: SFMTA on-street meter policy of 5 December 2017 (verified against the SFMTA PDF).
94
+ SFMTA_POLICY = PricingPolicy(
95
+ bands=(
96
+ PriceBand(0.0, 0.60, -0.25, "below 60%: lower"),
97
+ PriceBand(0.60, 0.80, 0.0, "60-80%: hold"),
98
+ PriceBand(0.80, math.inf, 0.25, "80% or above: raise"),
99
+ ),
100
+ min_rate=0.50,
101
+ max_rate=8.00,
102
+ rounding=0.01,
103
+ name="SFMTA demand-responsive meter pricing (2017)",
104
+ )
105
+
106
+ #: SFpark pilot four-band table (secondary sources; floor assumed, see module docstring).
107
+ SFPARK_PILOT_POLICY = PricingPolicy(
108
+ bands=(
109
+ PriceBand(0.0, 0.30, -0.50, "below 30%: lower 0.50"),
110
+ PriceBand(0.30, 0.60, -0.25, "30-60%: lower 0.25"),
111
+ PriceBand(0.60, 0.80, 0.0, "60-80%: hold"),
112
+ PriceBand(0.80, math.inf, 0.25, "80% or above: raise"),
113
+ ),
114
+ min_rate=0.25, # assumption, not verified
115
+ max_rate=6.00,
116
+ rounding=0.01,
117
+ name="SFpark pilot (2011-2013), as reported",
118
+ )
119
+
120
+ #: Named policies for the CLI.
121
+ POLICIES: Dict[str, PricingPolicy] = {"sfmta": SFMTA_POLICY, "sfpark-pilot": SFPARK_PILOT_POLICY}
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class PriceRecommendation:
126
+ """Recommended new hourly rate for one key."""
127
+
128
+ key: str
129
+ occupancy: float
130
+ current_rate: float
131
+ new_rate: float
132
+ band: str
133
+ clamped: bool = field(default=False)
134
+
135
+ @property
136
+ def change(self) -> float:
137
+ """``new_rate - current_rate``."""
138
+ return round(self.new_rate - self.current_rate, 10)
139
+
140
+ def as_dict(self) -> Dict[str, object]:
141
+ """Plain-dict form."""
142
+ return {
143
+ "key": self.key,
144
+ "occupancy": self.occupancy,
145
+ "current_rate": self.current_rate,
146
+ "new_rate": self.new_rate,
147
+ "change": self.change,
148
+ "band": self.band,
149
+ "clamped": self.clamped,
150
+ }
151
+
152
+
153
+ def _round_to(value: float, step: float) -> float:
154
+ return round(round(value / step) * step, 10)
155
+
156
+
157
+ def recommend_rate(
158
+ occupancy: float,
159
+ current_rate: float,
160
+ policy: PricingPolicy = SFMTA_POLICY,
161
+ key: str = "",
162
+ ) -> PriceRecommendation:
163
+ """Apply the band rule to one occupancy rate (0-1 scale) and current hourly rate."""
164
+ if current_rate < 0:
165
+ raise DataError("current_rate must not be negative")
166
+ band = policy.band_for(occupancy)
167
+ raw = current_rate + band.change
168
+ clamped_value = min(max(raw, policy.min_rate), policy.max_rate)
169
+ new_rate = _round_to(clamped_value, policy.rounding)
170
+ new_rate = min(max(new_rate, policy.min_rate), policy.max_rate)
171
+ return PriceRecommendation(
172
+ key=key,
173
+ occupancy=occupancy,
174
+ current_rate=current_rate,
175
+ new_rate=new_rate,
176
+ band=band.label,
177
+ clamped=not math.isclose(raw, clamped_value),
178
+ )
179
+
180
+
181
+ def recommend_rates(
182
+ occupancy: Mapping[str, Optional[float]],
183
+ rates: Mapping[str, Optional[float]],
184
+ policy: PricingPolicy = SFMTA_POLICY,
185
+ default_rate: Optional[float] = None,
186
+ ) -> List[PriceRecommendation]:
187
+ """Recommendations for many keys.
188
+
189
+ Keys whose occupancy is ``None`` or whose rate is missing (and no
190
+ ``default_rate``) are skipped.
191
+ """
192
+ out: List[PriceRecommendation] = []
193
+ for key, occ in occupancy.items():
194
+ rate = rates.get(key)
195
+ if rate is None:
196
+ rate = default_rate
197
+ if occ is None or rate is None:
198
+ continue
199
+ out.append(recommend_rate(occ, rate, policy, key=key))
200
+ return out
201
+
202
+
203
+ def mean_occupancy(
204
+ rows: Iterable[OccupancyRow], keys: Optional[Sequence[str]] = None
205
+ ) -> Dict[str, Optional[float]]:
206
+ """Pooled occupancy (sum of vehicles / sum of spaces) per key from occupancy rows.
207
+
208
+ ``rows`` are :class:`~parkingstudy.occupancy.OccupancyRow` objects, typically
209
+ pre-filtered to the pricing time band (e.g. weekday 09:00-12:00).
210
+ """
211
+ acc: Dict[str, List[int]] = {}
212
+ for r in rows:
213
+ a = acc.setdefault(r.key, [0, 0])
214
+ a[0] += r.occupied
215
+ a[1] += r.capacity
216
+ order = list(keys) if keys is not None else list(acc)
217
+ return {k: (acc[k][0] / acc[k][1] if k in acc and acc[k][1] else None) for k in order}
@@ -0,0 +1,145 @@
1
+ """Anonymisation of licence plates.
2
+
3
+ Licence plates are personal data in most jurisdictions (for example under the
4
+ EU GDPR and the Turkish KVKK, Law No. 6698). Turnover analysis only needs to
5
+ know that two sightings belong to the *same* vehicle, so plates are replaced
6
+ on input by a keyed hash (HMAC-SHA256, RFC 2104) computed with a secret salt.
7
+
8
+ * The same plate and salt always give the same token, so stays can be linked.
9
+ * Without the salt the token cannot be reversed by brute-forcing the small
10
+ space of valid plates, which an unsalted hash would allow.
11
+ * Use a fresh salt per survey and destroy it afterwards to make tokens
12
+ unlinkable across surveys.
13
+
14
+ Nothing in this package stores or writes raw plates: :class:`~parkingstudy.lpr.Sighting`
15
+ holds only the token, and the CSV readers refuse a raw plate column unless a
16
+ :class:`PlateHasher` is supplied.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import hmac
23
+ import os
24
+ import secrets
25
+ import unicodedata
26
+ from pathlib import Path
27
+ from typing import Dict, Iterable, Iterator, Mapping, Optional, Union
28
+
29
+ from .errors import PrivacyError
30
+
31
+ #: Environment variable read by :meth:`PlateHasher.from_env`.
32
+ SALT_ENV_VAR = "PARKINGSTUDY_SALT"
33
+
34
+ #: Minimum salt length in bytes (128 bits).
35
+ MIN_SALT_BYTES = 16
36
+
37
+
38
+ def generate_salt(nbytes: int = 32) -> str:
39
+ """Return a new random salt as a hex string (``nbytes`` of entropy)."""
40
+ if nbytes < MIN_SALT_BYTES:
41
+ raise ValueError(f"salt must be at least {MIN_SALT_BYTES} bytes")
42
+ return secrets.token_hex(nbytes)
43
+
44
+
45
+ def normalize_plate(raw: str) -> str:
46
+ """Canonical form of a plate before hashing.
47
+
48
+ Unicode NFKC normalisation, upper-casing and removal of every character that
49
+ is not a letter or digit, so ``"34 abc-123"`` and ``"34ABC123"`` give the same
50
+ token. Returns an empty string for blank input.
51
+ """
52
+ text = unicodedata.normalize("NFKC", str(raw)).upper()
53
+ return "".join(ch for ch in text if ch.isalnum())
54
+
55
+
56
+ class PlateHasher:
57
+ """Keyed (salted) hashing of licence plates with HMAC-SHA256.
58
+
59
+ Parameters
60
+ ----------
61
+ salt:
62
+ Secret key, ``str`` or ``bytes``, at least 16 bytes long. Generate one
63
+ with :func:`generate_salt` and keep it out of version control.
64
+ length:
65
+ Number of hex characters kept from the digest (8-64, default 16 = 64
66
+ bits, ample to avoid collisions within one survey).
67
+ prefix:
68
+ Text prepended to every token, so tokens are recognisable as such.
69
+
70
+ The salt is never shown by ``repr`` or ``str``.
71
+ """
72
+
73
+ def __init__(self, salt: Union[str, bytes], *, length: int = 16, prefix: str = "v_") -> None:
74
+ key = salt.encode("utf-8") if isinstance(salt, str) else bytes(salt)
75
+ if len(key) < MIN_SALT_BYTES:
76
+ raise PrivacyError(
77
+ f"salt is too short ({len(key)} bytes); use at least {MIN_SALT_BYTES} bytes, "
78
+ "e.g. parkingstudy.generate_salt()"
79
+ )
80
+ if not 8 <= length <= 64:
81
+ raise ValueError("length must be between 8 and 64 hex characters")
82
+ self._key = key
83
+ self.length = length
84
+ self.prefix = prefix
85
+
86
+ @classmethod
87
+ def from_env(cls, var: str = SALT_ENV_VAR, **kwargs: object) -> PlateHasher:
88
+ """Build a hasher from the salt stored in an environment variable."""
89
+ value = os.environ.get(var)
90
+ if not value:
91
+ raise PrivacyError(f"environment variable {var} is not set or empty")
92
+ return cls(value, **kwargs) # type: ignore[arg-type]
93
+
94
+ @classmethod
95
+ def from_file(cls, path: Union[str, Path], **kwargs: object) -> PlateHasher:
96
+ """Build a hasher from the first line of a salt file."""
97
+ value = Path(path).read_text(encoding="utf-8").strip()
98
+ return cls(value, **kwargs) # type: ignore[arg-type]
99
+
100
+ def hash(self, raw_plate: str) -> str:
101
+ """Return the anonymous token for a plate."""
102
+ plate = normalize_plate(raw_plate)
103
+ if not plate:
104
+ raise PrivacyError("empty licence plate cannot be hashed")
105
+ digest = hmac.new(self._key, plate.encode("utf-8"), hashlib.sha256).hexdigest()
106
+ return self.prefix + digest[: self.length]
107
+
108
+ __call__ = hash
109
+
110
+ def __repr__(self) -> str:
111
+ return f"PlateHasher(salt=<hidden>, length={self.length}, prefix={self.prefix!r})"
112
+
113
+
114
+ def anonymise_rows(
115
+ rows: Iterable[Mapping[str, str]],
116
+ hasher: PlateHasher,
117
+ *,
118
+ plate_column: str = "plate",
119
+ token_column: str = "plate_token",
120
+ ) -> Iterator[Dict[str, str]]:
121
+ """Yield copies of ``rows`` with ``plate_column`` replaced by ``token_column``.
122
+
123
+ The raw plate column is dropped from every output row.
124
+ """
125
+ for row in rows:
126
+ if plate_column not in row:
127
+ raise PrivacyError(f"plate column {plate_column!r} not found")
128
+ out = {k: v for k, v in row.items() if k != plate_column}
129
+ out[token_column] = hasher.hash(row[plate_column] or "")
130
+ yield out
131
+
132
+
133
+ def resolve_hasher(
134
+ salt: Optional[str] = None,
135
+ salt_file: Optional[Union[str, Path]] = None,
136
+ salt_env: Optional[str] = None,
137
+ ) -> Optional[PlateHasher]:
138
+ """Helper for front ends: build a hasher from whichever salt source is given."""
139
+ if salt is not None:
140
+ return PlateHasher(salt)
141
+ if salt_file is not None:
142
+ return PlateHasher.from_file(salt_file)
143
+ if salt_env is not None:
144
+ return PlateHasher.from_env(salt_env)
145
+ return None
parkingstudy/py.typed ADDED
File without changes