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.
@@ -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'
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m ptbr_sampler``."""
2
+
3
+ from ptbr_sampler.cli import main
4
+
5
+ if __name__ == '__main__':
6
+ main()
@@ -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
+ )