| 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 |