Total Complexity | 98 |
Total Lines | 429 |
Duplicated Lines | 0 % |
Complex classes like postal_address.Address often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | # -*- coding: utf-8 -*- |
||
82 | class Address(object): |
||
83 | |||
84 | """ Define a postal address. |
||
85 | |||
86 | All addresses share the following fields: |
||
87 | * ``line1`` (required): a non-constrained string. |
||
88 | * ``line2``: a non-constrained string. |
||
89 | * ``postal_code`` (required): a non-constrained string (see issue #2). |
||
90 | * ``city_name`` (required): a non-constrained string. |
||
91 | * ``country_code`` (required): an ISO 3166-1 alpha-2 code. |
||
92 | * ``subdivision_code``: an ISO 3166-2 code. |
||
93 | |||
94 | At instanciation, the ``normalize()`` method is called. The latter try to |
||
95 | clean-up the data and populate empty fields that can be derived from |
||
96 | others. As such, ``city_name`` can be overriden by ``subdivision_code``. |
||
97 | See the internal ``SUBDIVISION_METADATA_WHITELIST`` constant. |
||
98 | |||
99 | If inconsistencies are found at the normalization step, they are left as-is |
||
100 | to give a chance to the ``validate()`` method to catch them. Which means |
||
101 | that, after each normalization (including the one at initialization), it is |
||
102 | your job to call the ``validate()`` method manually to check that the |
||
103 | address is good. |
||
104 | """ |
||
105 | |||
106 | # All normalized field's IDs and values of the address are stored here. |
||
107 | # _fields = {} |
||
108 | |||
109 | # Fields common to any postal address. Those are free-form fields, allowed |
||
110 | # to be set directly by the user, although their values might be normalized |
||
111 | # and clean-up automatticaly by the validation method. |
||
112 | BASE_FIELD_IDS = frozenset([ |
||
113 | 'line1', 'line2', 'postal_code', 'city_name', 'country_code', |
||
114 | 'subdivision_code']) |
||
115 | |||
116 | # List of subdivision-derived metadata IDs which are allowed to collide |
||
117 | # with base field IDs. |
||
118 | SUBDIVISION_METADATA_WHITELIST = frozenset(['city_name']) |
||
119 | assert SUBDIVISION_METADATA_WHITELIST.issubset(BASE_FIELD_IDS) |
||
120 | |||
121 | # Fields tested on validate(). |
||
122 | REQUIRED_FIELDS = frozenset([ |
||
123 | 'line1', 'postal_code', 'city_name', 'country_code']) |
||
124 | assert REQUIRED_FIELDS.issubset(BASE_FIELD_IDS) |
||
125 | |||
126 | def __init__(self, strict=True, **kwargs): |
||
127 | """ Set address' individual fields and normalize them. |
||
128 | |||
129 | By default, normalization is ``strict``. |
||
130 | """ |
||
131 | # Only common fields are allowed to be set directly. |
||
132 | unknown_fields = set(kwargs).difference(self.BASE_FIELD_IDS) |
||
133 | if unknown_fields: |
||
134 | raise KeyError( |
||
135 | "{!r} fields are not allowed to be set freely.".format( |
||
136 | unknown_fields)) |
||
137 | # Initialize base fields values. |
||
138 | self._fields = dict.fromkeys(self.BASE_FIELD_IDS) |
||
139 | # Load provided fields. |
||
140 | for field_id, field_value in kwargs.items(): |
||
141 | self[field_id] = field_value |
||
142 | # Normalize addresses fields. |
||
143 | self.normalize(strict=strict) |
||
144 | |||
145 | def __repr__(self): |
||
146 | """ Print all fields available from the address. |
||
147 | |||
148 | Also include internal fields disguised as properties. |
||
149 | """ |
||
150 | # Repr all plain fields. |
||
151 | fields_repr = ['{}={!r}'.format(k, v) for k, v in self.items()] |
||
152 | # Repr all internal properties. |
||
153 | for internal_id in [ |
||
154 | 'valid', 'empty', 'country_name', 'subdivision_name', |
||
155 | 'subdivision_type_name', 'subdivision_type_id']: |
||
156 | fields_repr.append( |
||
157 | '{}={!r}'.format(internal_id, getattr(self, internal_id))) |
||
158 | return '{}({})'.format( |
||
159 | self.__class__.__name__, ', '.join(sorted(fields_repr))) |
||
160 | |||
161 | def __str__(self): |
||
162 | """ Return a simple string representation of the address block. """ |
||
163 | return self.render() |
||
164 | |||
165 | def __getattr__(self, name): |
||
166 | """ Expose fields as attributes. """ |
||
167 | if name in self._fields: |
||
168 | return self._fields[name] |
||
169 | raise AttributeError |
||
170 | |||
171 | def __setattr__(self, name, value): |
||
172 | """ Allow update of address fields as attributes. """ |
||
173 | if name in self.BASE_FIELD_IDS: |
||
174 | self[name] = value |
||
175 | return |
||
176 | super(Address, self).__setattr__(name, value) |
||
177 | |||
178 | # Let an address be accessed like a dict of its fields IDs & values. |
||
179 | # This is a proxy to the internal _fields dict. |
||
180 | |||
181 | def __len__(self): |
||
182 | """ Return the number of fields. """ |
||
183 | return len(self._fields) |
||
184 | |||
185 | def __getitem__(self, key): |
||
186 | """ Return the value of a field. """ |
||
187 | if not isinstance(key, basestring): |
||
188 | raise TypeError |
||
189 | return self._fields[key] |
||
190 | |||
191 | def __setitem__(self, key, value): |
||
192 | """ Set a field's value. |
||
193 | |||
194 | Only base fields are allowed to be set explicitely. |
||
195 | """ |
||
196 | if not isinstance(key, basestring): |
||
197 | raise TypeError |
||
198 | if not (isinstance(value, basestring) or value is None): |
||
199 | raise TypeError |
||
200 | if key not in self.BASE_FIELD_IDS: |
||
201 | raise KeyError |
||
202 | self._fields[key] = value |
||
203 | |||
204 | def __delitem__(self, key): |
||
205 | """ Remove a field. """ |
||
206 | if key in self.BASE_FIELD_IDS: |
||
207 | self._fields[key] = None |
||
208 | else: |
||
209 | del self._fields[key] |
||
210 | |||
211 | def __iter__(self): |
||
212 | """ Iterate over field IDs. """ |
||
213 | for field_id in self._fields: |
||
214 | yield field_id |
||
215 | |||
216 | def keys(self): |
||
217 | """ Return a list of field IDs. """ |
||
218 | return self._fields.keys() |
||
219 | |||
220 | def values(self): |
||
221 | """ Return a list of field values. """ |
||
222 | return self._fields.values() |
||
223 | |||
224 | def items(self): |
||
225 | """ Return a list of field IDs & values. """ |
||
226 | return self._fields.items() |
||
227 | |||
228 | def render(self, separator='\n'): |
||
229 | """ Render a human-friendly address block. |
||
230 | |||
231 | The block is composed of: |
||
232 | * The ``line1`` field rendered as-is if not empty. |
||
233 | * The ``line2`` field rendered as-is if not empty. |
||
234 | * A third line made of the postal code, the city name and state name if |
||
235 | any is set. |
||
236 | * A fourth optionnal line with the subdivision name if its value does |
||
237 | not overlap with the city, state or country name. |
||
238 | * The last line feature country's common name. |
||
239 | """ |
||
240 | lines = [] |
||
241 | |||
242 | if self.line1: |
||
243 | lines.append(self.line1) |
||
244 | |||
245 | if self.line2: |
||
246 | lines.append(self.line2) |
||
247 | |||
248 | # Build the third line. |
||
249 | line3_elements = [] |
||
250 | if self.city_name: |
||
251 | line3_elements.append(self.city_name) |
||
252 | if hasattr(self, 'state_name'): |
||
253 | line3_elements.append(self.state_name) |
||
254 | # Separate city and state by a comma. |
||
255 | line3_elements = [', '.join(line3_elements)] |
||
256 | if self.postal_code: |
||
257 | line3_elements.insert(0, self.postal_code) |
||
258 | # Separate the leading zip code and the rest by a dash. |
||
259 | line3 = ' - '.join(line3_elements) |
||
260 | if line3: |
||
261 | lines.append(line3) |
||
262 | |||
263 | # Compare the vanilla subdivision name to properties that are based on |
||
264 | # it and used in the current ``render()`` method to produce a printable |
||
265 | # address. If none overlap, then print an additional line with the |
||
266 | # subdivision name as-is to provide extra, non-redundant, territory |
||
267 | # precision. |
||
268 | subdiv_based_properties = [ |
||
269 | 'city_name', 'state_name', 'country_name'] |
||
270 | subdiv_based_values = [ |
||
271 | getattr(self, prop_id) for prop_id in subdiv_based_properties |
||
272 | if hasattr(self, prop_id)] |
||
273 | if self.subdivision_name and \ |
||
274 | self.subdivision_name not in subdiv_based_values: |
||
275 | lines.append(self.subdivision_name) |
||
276 | |||
277 | # Place the country line at the end. |
||
278 | if self.country_name: |
||
279 | lines.append(self.country_name) |
||
280 | |||
281 | # Render the address block with the provided separator. |
||
282 | return separator.join(lines) |
||
283 | |||
284 | def normalize(self, strict=True): |
||
285 | """ Normalize address fields. |
||
286 | |||
287 | If values are unrecognized or invalid, they will be set to None. |
||
288 | |||
289 | By default, the normalization is ``strict``: metadata derived from |
||
290 | territory's parents are not allowed to overwrite valid address fields |
||
291 | entered by the user. If set to ``False``, territory-derived values |
||
292 | takes precedence over user's. |
||
293 | |||
294 | You need to call back the ``validate()`` method afterwards to properly |
||
295 | check that the fully-qualified address is ready for consumption. |
||
296 | """ |
||
297 | # Strip postal codes of any characters but alphanumerics, spaces and |
||
298 | # hyphens. |
||
299 | if self.postal_code: |
||
300 | self.postal_code = self.postal_code.upper() |
||
301 | # Remove unrecognized characters. |
||
302 | self.postal_code = re.compile( |
||
303 | r'[^A-Z0-9 -]').sub('', self.postal_code) |
||
304 | # Reduce sequences of mixed hyphens and spaces to single hyphen. |
||
305 | self.postal_code = re.compile( |
||
306 | r'[^A-Z0-9]*-+[^A-Z0-9]*').sub('-', self.postal_code) |
||
307 | # Edge case: remove leading and trailing hyphens and spaces. |
||
308 | self.postal_code = self.postal_code.strip('-') |
||
309 | |||
310 | # Normalize spaces. |
||
311 | for field_id, field_value in self.items(): |
||
312 | if isinstance(field_value, basestring): |
||
313 | self[field_id] = ' '.join(field_value.split()) |
||
314 | |||
315 | # Reset empty and blank strings. |
||
316 | empty_fields = [f_id for f_id, f_value in self.items() if not f_value] |
||
317 | for field_id in empty_fields: |
||
318 | del self[field_id] |
||
319 | |||
320 | # Swap lines if the first is empty. |
||
321 | if self.line2 and not self.line1: |
||
322 | self.line1, self.line2 = self.line2, self.line1 |
||
323 | |||
324 | # Normalize territory codes. Unrecognized territory codes are reset |
||
325 | # to None. |
||
326 | for territory_id in ['country_code', 'subdivision_code']: |
||
327 | territory_code = getattr(self, territory_id) |
||
328 | if territory_code: |
||
329 | try: |
||
330 | code = normalize_territory_code( |
||
331 | territory_code, resolve_aliases=False) |
||
332 | except ValueError: |
||
333 | code = None |
||
334 | setattr(self, territory_id, code) |
||
335 | |||
336 | # Try to set default subdivision from country if not set. |
||
337 | if self.country_code and not self.subdivision_code: |
||
338 | self.subdivision_code = default_subdivision_code(self.country_code) |
||
339 | # If the country set its own subdivision, reset it. It will be |
||
340 | # properly re-guessed below. |
||
341 | if self.subdivision_code: |
||
342 | self.country_code = None |
||
343 | |||
344 | # Automaticcaly populate address fields with metadata extracted from |
||
345 | # all subdivision parents. |
||
346 | if self.subdivision_code: |
||
347 | parent_metadata = { |
||
348 | # All subdivisions have a parent country. |
||
349 | 'country_code': country_from_subdivision( |
||
350 | self.subdivision_code)} |
||
351 | |||
352 | # Add metadata of each subdivision parent. |
||
353 | for parent_subdiv in territory_parents( |
||
354 | self.subdivision_code, include_country=False): |
||
355 | parent_metadata.update(subdivision_metadata(parent_subdiv)) |
||
356 | |||
357 | # Parent metadata are not allowed to overwrite address fields |
||
358 | # if not blank, unless strict mode is de-activated. |
||
359 | if strict: |
||
360 | for field_id, new_value in parent_metadata.items(): |
||
361 | # New metadata are not allowed to be blank. |
||
362 | assert new_value |
||
363 | current_value = self._fields.get(field_id) |
||
364 | if current_value and field_id in self.BASE_FIELD_IDS: |
||
365 | |||
366 | # Build the list of substitute values that are |
||
367 | # equivalent to our new normalized target. |
||
368 | alias_values = set([new_value]) |
||
369 | if field_id == 'country_code': |
||
370 | # Allow normalization if the current country code |
||
371 | # is the direct parent of a subdivision which also |
||
372 | # have its own country code. |
||
373 | alias_values.add(subdivisions.get( |
||
374 | code=self.subdivision_code).country_code) |
||
375 | |||
376 | # Change of current value is allowed if it is a direct |
||
377 | # substitute to our new normalized value. |
||
378 | if current_value not in alias_values: |
||
379 | raise InvalidAddress( |
||
380 | inconsistent_fields=set([ |
||
381 | tuple(sorted(( |
||
382 | field_id, 'subdivision_code')))]), |
||
383 | extra_msg="{} subdivision is trying to replace" |
||
384 | " {}={!r} field by {}={!r}".format( |
||
385 | self.subdivision_code, |
||
386 | field_id, current_value, |
||
387 | field_id, new_value)) |
||
388 | |||
389 | self._fields.update(parent_metadata) |
||
390 | |||
391 | def validate(self): |
||
392 | """ Check fields consistency and requirements in one go. |
||
393 | |||
394 | Properly check that fields are consistent between themselves, and only |
||
395 | raise an exception at the end, for the whole address object. Our custom |
||
396 | exception will provide a detailed status of bad fields. |
||
397 | """ |
||
398 | # Keep a classification of bad fields along the validation process. |
||
399 | required_fields = set() |
||
400 | invalid_fields = set() |
||
401 | inconsistent_fields = set() |
||
402 | |||
403 | # Check that all required fields are set. |
||
404 | for field_id in self.REQUIRED_FIELDS: |
||
405 | if not getattr(self, field_id): |
||
406 | required_fields.add(field_id) |
||
407 | |||
408 | # Check all fields for invalidity, only if not previously flagged as |
||
409 | # required. |
||
410 | if 'country_code' not in required_fields: |
||
411 | # Check that the country code exists. |
||
412 | try: |
||
413 | countries.get(alpha2=self.country_code) |
||
414 | except KeyError: |
||
415 | invalid_fields.add('country_code') |
||
416 | if self.subdivision_code and 'subdivision_code' not in required_fields: |
||
417 | # Check that the country code exists. |
||
418 | try: |
||
419 | subdivisions.get(code=self.subdivision_code) |
||
420 | except KeyError: |
||
421 | invalid_fields.add('subdivision_code') |
||
422 | |||
423 | # Check country consistency against subdivision, only if none of the |
||
424 | # two fields were previously flagged as required or invalid. |
||
425 | if self.subdivision_code and not set( |
||
426 | ['country_code', 'subdivision_code']).intersection( |
||
427 | required_fields.union(invalid_fields)) and \ |
||
428 | country_from_subdivision( |
||
429 | self.subdivision_code) != self.country_code: |
||
430 | inconsistent_fields.add( |
||
431 | tuple(sorted(('country_code', 'subdivision_code')))) |
||
432 | |||
433 | # Raise our custom exception at last. |
||
434 | if required_fields or invalid_fields or inconsistent_fields: |
||
435 | raise InvalidAddress( |
||
436 | required_fields, invalid_fields, inconsistent_fields) |
||
437 | |||
438 | @property |
||
439 | def valid(self): |
||
440 | """ Return a boolean indicating if the address is valid. """ |
||
441 | try: |
||
442 | self.validate() |
||
443 | except InvalidAddress: |
||
444 | return False |
||
445 | return True |
||
446 | |||
447 | @property |
||
448 | def empty(self): |
||
449 | """ Return True only if all fields are empty. """ |
||
450 | for value in set(self.values()): |
||
451 | if value: |
||
452 | return False |
||
453 | return True |
||
454 | |||
455 | def __bool__(self): |
||
456 | """ Consider the instance to be True if not empty. """ |
||
457 | return not self.empty |
||
458 | |||
459 | def __nonzero__(self): |
||
460 | """ Python2 retro-compatibility of ``__bool__()``. """ |
||
461 | return self.__bool__() |
||
462 | |||
463 | @property |
||
464 | def country(self): |
||
465 | """ Return country object. """ |
||
466 | if self.country_code: |
||
467 | return countries.get(alpha2=self.country_code) |
||
468 | return None |
||
469 | |||
470 | @property |
||
471 | def country_name(self): |
||
472 | """ Return country's name. |
||
473 | |||
474 | Common name always takes precedence over the default name, as the |
||
475 | latter isoften pompous, and sometimes false (i.e. not in sync with |
||
476 | current political situation). |
||
477 | """ |
||
478 | if self.country: |
||
479 | if hasattr(self.country, 'common_name'): |
||
480 | return self.country.common_name |
||
481 | return self.country.name |
||
482 | return None |
||
483 | |||
484 | @property |
||
485 | def subdivision(self): |
||
486 | """ Return subdivision object. """ |
||
487 | if self.subdivision_code: |
||
488 | return subdivisions.get(code=self.subdivision_code) |
||
489 | return None |
||
490 | |||
491 | @property |
||
492 | def subdivision_name(self): |
||
493 | """ Return subdivision's name. """ |
||
494 | if self.subdivision: |
||
495 | return self.subdivision.name |
||
496 | return None |
||
497 | |||
498 | @property |
||
499 | def subdivision_type_name(self): |
||
500 | """ Return subdivision's type human-readable name. """ |
||
501 | if self.subdivision: |
||
502 | return self.subdivision.type |
||
503 | return None |
||
504 | |||
505 | @property |
||
506 | def subdivision_type_id(self): |
||
507 | """ Return subdivision's type as a Python-friendly ID string. """ |
||
508 | if self.subdivision: |
||
509 | return subdivision_type_id(self.subdivision) |
||
510 | return None |
||
511 | |||
725 |