ptbr-sampler 0.3.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.
- ptbr_sampler/__init__.py +120 -0
- ptbr_sampler/__main__.py +6 -0
- ptbr_sampler/br_location_class.py +444 -0
- ptbr_sampler/br_name_class.py +1192 -0
- ptbr_sampler/br_rg_class.py +161 -0
- ptbr_sampler/cli.py +941 -0
- ptbr_sampler/data/dataset_manifest.json +1378 -0
- ptbr_sampler/data_origins.py +290 -0
- ptbr_sampler/datasets.py +480 -0
- ptbr_sampler/document_sampler.py +67 -0
- ptbr_sampler/faker.py +691 -0
- ptbr_sampler/sampler.py +960 -0
- ptbr_sampler/sampling/__init__.py +22 -0
- ptbr_sampler/sampling/alias.py +222 -0
- ptbr_sampler/sampling/store.py +216 -0
- ptbr_sampler/sampling/validation.py +190 -0
- ptbr_sampler/sampling/vocabulary.py +291 -0
- ptbr_sampler/utils/__init__.py +1 -0
- ptbr_sampler/utils/address_for_offline.py +723 -0
- ptbr_sampler/utils/cei.py +76 -0
- ptbr_sampler/utils/cep_service.js +26 -0
- ptbr_sampler/utils/cep_wrapper.py +181 -0
- ptbr_sampler/utils/cnpj.py +121 -0
- ptbr_sampler/utils/cpf.py +86 -0
- ptbr_sampler/utils/phone.py +164 -0
- ptbr_sampler/utils/pis.py +77 -0
- ptbr_sampler/utils/util.py +28 -0
- ptbr_sampler-0.3.0.dist-info/METADATA +346 -0
- ptbr_sampler-0.3.0.dist-info/RECORD +33 -0
- ptbr_sampler-0.3.0.dist-info/WHEEL +4 -0
- ptbr_sampler-0.3.0.dist-info/entry_points.txt +3 -0
- ptbr_sampler-0.3.0.dist-info/licenses/LICENSE +21 -0
- ptbr_sampler-0.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +59 -0
ptbr_sampler/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Brazilian Name and Location Generator.
|
|
2
|
+
|
|
3
|
+
This package provides tools for generating realistic Brazilian names and
|
|
4
|
+
locations based on population data and historical naming patterns.
|
|
5
|
+
|
|
6
|
+
Top-level exports:
|
|
7
|
+
sample — ergonomic profile generator (defaults to Census 2022).
|
|
8
|
+
BrazilianLocationSampler — state/city/CEP sampler.
|
|
9
|
+
BrazilianNameSampler — alias-table-backed name sampler.
|
|
10
|
+
TimePeriod — birth-period buckets.
|
|
11
|
+
NameComponents — (first_name, middle_name, surname) triple.
|
|
12
|
+
app, main — Typer CLI (``ptbr`` entry point).
|
|
13
|
+
|
|
14
|
+
The CLI is installed as the ``ptbr`` console script; see ``pyproject.toml``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import random as _stdlib_random
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ptbr_sampler.br_location_class import BrazilianLocationSampler
|
|
21
|
+
from ptbr_sampler.br_name_class import BrazilianNameSampler, NameComponents, TimePeriod
|
|
22
|
+
from ptbr_sampler.cli import app, main
|
|
23
|
+
from ptbr_sampler.datasets import PACKAGE_DATA_DIR
|
|
24
|
+
from ptbr_sampler.sampler import sample as _sampler_function
|
|
25
|
+
|
|
26
|
+
_SAMPLE_DEFAULTS: dict[str, Any] = {
|
|
27
|
+
'qty': 1,
|
|
28
|
+
'city_only': False,
|
|
29
|
+
'state_abbr_only': False,
|
|
30
|
+
'state_full_only': False,
|
|
31
|
+
'only_cep': False,
|
|
32
|
+
'cep_without_dash': False,
|
|
33
|
+
'make_api_call': False,
|
|
34
|
+
'time_period': None,
|
|
35
|
+
'return_only_name': False,
|
|
36
|
+
'name_raw': False,
|
|
37
|
+
'json_path': PACKAGE_DATA_DIR / 'cities_with_ceps.json',
|
|
38
|
+
'names_path': PACKAGE_DATA_DIR / 'names_data.json',
|
|
39
|
+
'middle_names_path': PACKAGE_DATA_DIR / 'middle_names.json',
|
|
40
|
+
'only_surname': False,
|
|
41
|
+
'top_40': False,
|
|
42
|
+
'with_only_one_surname': False,
|
|
43
|
+
'always_middle': False,
|
|
44
|
+
'only_middle': False,
|
|
45
|
+
'always_cpf': True,
|
|
46
|
+
'always_pis': False,
|
|
47
|
+
'always_cnpj': False,
|
|
48
|
+
'always_cei': False,
|
|
49
|
+
'always_rg': True,
|
|
50
|
+
'always_phone': True,
|
|
51
|
+
'only_cpf': False,
|
|
52
|
+
'only_pis': False,
|
|
53
|
+
'only_cnpj': False,
|
|
54
|
+
'only_cei': False,
|
|
55
|
+
'only_rg': False,
|
|
56
|
+
'only_fone': False,
|
|
57
|
+
'include_issuer': True,
|
|
58
|
+
'only_document': False,
|
|
59
|
+
'surnames_path': PACKAGE_DATA_DIR / 'surnames_data.json',
|
|
60
|
+
'locations_path': PACKAGE_DATA_DIR / 'locations_data.json',
|
|
61
|
+
'save_to_jsonl': None,
|
|
62
|
+
'all_data': False,
|
|
63
|
+
'random': False,
|
|
64
|
+
'experimental_uf_sex_conditioning': False,
|
|
65
|
+
'census_year': 2022,
|
|
66
|
+
'conditioning_mode': 'strict',
|
|
67
|
+
'uf_conditioning': False,
|
|
68
|
+
'sex_conditioning': None,
|
|
69
|
+
'uf': None,
|
|
70
|
+
'formatted': True,
|
|
71
|
+
'rng': None,
|
|
72
|
+
'include_address': True,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sample(
|
|
77
|
+
q: int | None = None,
|
|
78
|
+
save_to_jsonl: str | None = None,
|
|
79
|
+
all_data: bool = False,
|
|
80
|
+
random: bool = False,
|
|
81
|
+
experimental_uf_sex_conditioning: bool = False,
|
|
82
|
+
census_year: int = 2022,
|
|
83
|
+
conditioning_mode: str = 'strict',
|
|
84
|
+
uf_conditioning: bool = False,
|
|
85
|
+
uf: str | None = None,
|
|
86
|
+
rng: _stdlib_random.Random | None = None,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> dict | list[dict]:
|
|
89
|
+
"""Generate random Brazilian name, location, and document samples.
|
|
90
|
+
|
|
91
|
+
Defaults to IBGE Censo 2022 (``census_year=2022``, ``TimePeriod.ALL``,
|
|
92
|
+
``conditioning_mode='strict'``). Pass ``census_year=2010`` to opt into the
|
|
93
|
+
historical 2010 vintage, ``uf='SP'`` to restrict draws to a single UF, or
|
|
94
|
+
``rng=random.Random(seed)`` for isolated reproducible streams.
|
|
95
|
+
"""
|
|
96
|
+
merged = {**_SAMPLE_DEFAULTS, **kwargs}
|
|
97
|
+
merged['q'] = q
|
|
98
|
+
merged['save_to_jsonl'] = save_to_jsonl
|
|
99
|
+
merged['all_data'] = all_data
|
|
100
|
+
merged['random'] = random
|
|
101
|
+
merged['experimental_uf_sex_conditioning'] = experimental_uf_sex_conditioning
|
|
102
|
+
merged['census_year'] = census_year
|
|
103
|
+
merged['conditioning_mode'] = conditioning_mode
|
|
104
|
+
merged['uf_conditioning'] = uf_conditioning
|
|
105
|
+
merged['uf'] = uf
|
|
106
|
+
merged['rng'] = rng
|
|
107
|
+
return _sampler_function(**merged)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
'BrazilianLocationSampler',
|
|
112
|
+
'BrazilianNameSampler',
|
|
113
|
+
'NameComponents',
|
|
114
|
+
'TimePeriod',
|
|
115
|
+
'app',
|
|
116
|
+
'main',
|
|
117
|
+
'sample',
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
__version__ = '0.3.0'
|
ptbr_sampler/__main__.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrazilianLocationSampler:
|
|
12
|
+
"""Brazilian location sampling class for generating realistic location data."""
|
|
13
|
+
|
|
14
|
+
VALID_UFS: frozenset[str] = frozenset(
|
|
15
|
+
{
|
|
16
|
+
'AC',
|
|
17
|
+
'AL',
|
|
18
|
+
'AM',
|
|
19
|
+
'AP',
|
|
20
|
+
'BA',
|
|
21
|
+
'CE',
|
|
22
|
+
'DF',
|
|
23
|
+
'ES',
|
|
24
|
+
'GO',
|
|
25
|
+
'MA',
|
|
26
|
+
'MG',
|
|
27
|
+
'MS',
|
|
28
|
+
'MT',
|
|
29
|
+
'PA',
|
|
30
|
+
'PB',
|
|
31
|
+
'PE',
|
|
32
|
+
'PI',
|
|
33
|
+
'PR',
|
|
34
|
+
'RJ',
|
|
35
|
+
'RN',
|
|
36
|
+
'RO',
|
|
37
|
+
'RR',
|
|
38
|
+
'RS',
|
|
39
|
+
'SC',
|
|
40
|
+
'SE',
|
|
41
|
+
'SP',
|
|
42
|
+
'TO',
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self, json_file_path: str | Path | dict):
|
|
47
|
+
"""Initialize the sampler with population data from JSON files or a dict.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
json_file_path: Path to main JSON file with population data, or
|
|
51
|
+
a pre-loaded dictionary with the same structure.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If required data is missing or invalid
|
|
55
|
+
FileNotFoundError: If JSON files cannot be found
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(json_file_path, dict):
|
|
58
|
+
self.data = json_file_path
|
|
59
|
+
else:
|
|
60
|
+
with Path(json_file_path).open(encoding='utf-8') as file:
|
|
61
|
+
self.data = json.load(file)
|
|
62
|
+
|
|
63
|
+
# Ensure we have required data
|
|
64
|
+
if not self.data.get('cities') or not self.data.get('states'):
|
|
65
|
+
raise ValueError("Missing 'cities' or 'states' data in JSON file")
|
|
66
|
+
|
|
67
|
+
# Pre-calculate weights for more efficient sampling
|
|
68
|
+
self._calculate_weights()
|
|
69
|
+
|
|
70
|
+
def update_cities(self, cities_data: dict) -> None:
|
|
71
|
+
"""Update the cities data and recalculate weights.
|
|
72
|
+
|
|
73
|
+
This method allows updating the cities data after initialization,
|
|
74
|
+
which is useful when loading custom location data.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
cities_data: Dictionary containing city data to update or add
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
TypeError: If cities_data is not a valid dictionary
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(cities_data, dict):
|
|
83
|
+
raise TypeError('cities_data must be a dictionary')
|
|
84
|
+
|
|
85
|
+
# Update the cities data
|
|
86
|
+
if cities_data:
|
|
87
|
+
self.data['cities'].update(cities_data)
|
|
88
|
+
|
|
89
|
+
# Recalculate weights to ensure proper sampling
|
|
90
|
+
self._calculate_weights()
|
|
91
|
+
|
|
92
|
+
def update_states(self, states_data: dict) -> None:
|
|
93
|
+
"""Update the states data and recalculate weights.
|
|
94
|
+
|
|
95
|
+
This method allows updating the states data after initialization,
|
|
96
|
+
which is useful when loading custom location data.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
states_data: Dictionary containing state data to update or add
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
TypeError: If states_data is not a valid dictionary
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(states_data, dict):
|
|
105
|
+
raise TypeError('states_data must be a dictionary')
|
|
106
|
+
|
|
107
|
+
# Update the states data
|
|
108
|
+
if states_data:
|
|
109
|
+
self.data['states'].update(states_data)
|
|
110
|
+
|
|
111
|
+
# Recalculate weights to ensure proper sampling
|
|
112
|
+
self._calculate_weights()
|
|
113
|
+
|
|
114
|
+
def _calculate_weights(self) -> None:
|
|
115
|
+
"""Pre-calculate weights for states and cities based on population percentages."""
|
|
116
|
+
# Calculate state weights
|
|
117
|
+
self.state_weights = []
|
|
118
|
+
self.state_names = []
|
|
119
|
+
|
|
120
|
+
for state_name, state_data in self.data['states'].items():
|
|
121
|
+
self.state_names.append(state_name)
|
|
122
|
+
self.state_weights.append(state_data['population_percentage'])
|
|
123
|
+
|
|
124
|
+
# Normalize state weights to sum to 1
|
|
125
|
+
total_weight = sum(self.state_weights)
|
|
126
|
+
self.state_weights = [w / total_weight for w in self.state_weights]
|
|
127
|
+
|
|
128
|
+
# Calculate city weights per state
|
|
129
|
+
self.city_weights_by_state = {}
|
|
130
|
+
self.city_names_by_state = {}
|
|
131
|
+
self.city_data_by_name = {}
|
|
132
|
+
self.city_data_by_uf_and_name: dict[tuple[str, str], dict[str, Any]] = {}
|
|
133
|
+
|
|
134
|
+
for city_data in self.data['cities'].values():
|
|
135
|
+
state = city_data['city_uf']
|
|
136
|
+
city_name = city_data['city_name']
|
|
137
|
+
|
|
138
|
+
if state not in self.city_weights_by_state:
|
|
139
|
+
self.city_weights_by_state[state] = []
|
|
140
|
+
self.city_names_by_state[state] = []
|
|
141
|
+
|
|
142
|
+
self.city_names_by_state[state].append(city_name)
|
|
143
|
+
self.city_weights_by_state[state].append(city_data['population_percentage_state'])
|
|
144
|
+
self.city_data_by_name[city_name] = city_data
|
|
145
|
+
self.city_data_by_uf_and_name[(str(state).strip().upper(), city_name)] = city_data
|
|
146
|
+
|
|
147
|
+
# Normalize city weights within each state
|
|
148
|
+
for state in self.city_weights_by_state:
|
|
149
|
+
total = sum(self.city_weights_by_state[state])
|
|
150
|
+
if total > 0:
|
|
151
|
+
self.city_weights_by_state[state] = [w / total for w in self.city_weights_by_state[state]]
|
|
152
|
+
|
|
153
|
+
# Build reverse lookup: state abbreviation -> full state name
|
|
154
|
+
self.abbr_to_name: dict[str, str] = {data['state_abbr']: name for name, data in self.data['states'].items()}
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def normalize_uf(uf: str | None) -> str | None:
|
|
158
|
+
"""Validate and uppercase an optional two-letter Brazilian UF code."""
|
|
159
|
+
if uf is None:
|
|
160
|
+
return None
|
|
161
|
+
if not isinstance(uf, str):
|
|
162
|
+
raise TypeError(f'uf must be a two-letter string or None; got {type(uf).__name__}')
|
|
163
|
+
cleaned = uf.strip().upper()
|
|
164
|
+
if cleaned not in BrazilianLocationSampler.VALID_UFS:
|
|
165
|
+
raise ValueError(f'Unknown UF abbreviation: {uf!r}')
|
|
166
|
+
return cleaned
|
|
167
|
+
|
|
168
|
+
def _resolve_uf(self, uf: str | None) -> str | None:
|
|
169
|
+
uf_norm = self.normalize_uf(uf)
|
|
170
|
+
if uf_norm is not None and uf_norm not in self.abbr_to_name:
|
|
171
|
+
raise ValueError(f'UF {uf_norm!r} is not present in loaded location data')
|
|
172
|
+
return uf_norm
|
|
173
|
+
|
|
174
|
+
def get_state(
|
|
175
|
+
self,
|
|
176
|
+
random_mode: bool = False,
|
|
177
|
+
*,
|
|
178
|
+
uf: str | None = None,
|
|
179
|
+
rng: random.Random | None = None,
|
|
180
|
+
) -> tuple[str, str]:
|
|
181
|
+
"""Get a random state weighted by population percentage.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
random_mode: When ``True``, ignore population weights and pick
|
|
185
|
+
uniformly across the 27 UFs.
|
|
186
|
+
uf: Optional fixed UF abbreviation (e.g. ``'SP'``).
|
|
187
|
+
rng: Optional ``random.Random`` instance; defaults to module ``random``.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Tuple of (state_name, state_abbreviation)
|
|
191
|
+
"""
|
|
192
|
+
uf_norm = self._resolve_uf(uf)
|
|
193
|
+
if uf_norm is not None:
|
|
194
|
+
return self.abbr_to_name[uf_norm], uf_norm
|
|
195
|
+
r = rng if rng is not None else random
|
|
196
|
+
weights = None if random_mode else self.state_weights
|
|
197
|
+
state_name = r.choices(self.state_names, weights=weights, k=1)[0]
|
|
198
|
+
state_abbr = self.data['states'][state_name]['state_abbr']
|
|
199
|
+
return state_name, state_abbr
|
|
200
|
+
|
|
201
|
+
def get_city(
|
|
202
|
+
self,
|
|
203
|
+
state_abbr: str | None = None,
|
|
204
|
+
random_mode: bool = False,
|
|
205
|
+
*,
|
|
206
|
+
rng: random.Random | None = None,
|
|
207
|
+
) -> tuple[str, str]:
|
|
208
|
+
"""Get a random city weighted by population percentage."""
|
|
209
|
+
r = rng if rng is not None else random
|
|
210
|
+
if state_abbr is None:
|
|
211
|
+
if rng is None:
|
|
212
|
+
_, state_abbr = self.get_state(random_mode=random_mode)
|
|
213
|
+
else:
|
|
214
|
+
_, state_abbr = self.get_state(random_mode=random_mode, rng=rng)
|
|
215
|
+
else:
|
|
216
|
+
state_abbr = state_abbr.strip().upper()
|
|
217
|
+
|
|
218
|
+
if state_abbr not in self.city_weights_by_state:
|
|
219
|
+
raise ValueError(f'No cities found for state: {state_abbr}')
|
|
220
|
+
|
|
221
|
+
weights = None if random_mode else self.city_weights_by_state[state_abbr]
|
|
222
|
+
city_name = r.choices(self.city_names_by_state[state_abbr], weights=weights, k=1)[0]
|
|
223
|
+
|
|
224
|
+
return city_name, state_abbr
|
|
225
|
+
|
|
226
|
+
def get_state_and_city(
|
|
227
|
+
self,
|
|
228
|
+
random_mode: bool = False,
|
|
229
|
+
*,
|
|
230
|
+
uf: str | None = None,
|
|
231
|
+
rng: random.Random | None = None,
|
|
232
|
+
) -> tuple[str, str, str]:
|
|
233
|
+
"""Get a random state and city combination weighted by population percentage."""
|
|
234
|
+
state_kwargs: dict[str, object] = {'random_mode': random_mode}
|
|
235
|
+
if uf is not None:
|
|
236
|
+
state_kwargs['uf'] = uf
|
|
237
|
+
if rng is not None:
|
|
238
|
+
state_kwargs['rng'] = rng
|
|
239
|
+
state_name, state_abbr = self.get_state(**state_kwargs) # type: ignore[arg-type]
|
|
240
|
+
if rng is None:
|
|
241
|
+
city_name, _ = self.get_city(state_abbr, random_mode=random_mode)
|
|
242
|
+
else:
|
|
243
|
+
city_name, _ = self.get_city(state_abbr, random_mode=random_mode, rng=rng)
|
|
244
|
+
return state_name, state_abbr, city_name
|
|
245
|
+
|
|
246
|
+
def batch_sample_locations(
|
|
247
|
+
self,
|
|
248
|
+
n: int,
|
|
249
|
+
random_mode: bool = False,
|
|
250
|
+
*,
|
|
251
|
+
uf: str | None = None,
|
|
252
|
+
rng: random.Random | None = None,
|
|
253
|
+
) -> list[dict[str, str]]:
|
|
254
|
+
"""Draw n location samples with batched random calls."""
|
|
255
|
+
uf_norm = self._resolve_uf(uf)
|
|
256
|
+
if n <= 0:
|
|
257
|
+
return []
|
|
258
|
+
r = rng if rng is not None else random
|
|
259
|
+
|
|
260
|
+
if uf_norm is not None:
|
|
261
|
+
state_name = self.abbr_to_name[uf_norm]
|
|
262
|
+
if uf_norm in self.city_names_by_state:
|
|
263
|
+
city_weights = None if random_mode else self.city_weights_by_state[uf_norm]
|
|
264
|
+
cities = r.choices(
|
|
265
|
+
self.city_names_by_state[uf_norm],
|
|
266
|
+
weights=city_weights,
|
|
267
|
+
k=n,
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
logger.warning(
|
|
271
|
+
"No cities found for state '%s' (%s); emitting 'Unknown' for %d draw(s).",
|
|
272
|
+
state_name,
|
|
273
|
+
uf_norm,
|
|
274
|
+
n,
|
|
275
|
+
)
|
|
276
|
+
cities = ['Unknown'] * n
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
'state_name': state_name,
|
|
280
|
+
'state_abbr': uf_norm,
|
|
281
|
+
'city_name': city_name,
|
|
282
|
+
}
|
|
283
|
+
for city_name in cities
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
# Stage 1: batch-draw all states (self.state_names holds full names)
|
|
287
|
+
state_weights = None if random_mode else self.state_weights
|
|
288
|
+
drawn_state_names = r.choices(self.state_names, weights=state_weights, k=n)
|
|
289
|
+
|
|
290
|
+
# Map full names to abbreviations for city lookup
|
|
291
|
+
state_abbr_map = {name: self.data['states'][name]['state_abbr'] for name in set(drawn_state_names)}
|
|
292
|
+
|
|
293
|
+
# Stage 2: count per-state, batch-draw cities
|
|
294
|
+
state_name_counts = Counter(drawn_state_names)
|
|
295
|
+
city_draws: dict[str, list[str]] = {}
|
|
296
|
+
|
|
297
|
+
for state_name, count in state_name_counts.items():
|
|
298
|
+
abbr = state_abbr_map[state_name]
|
|
299
|
+
if abbr in self.city_names_by_state:
|
|
300
|
+
city_weights = None if random_mode else self.city_weights_by_state[abbr]
|
|
301
|
+
city_draws[state_name] = r.choices(
|
|
302
|
+
self.city_names_by_state[abbr],
|
|
303
|
+
weights=city_weights,
|
|
304
|
+
k=count,
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
logger.warning(
|
|
308
|
+
"No cities found for state '%s' (%s); emitting 'Unknown' for %d draw(s).",
|
|
309
|
+
state_name,
|
|
310
|
+
abbr,
|
|
311
|
+
count,
|
|
312
|
+
)
|
|
313
|
+
city_draws[state_name] = ['Unknown'] * count
|
|
314
|
+
|
|
315
|
+
# Stage 3: reassemble in original order
|
|
316
|
+
state_idx: dict[str, int] = dict.fromkeys(state_name_counts, 0)
|
|
317
|
+
results: list[dict[str, str]] = []
|
|
318
|
+
|
|
319
|
+
for state_name in drawn_state_names:
|
|
320
|
+
abbr = state_abbr_map[state_name]
|
|
321
|
+
city_name = city_draws[state_name][state_idx[state_name]]
|
|
322
|
+
state_idx[state_name] += 1
|
|
323
|
+
results.append(
|
|
324
|
+
{
|
|
325
|
+
'state_name': state_name,
|
|
326
|
+
'state_abbr': abbr,
|
|
327
|
+
'city_name': city_name,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return results
|
|
332
|
+
|
|
333
|
+
def get_city_data(self, city_name: str, state_abbr: str | None = None) -> dict[str, Any]:
|
|
334
|
+
"""Resolve city metadata by ``(state_abbr, city_name)`` when UF is known, else by ``city_name``."""
|
|
335
|
+
if state_abbr is not None:
|
|
336
|
+
uf_key = state_abbr.strip().upper()
|
|
337
|
+
if (uf_key, city_name) in self.city_data_by_uf_and_name:
|
|
338
|
+
return self.city_data_by_uf_and_name[(uf_key, city_name)]
|
|
339
|
+
return {}
|
|
340
|
+
return self.city_data_by_name.get(city_name, {})
|
|
341
|
+
|
|
342
|
+
def _get_random_cep_for_city(
|
|
343
|
+
self,
|
|
344
|
+
city_name: str,
|
|
345
|
+
*,
|
|
346
|
+
state_abbr: str | None = None,
|
|
347
|
+
rng: random.Random | None = None,
|
|
348
|
+
) -> str:
|
|
349
|
+
"""Generate random CEP from city's available CEPs or CEP range."""
|
|
350
|
+
city_data = self.get_city_data(city_name, state_abbr)
|
|
351
|
+
if not city_data:
|
|
352
|
+
raise ValueError(f'City not found: {city_name}')
|
|
353
|
+
|
|
354
|
+
r = rng if rng is not None else random
|
|
355
|
+
|
|
356
|
+
# Try using specific CEPs first
|
|
357
|
+
if city_data.get('ceps'):
|
|
358
|
+
return r.choice(city_data['ceps'])
|
|
359
|
+
|
|
360
|
+
# Fall back to generating from CEP range if available.
|
|
361
|
+
if city_data.get('cep_range_begins') and city_data.get('cep_range_ends'):
|
|
362
|
+
range_start = int(city_data['cep_range_begins'].replace('-', ''))
|
|
363
|
+
range_end = int(city_data['cep_range_ends'].replace('-', ''))
|
|
364
|
+
# Always zero-pad to 8 digits so leading zeros aren't lost when
|
|
365
|
+
# the numeric range straddles value<10_000_000 (e.g., starts "01").
|
|
366
|
+
return f'{r.randint(range_start, range_end):08d}'
|
|
367
|
+
|
|
368
|
+
def _format_cep(self, cep: str, with_dash: bool = True) -> str:
|
|
369
|
+
"""Format CEP string with optional dash."""
|
|
370
|
+
cep = cep.replace('-', '')
|
|
371
|
+
return f'{cep[:5]}-{cep[5:]}' if with_dash else cep
|
|
372
|
+
|
|
373
|
+
def format_full_location(
|
|
374
|
+
self,
|
|
375
|
+
city: str,
|
|
376
|
+
state: str,
|
|
377
|
+
state_abbr: str,
|
|
378
|
+
include_cep: bool = True,
|
|
379
|
+
cep_without_dash: bool = False,
|
|
380
|
+
name: str | None = None,
|
|
381
|
+
*,
|
|
382
|
+
rng: random.Random | None = None,
|
|
383
|
+
) -> str:
|
|
384
|
+
"""Format location information into a single string."""
|
|
385
|
+
base = f'{city}, {state} ({state_abbr})'
|
|
386
|
+
if not include_cep:
|
|
387
|
+
return base
|
|
388
|
+
|
|
389
|
+
parts = [base]
|
|
390
|
+
|
|
391
|
+
if include_cep:
|
|
392
|
+
cep = self._get_random_cep_for_city(city, state_abbr=state_abbr, rng=rng)
|
|
393
|
+
formatted_cep = self._format_cep(cep, not cep_without_dash)
|
|
394
|
+
parts.append(formatted_cep)
|
|
395
|
+
|
|
396
|
+
return ', '.join(parts)
|
|
397
|
+
|
|
398
|
+
def get_random_location(
|
|
399
|
+
self,
|
|
400
|
+
city_only: bool = False,
|
|
401
|
+
state_abbr_only: bool = False,
|
|
402
|
+
state_full_only: bool = False,
|
|
403
|
+
only_cep: bool = False,
|
|
404
|
+
cep_without_dash: bool = False,
|
|
405
|
+
random_mode: bool = False,
|
|
406
|
+
*,
|
|
407
|
+
uf: str | None = None,
|
|
408
|
+
rng: random.Random | None = None,
|
|
409
|
+
) -> str:
|
|
410
|
+
"""Get a random location with various formatting options."""
|
|
411
|
+
uf_norm = self._resolve_uf(uf)
|
|
412
|
+
state_kwargs: dict[str, object] = {'random_mode': random_mode}
|
|
413
|
+
if uf_norm is not None:
|
|
414
|
+
state_kwargs['uf'] = uf_norm
|
|
415
|
+
if rng is not None:
|
|
416
|
+
state_kwargs['rng'] = rng
|
|
417
|
+
|
|
418
|
+
city_kwargs: dict[str, object] = {'random_mode': random_mode}
|
|
419
|
+
if rng is not None:
|
|
420
|
+
city_kwargs['rng'] = rng
|
|
421
|
+
|
|
422
|
+
if only_cep:
|
|
423
|
+
city_name, city_uf = self.get_city(uf_norm, **city_kwargs) # type: ignore[arg-type]
|
|
424
|
+
cep = self._get_random_cep_for_city(city_name, state_abbr=city_uf, rng=rng)
|
|
425
|
+
return self._format_cep(cep, not cep_without_dash)
|
|
426
|
+
|
|
427
|
+
if state_abbr_only:
|
|
428
|
+
return self.get_state(**state_kwargs)[1] # type: ignore[arg-type]
|
|
429
|
+
|
|
430
|
+
if state_full_only:
|
|
431
|
+
return self.get_state(**state_kwargs)[0] # type: ignore[arg-type]
|
|
432
|
+
|
|
433
|
+
if city_only:
|
|
434
|
+
return self.get_city(uf_norm, **city_kwargs)[0] # type: ignore[arg-type]
|
|
435
|
+
|
|
436
|
+
state_name, state_abbr, city_name = self.get_state_and_city(**state_kwargs) # type: ignore[arg-type]
|
|
437
|
+
return self.format_full_location(
|
|
438
|
+
city_name,
|
|
439
|
+
state_name,
|
|
440
|
+
state_abbr,
|
|
441
|
+
True,
|
|
442
|
+
cep_without_dash,
|
|
443
|
+
rng=rng,
|
|
444
|
+
)
|