Source code for hmrc.api.data

"""HMRC API data representation

Data structures used within API messages are represented in Python
using `dataclasses`, with the mapping between the Python
representation and the HMRC API wire protocol representation handled
automatically via introspection of the Python type annotations.

>>> from decimal import Decimal
>>> @hmrcdataclass
... class TaxPeriodSummary(HmrcDataClass):
...     tax_id: str
...     start: date
...     end: date
...     total_income: Decimal
...     tax_due: Decimal

>>> t1 = TaxPeriodSummary.from_json('''
...     {
...         "taxId": "82719NH23A",
...         "start": "2018-04-06",
...         "end": "2019-04-05",
...         "totalIncome": 38600.00,
...         "taxDue": 2412.50
...     }
... ''')

>>> t1.tax_id
'82719NH23A'

>>> t1.start.year
2018

>>> t1.total_income
Decimal('38600.00')

>>> t1.to_hmrc() # doctest: +NORMALIZE_WHITESPACE
{'taxId': '82719NH23A', 'start': '2018-04-06', 'end': '2019-04-05',
 'totalIncome': Decimal('38600.00'), 'taxDue': Decimal('2412.50')}

>>> t1.tax_due -= Decimal('100.00')

>>> t1.to_hmrc() # doctest: +NORMALIZE_WHITESPACE
{'taxId': '82719NH23A', 'start': '2018-04-06', 'end': '2019-04-05',
 'totalIncome': Decimal('38600.00'), 'taxDue': Decimal('2312.50')}

>>> t2 = TaxPeriodSummary(
...    tax_id = '543242WD69B',
...    start = date(2015, 6, 24),
...    end = date(2016, 6, 23),
...    total_income = Decimal('14000.00'),
...    tax_due = Decimal('0.00'),
... )

>>> t2.to_hmrc() # doctest: +NORMALIZE_WHITESPACE
{'taxId': '543242WD69B', 'start': '2015-06-24', 'end': '2016-06-23',
 'totalIncome': Decimal('14000.00'), 'taxDue': Decimal('0.00')}

>>> t2.to_json() # doctest: +NORMALIZE_WHITESPACE
'{"taxId": "543242WD69B", "start": "2015-06-24", "end": "2016-06-23",
  "totalIncome": 14000.00, "taxDue": 0.00}'
"""

from dataclasses import dataclass, fields
from datetime import date, datetime
from enum import Enum
import re
from typing import Callable, ClassVar, Mapping, Set
import iso8601
import simplejson

__all__ = [
    'HmrcUnknownFieldError',
    'HmrcFieldMap',
    'HmrcTypeMap',
    'HmrcDataClass',
    'hmrcdataclass',
]


[docs]class HmrcUnknownFieldError(KeyError): """Unexpected HMRC API field""" def __str__(self): return 'Unknown field "%s" in %r' % self.args
[docs]@dataclass class HmrcFieldMap: """A mapping between a Python dataclass field and an HMRC API field""" name: str """Python field name""" from_hmrc: Callable """Construct Python value from HMRC API value""" to_hmrc: Callable """Convert Python value to HMRC API value""" hmrc_name: str = None """HMRC field name""" def __post_init__(self): if self.hmrc_name is None: self.hmrc_name = self.default_hmrc_name()
[docs] def default_hmrc_name(self): """Construct default HMRC API field name from Python field name The Python field name is converted from snake_case to camelCase. """ return re.sub(r'_(\w?)', lambda m: m.group(1).upper(), self.name)
[docs]class HmrcTypeMap: """Mapper between Python field values and HMRC API field values"""
[docs] @classmethod def from_hmrc(cls, pytype): """Construct Python value from HMRC API value""" # Recurse into list types if ((isinstance(pytype, type) and issubclass(pytype, list)) or (getattr(pytype, '__origin__', None) is list)): subtype = pytype.__args__[0] subtype_from_hmrc = cls.from_hmrc(subtype) return lambda lst: [subtype_from_hmrc(x) for x in lst] # Recurse into embedded HmrcDataClass instances if hasattr(pytype, 'from_hmrc'): return pytype.from_hmrc # Parse ISO8601 format datetimes. Note that the "Z" suffix # cannot be handled by datetime.fromisoformat() if issubclass(pytype, datetime): return iso8601.parse_date # Parse ISO8601 format dates if issubclass(pytype, date): return pytype.fromisoformat # Otherwise, assume constructor can handle the HMRC value return pytype
[docs] @classmethod def to_hmrc(cls, pytype): """Convert Python value to HMRC API value""" # Recurse into list types if ((isinstance(pytype, type) and issubclass(pytype, list)) or (getattr(pytype, '__origin__', None) is list)): subtype = pytype.__args__[0] subtype_to_hmrc = cls.to_hmrc(subtype) return lambda lst: [subtype_to_hmrc(x) for x in lst] # Recurse into embedded HmrcDataClass instances if hasattr(pytype, 'to_hmrc'): return pytype.to_hmrc # Format dates and datetimes as ISO8601 if issubclass(pytype, date): return lambda x: x.isoformat() # Format enumerations using the enum value if issubclass(pytype, Enum): return lambda x: x.value # Otherwise, assume constructor produces a valid HMRC value return pytype
[docs]class HmrcDataClass: """HMRC data class""" FieldMap: ClassVar[type] = HmrcFieldMap """Field mapping class""" TypeMap: ClassVar[type] = HmrcTypeMap """Type mapping class""" __mapping_by_name: ClassVar[Mapping] = {} __mapping_by_hmrc_name: ClassVar[Mapping] = {} __known_hmrc_names: ClassVar[Set] = set()
[docs] @classmethod def build_hmrc_mappings(cls): """Construct mappings between Python fields and HMRC API fields""" mappings = [cls.FieldMap( name=f.name, hmrc_name=f.metadata.get('name'), from_hmrc=cls.TypeMap.from_hmrc(f.type), to_hmrc=cls.TypeMap.to_hmrc(f.type), ) for f in fields(cls)] cls.__mapping_by_name = {m.name: m for m in mappings} cls.__mapping_by_hmrc_name = {m.hmrc_name: m for m in mappings} cls.__known_hmrc_names = set(cls.__mapping_by_hmrc_name)
[docs] @classmethod def from_hmrc(cls, hmrc): """Construct Python object from HMRC API representation""" mapping = cls.__mapping_by_hmrc_name missing = set(hmrc) - cls.__known_hmrc_names if missing: raise HmrcUnknownFieldError(missing.pop(), hmrc) from None vals = { mapping[k].name: mapping[k].from_hmrc(v) for k, v in hmrc.items() } return cls(**vals)
[docs] def to_hmrc(self): """Convert Python object to HMRC API representation""" mapping = self.__mapping_by_name hmrc = { v.hmrc_name: v.to_hmrc(getattr(self, k)) for k, v in mapping.items() if getattr(self, k) is not None } return hmrc
[docs] @classmethod def from_json(cls, json, *, loads=simplejson.loads): """Construct Python object from JSON representation""" return cls.from_hmrc(loads(json, use_decimal=True))
[docs] def to_json(self, *, dumps=simplejson.dumps): """Convert Python object to JSON representation""" return dumps(self.to_hmrc(), use_decimal=True)
[docs]def hmrcdataclass(cls): """HMRC data class decorator""" cls = dataclass(cls) cls.build_hmrc_mappings() return cls