Skip to content

Commit

Permalink
Merge pull request #64 from fyndata/develop
Browse files Browse the repository at this point in the history
Release 0.7.0
  • Loading branch information
glarrain committed Jun 13, 2019
2 parents 1781c5e + fc8f3d2 commit bcf3f95
Show file tree
Hide file tree
Showing 23 changed files with 2,208 additions and 418 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.6.5
current_version = 0.7.0
commit = True
tag = True

Expand Down
9 changes: 9 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
History
-------

0.7.0 (2019-06-13)
+++++++++++++++++++++++

* (PR #63, 2019-06-13) rcv.parse_csv: significant changes to parse functions
* (PR #62, 2019-06-13) libs: add module ``io_utils``
* (PR #61, 2019-06-12) rcv: add data models, constants and more
* (PR #60, 2019-06-12) libs.tz_utils: misc
* (PR #59, 2019-05-31) rcv.parse_csv: add ``parse_rcv_compra_X_csv_file``

0.6.5 (2019-05-29)
+++++++++++++++++++++++

Expand Down
2 changes: 1 addition & 1 deletion cl_sii/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
"""


__version__ = '0.6.5'
__version__ = '0.7.0'
Empty file added cl_sii/base/__init__.py
Empty file.
13 changes: 13 additions & 0 deletions cl_sii/base/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
Base / constants
================
"""
import pytz

from cl_sii.libs.tz_utils import PytzTimezone


TZ_CL_SANTIAGO: PytzTimezone = pytz.timezone('America/Santiago')

SII_OFFICIAL_TZ = TZ_CL_SANTIAGO
12 changes: 3 additions & 9 deletions cl_sii/dte/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import cl_sii.contribuyente.constants
import cl_sii.rut.constants
from cl_sii.base.constants import SII_OFFICIAL_TZ
from cl_sii.libs import tz_utils
from cl_sii.rut import Rut

Expand Down Expand Up @@ -93,13 +94,6 @@ def validate_non_empty_bytes(value: bytes) -> None:
raise ValueError("Bytes value length is 0.")


def validate_correct_tz(value: datetime, tz: tz_utils.PytzTimezone) -> None:
if not tz_utils.dt_is_aware(value):
raise ValueError("Value must be a timezone-aware datetime.", value)
if value.tzinfo.zone != tz.zone: # type: ignore
raise ValueError(f"Timezone of datetime value must be '{tz.zone!s}'.", value)


@dataclasses.dataclass(frozen=True)
class DteNaturalKey:

Expand Down Expand Up @@ -327,7 +321,7 @@ class DteDataL2(DteDataL1):
# constants
###########################################################################

DATETIME_FIELDS_TZ = tz_utils.TZ_CL_SANTIAGO
DATETIME_FIELDS_TZ = SII_OFFICIAL_TZ

###########################################################################
# fields
Expand Down Expand Up @@ -406,7 +400,7 @@ def __post_init__(self) -> None:
if self.firma_documento_dt is not None:
if not isinstance(self.firma_documento_dt, datetime):
raise TypeError("Inappropriate type of 'firma_documento_dt'.")
validate_correct_tz(self.firma_documento_dt, self.DATETIME_FIELDS_TZ)
tz_utils.validate_dt_tz(self.firma_documento_dt, self.DATETIME_FIELDS_TZ)

if self.signature_value is not None:
if not isinstance(self.signature_value, bytes):
Expand Down
2 changes: 1 addition & 1 deletion cl_sii/dte/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2:
# TODO: change response type to a dataclass like 'DteXmlData'.
# TODO: separate the XML parsing stage from the deserialization stage, which could be
# performed by XML-agnostic code (perhaps using Marshmallow or data clacases?).
# See :class:`cl_sii.rcv.parse.RcvCsvRowSchema`.
# See :class:`cl_sii.rcv.parse_csv.RcvVentaCsvRowSchema`.

if not isinstance(xml_doc, (XmlElement, XmlElementTree)):
raise TypeError("'xml_doc' must be an 'XmlElement'.")
Expand Down
54 changes: 54 additions & 0 deletions cl_sii/extras/mm_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import marshmallow.fields

from cl_sii.dte.constants import TipoDteEnum
from cl_sii.rcv.constants import RcvTipoDocto
from cl_sii.rut import Rut


Expand Down Expand Up @@ -117,3 +118,56 @@ def _validated(self, value: Optional[object]) -> Optional[TipoDteEnum]:
# TipoDteEnum('x') raises 'ValueError', not 'TypeError'
self.fail('invalid')
return validated


class RcvTipoDoctoField(marshmallow.fields.Field):

"""
Marshmallow field for RCV's "tipo documento".
Data types:
* native/primitive/internal/deserialized: :class:`RcvTipoDocto`
* representation/serialized: int, same as for Marshmallow field
:class:`marshmallow.fields.Integer`
The field performs some input value cleaning when it is an str;
for example ``' 33 \t '`` is allowed and the resulting value
is ``RcvTipoDocto(33)``.
Implementation almost identical to :class:`TipoDteField`.
"""

default_error_messages = {
'invalid': "Not a valid RCV's Tipo de Documento."
}

def _serialize(self, value: Optional[object], attr: str, obj: object) -> Optional[int]:
validated: Optional[RcvTipoDocto] = self._validated(value)
return validated.value if validated is not None else None

def _deserialize(self, value: object, attr: str, data: dict) -> Optional[RcvTipoDocto]:
return self._validated(value)

def _validated(self, value: Optional[object]) -> Optional[RcvTipoDocto]:
if value is None or isinstance(value, RcvTipoDocto):
validated = value
else:
if isinstance(value, bool):
# is value is bool, `isinstance(value, int)` is True and `int(value)` works!
self.fail('type')
try:
value = int(value) # type: ignore
except ValueError:
# `int('x')` raises 'ValueError', not 'TypeError'
self.fail('type')
except TypeError:
# `int(date(2018, 10, 10))` raises 'TypeError', unlike `int('x')`
self.fail('type')

try:
validated = RcvTipoDocto(value) # type: ignore
except ValueError:
# RcvTipoDocto('x') raises 'ValueError', not 'TypeError'
self.fail('invalid')
return validated
71 changes: 71 additions & 0 deletions cl_sii/libs/io_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import codecs
import io
from typing import IO


# notes:
# - For streams and modes see 'io.open()'
# - Stream classes have a pretty strange 'typing'/ABC/inheritance/etc arrangement because,
# among others, they are implemented in C.
# - Use `IO[X]` for arguments and `TextIO`/`BinaryIO` for return types (says GVR).
# https://github.com/python/typing/issues/518#issuecomment-350903120


def with_mode_binary(stream: IO) -> bool:
"""
Return whether ``stream`` is a binary stream (i.e. reads bytes).
"""
result = False
try:
result = 'b' in stream.mode
except AttributeError:
if isinstance(stream, (io.RawIOBase, io.BufferedIOBase, io.BytesIO)):
result = True

return result


def with_mode_text(stream: IO) -> bool:
"""
Return whether ``stream`` is a text stream (i.e. reads strings).
"""
result = False
try:
result = 't' in stream.mode
except AttributeError:
if isinstance(stream, (io.TextIOBase, io.TextIOWrapper, io.StringIO)):
result = True

return result


def with_encoding_utf8(text_stream: IO[str]) -> bool:
"""
Return whether ``text_stream`` is a text stream with encoding set to UTF-8.
:raises TypeError: if ``text_stream`` is not a text stream
"""
result = False

if isinstance(text_stream, io.StringIO):
# note: 'StringIO' saves (unicode) strings in memory and therefore doesn't have (or need)
# an encoding, which is fine.
# https://stackoverflow.com/questions/9368865/io-stringio-encoding-in-python3/9368909#9368909
result = True
else:
try:
text_stream_encoding: str = text_stream.encoding # type: ignore
except AttributeError as exc:
raise TypeError("Value is not a text stream.") from exc
if text_stream_encoding is None:
# e.g. the strange case of `tempfile.SpooledTemporaryFile(mode='rt', encoding='utf-8')`
pass
else:
try:
text_stream_encoding_norm = codecs.lookup(text_stream_encoding).name
result = text_stream_encoding_norm == 'utf-8'
except LookupError:
pass

return result
59 changes: 51 additions & 8 deletions cl_sii/libs/tz_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@


TZ_UTC = pytz.UTC # type: PytzTimezone
TZ_CL_SANTIAGO = pytz.timezone('America/Santiago') # type: PytzTimezone

# TODO: remove
UTC = TZ_UTC
TIMEZONE_CL_SANTIAGO = TZ_CL_SANTIAGO
_TZ_CL_SANTIAGO: PytzTimezone = pytz.timezone('America/Santiago')


def get_now_tz_aware() -> datetime:
Expand Down Expand Up @@ -66,7 +62,7 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime:
>>> dt_tz_aware_1.isoformat()
'2018-10-23T04:54:13+00:00'
>>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO)
>>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, _TZ_CL_SANTIAGO)
>>> dt_tz_aware_2
datetime.datetime(2018, 10, 23, 1, 54, 13, tzinfo=<DstTzInfo 'America/Santiago'
-03-1 day, 21:00:00 DST>)
Expand All @@ -82,6 +78,43 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime:
return dt_tz_aware


def convert_tz_aware_dt_to_naive(dt: datetime, tz: PytzTimezone = None) -> datetime:
"""
Convert a timezone-aware datetime object to an offset-naive one.
Default ``tz`` is UTC.
>>> dt_tz_aware = datetime(2018, 10, 1, 2, 30, 0, tzinfo=TZ_UTC)
>>> dt_tz_aware.isoformat()
'2018-10-01T02:30:00+00:00'
>>> dt_naive_utc = convert_tz_aware_dt_to_naive(dt_tz_aware, TZ_UTC)
>>> dt_naive_utc.isoformat()
'2018-10-01T02:30:00'
>>> dt_naive_cl_santiago = convert_tz_aware_dt_to_naive(dt_tz_aware, _TZ_CL_SANTIAGO)
>>> dt_naive_cl_santiago.isoformat()
'2018-09-30T23:30:00'
>>> int((dt_naive_cl_santiago - dt_naive_utc).total_seconds() / 3600)
-3
>>> (dt_naive_cl_santiago.date() - dt_naive_utc.date()).days
-1
:param dt: timezone-aware datetime
:param tz: timezone e.g. ``pytz.timezone('America/Santiago')``
:raises ValueError: if ``dt`` is not timezone-aware
"""
if not dt_is_aware(dt):
raise ValueError("Value must be a timezone-aware datetime object.")

if tz is None:
tz = TZ_UTC
dt_naive = dt.astimezone(tz).replace(tzinfo=None) # type: datetime
return dt_naive


def dt_is_aware(value: datetime) -> bool:
"""
Return whether datetime ``value`` is "aware".
Expand All @@ -91,7 +124,7 @@ def dt_is_aware(value: datetime) -> bool:
False
>>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC))
True
>>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO))
>>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, _TZ_CL_SANTIAGO))
True
"""
Expand All @@ -110,11 +143,21 @@ def dt_is_naive(value: datetime) -> bool:
True
>>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC))
False
>>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO))
>>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, _TZ_CL_SANTIAGO))
False
"""
if not isinstance(value, datetime):
raise TypeError
# source: 'django.utils.timezone.is_naive' @ Django 2.1.7
return value.utcoffset() is None


def validate_dt_tz(value: datetime, tz: PytzTimezone) -> None:
"""
Validate that ``tz`` is the timezone of ``value``.
"""
if not dt_is_aware(value):
raise ValueError("Value must be a timezone-aware datetime object.")
if value.tzinfo.zone != tz.zone: # type: ignore
raise ValueError(f"Timezone of datetime value must be '{tz.zone!s}'.", value)

0 comments on commit bcf3f95

Please sign in to comment.