diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index d800f399c..350bbb4f5 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3", "2.7", "3.5", "3.6", "3.7", "3.8", "3.9-dev"] + python-version: ["pypy3", "3.6", "3.7", "3.8", "3.9"] os: [ubuntu-latest, macos-latest, windows-latest] exclude: # pypy3 randomly fails on Windows builds diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f5128595..e00406c8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,12 +2,12 @@ default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v3.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: fix-encoding-pragma - exclude: ^arrow/_version.py + args: [--remove] - id: requirements-txt-fixer - id: check-ast - id: check-yaml @@ -16,26 +16,34 @@ repos: - id: check-merge-conflict - id: debug-statements - repo: https://github.com/timothycrosley/isort - rev: 5.4.2 + rev: 5.7.0 hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v2.7.2 + rev: v2.10.0 hooks: - id: pyupgrade + args: [--py36-plus] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.6.0 + rev: v1.7.1 hooks: - id: python-no-eval - id: python-check-blanket-noqa + - id: python-use-type-annotations - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal - repo: https://github.com/psf/black rev: 20.8b1 hooks: - id: black - args: [--safe, --quiet] + args: [--safe, --quiet, --target-version=py36] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.3 + rev: 3.8.4 hooks: - id: flake8 additional_dependencies: [flake8-bugbear] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.800' + hooks: + - id: mypy diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0b55a4522..db6addb45 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,18 +1,55 @@ Changelog ========= +1.0.1 (2021-02-27) +------------------ + +- [FIXED] A ``py.typed`` file is now bundled with the Arrow package to conform to PEP 561. + +1.0.0 (2021-02-26) +------------------ + +After 8 years we're pleased to announce Arrow v1.0. Thanks to the entire Python community for helping make Arrow the amazing package it is today! + +- [CHANGE] Arrow has **dropped support** for Python 2.7 and 3.5. +- [CHANGE] There are multiple **breaking changes** with this release, please see the `migration guide `_ for a complete overview. +- [CHANGE] Arrow is now following `semantic versioning `_. +- [CHANGE] Made ``humanize`` granularity="auto" limits more accurate to reduce strange results. +- [NEW] Added support for Python 3.9. +- [NEW] Added a new keyword argument "exact" to ``span``, ``span_range`` and ``interval`` methods. This makes timespans begin at the start time given and not extend beyond the end time given, for example: + +.. code-block:: python + + >>> start = Arrow(2021, 2, 5, 12, 30) + >>> end = Arrow(2021, 2, 5, 17, 15) + >>> for r in arrow.Arrow.span_range('hour', start, end, exact=True): + ... print(r) + ... + (, ) + (, ) + (, ) + (, ) + (, ) + +- [NEW] Arrow now natively supports PEP 484-style type annotations. +- [FIX] Fixed handling of maximum permitted timestamp on Windows systems. +- [FIX] Corrections to French, German, Japanese and Norwegian locales. +- [INTERNAL] Raise more appropriate errors when string parsing fails to match. + 0.17.0 (2020-10-2) ------------------- - [WARN] Arrow will **drop support** for Python 2.7 and 3.5 in the upcoming 1.0.0 release. This is the last major release to support Python 2.7 and Python 3.5. - [NEW] Arrow now properly handles imaginary datetimes during DST shifts. For example: -..code-block:: python +.. code-block:: python + >>> just_before = arrow.get(2013, 3, 31, 1, 55, tzinfo="Europe/Paris") >>> just_before.shift(minutes=+10) -..code-block:: python +.. code-block:: python + >>> before = arrow.get("2018-03-10 23:00:00", "YYYY-MM-DD HH:mm:ss", tzinfo="US/Pacific") >>> after = arrow.get("2018-03-11 04:00:00", "YYYY-MM-DD HH:mm:ss", tzinfo="US/Pacific") >>> result=[(t, t.to("utc")) for t in arrow.Arrow.range("hour", before, after)] diff --git a/LICENSE b/LICENSE index 2bef500de..4f9eea5d1 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2019 Chris Smith + Copyright 2021 Chris Smith Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index f294985dc..18a7fbc5e 100644 --- a/Makefile +++ b/Makefile @@ -2,29 +2,32 @@ auto: build38 -build27: PYTHON_VER = python2.7 -build35: PYTHON_VER = python3.5 build36: PYTHON_VER = python3.6 build37: PYTHON_VER = python3.7 build38: PYTHON_VER = python3.8 build39: PYTHON_VER = python3.9 -build27 build35 build36 build37 build38 build39: clean - virtualenv venv --python=$(PYTHON_VER) +build36 build37 build38 build39: clean + $(PYTHON_VER) -m venv venv . venv/bin/activate; \ + pip install -U pip setuptools wheel; \ pip install -r requirements.txt; \ pre-commit install test: rm -f .coverage coverage.xml - . venv/bin/activate; pytest + . venv/bin/activate; \ + pytest lint: - . venv/bin/activate; pre-commit run --all-files --show-diff-on-failure + . venv/bin/activate; \ + pre-commit run --all-files --show-diff-on-failure docs: rm -rf docs/_build - . venv/bin/activate; cd docs; make html + . venv/bin/activate; \ + cd docs; \ + make html clean: clean-dist rm -rf venv .pytest_cache ./**/__pycache__ @@ -35,10 +38,11 @@ clean-dist: build-dist: . venv/bin/activate; \ - pip install -U setuptools twine wheel; \ + pip install -U pip setuptools twine wheel; \ python setup.py sdist bdist_wheel upload-dist: - . venv/bin/activate; twine upload dist/* + . venv/bin/activate; \ + twine upload dist/* publish: test clean-dist build-dist upload-dist clean-dist diff --git a/README.rst b/README.rst index 69f6c50d8..5eaa2e7e7 100644 --- a/README.rst +++ b/README.rst @@ -47,17 +47,18 @@ Features -------- - Fully-implemented, drop-in replacement for datetime -- Supports Python 2.7, 3.5, 3.6, 3.7, 3.8 and 3.9 +- Support for Python 3.6+ - Timezone-aware and UTC by default -- Provides super-simple creation options for many common input scenarios -- :code:`shift` method with support for relative offsets, including weeks -- Formats and parses strings automatically -- Wide support for ISO 8601 +- Super-simple creation options for many common input scenarios +- ``shift`` method with support for relative offsets, including weeks +- Format and parse strings automatically +- Wide support for the `ISO 8601 `_ standard - Timezone conversion -- Timestamp available as a property +- Support for ``dateutil``, ``pytz``, and ``ZoneInfo`` tzinfo objects - Generates time spans, ranges, floors and ceilings for time frames ranging from microsecond to year -- Humanizes and supports a growing list of contributed locales +- Humanize dates and times with a growing list of contributed locales - Extensible for your own Arrow-derived types +- Full support for PEP 484-style type hints Quick Start ----------- @@ -122,10 +123,10 @@ Contributions are welcome for both code and localizations (adding and updating l #. Find an issue or feature to tackle on the `issue tracker `_. Issues marked with the `"good first issue" label `_ may be a great place to start! #. Fork `this repository `_ on GitHub and begin making changes in a branch. #. Add a few tests to ensure that the bug was fixed or the feature works as expected. -#. Run the entire test suite and linting checks by running one of the following commands: :code:`tox` (if you have `tox `_ installed) **OR** :code:`make build38 && make test && make lint` (if you do not have Python 3.8 installed, replace :code:`build38` with the latest Python version on your system). +#. Run the entire test suite and linting checks by running one of the following commands: ``tox && tox -e lint,docs`` (if you have `tox `_ installed) **OR** ``make build39 && make test && make lint`` (if you do not have Python 3.9 installed, replace ``build39`` with the latest Python version on your system). #. Submit a pull request and await feedback 😃. -If you have any questions along the way, feel free to ask them `here `_. +If you have any questions along the way, feel free to ask them `here `_. Support Arrow ------------- diff --git a/arrow/__init__.py b/arrow/__init__.py index 2883527be..117c9e8a0 100644 --- a/arrow/__init__.py +++ b/arrow/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from ._version import __version__ from .api import get, now, utcnow from .arrow import Arrow diff --git a/arrow/_version.py b/arrow/_version.py index fd86b3ee9..5c4105cd3 100644 --- a/arrow/_version.py +++ b/arrow/_version.py @@ -1 +1 @@ -__version__ = "0.17.0" +__version__ = "1.0.1" diff --git a/arrow/api.py b/arrow/api.py index a6b7be3de..95696f3c1 100644 --- a/arrow/api.py +++ b/arrow/api.py @@ -1,19 +1,80 @@ -# -*- coding: utf-8 -*- """ Provides the default implementation of :class:`ArrowFactory ` methods for use as a module API. """ -from __future__ import absolute_import +from datetime import date, datetime +from datetime import tzinfo as dt_tzinfo +from time import struct_time +from typing import Any, List, Optional, Tuple, Type, Union, overload +from arrow.arrow import TZ_EXPR, Arrow from arrow.factory import ArrowFactory # internal default factory. _factory = ArrowFactory() - -def get(*args, **kwargs): +# TODO: Use Positional Only Argument (https://www.python.org/dev/peps/pep-0570/) +# after Python 3.7 deprecation + + +@overload +def get( + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, +) -> Arrow: + ... # pragma: no cover + + +@overload +def get( + __obj: Union[ + Arrow, + datetime, + date, + struct_time, + dt_tzinfo, + int, + float, + str, + Tuple[int, int, int], + ], + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, +) -> Arrow: + ... # pragma: no cover + + +@overload +def get( + __arg1: Union[datetime, date], + __arg2: TZ_EXPR, + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, +) -> Arrow: + ... # pragma: no cover + + +@overload +def get( + __arg1: str, + __arg2: Union[str, List[str]], + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, +) -> Arrow: + ... # pragma: no cover + + +def get(*args: Any, **kwargs: Any) -> Arrow: """Calls the default :class:`ArrowFactory ` ``get`` method.""" return _factory.get(*args, **kwargs) @@ -22,7 +83,7 @@ def get(*args, **kwargs): get.__doc__ = _factory.get.__doc__ -def utcnow(): +def utcnow() -> Arrow: """Calls the default :class:`ArrowFactory ` ``utcnow`` method.""" return _factory.utcnow() @@ -31,7 +92,7 @@ def utcnow(): utcnow.__doc__ = _factory.utcnow.__doc__ -def now(tz=None): +def now(tz: Optional[TZ_EXPR] = None) -> Arrow: """Calls the default :class:`ArrowFactory ` ``now`` method.""" return _factory.now(tz) @@ -40,7 +101,7 @@ def now(tz=None): now.__doc__ = _factory.now.__doc__ -def factory(type): +def factory(type: Type[Arrow]) -> ArrowFactory: """Returns an :class:`.ArrowFactory` for the specified :class:`Arrow ` or derived type. diff --git a/arrow/arrow.py b/arrow/arrow.py index 4fe954178..dda7b4056 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1,35 +1,83 @@ -# -*- coding: utf-8 -*- """ Provides the :class:`Arrow ` class, an enhanced ``datetime`` replacement. """ -from __future__ import absolute_import import calendar import sys -import warnings -from datetime import datetime, timedelta +from datetime import date +from datetime import datetime as dt_datetime +from datetime import time as dt_time +from datetime import timedelta from datetime import tzinfo as dt_tzinfo from math import trunc +from time import struct_time +from typing import ( + Any, + ClassVar, + Generator, + Iterable, + List, + Mapping, + Optional, + Tuple, + Union, + cast, + overload, +) from dateutil import tz as dateutil_tz from dateutil.relativedelta import relativedelta from arrow import formatter, locales, parser, util - -if sys.version_info[:2] < (3, 6): # pragma: no cover - with warnings.catch_warnings(): - warnings.simplefilter("default", DeprecationWarning) - warnings.warn( - "Arrow will drop support for Python 2.7 and 3.5 in the upcoming v1.0.0 release. Please upgrade to " - "Python 3.6+ to continue receiving updates for Arrow.", - DeprecationWarning, - ) - - -class Arrow(object): +from arrow.locales import TimeFrameLiteral + +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Final, Literal +else: + from typing import Final, Literal # pragma: no cover + + +TZ_EXPR = Union[dt_tzinfo, str] + +_T_FRAMES = Literal[ + "year", + "years", + "month", + "months", + "day", + "days", + "hour", + "hours", + "minute", + "minutes", + "second", + "seconds", + "microsecond", + "microseconds", + "week", + "weeks", + "quarter", + "quarters", +] + +_BOUNDS = Literal["[)", "()", "(]", "[]"] + +_GRANULARITY = Literal[ + "auto", + "second", + "minute", + "hour", + "day", + "week", + "month", + "year", +] + + +class Arrow: """An :class:`Arrow ` object. Implements the ``datetime`` interface, behaving as an aware ``datetime`` while implementing @@ -62,30 +110,52 @@ class Arrow(object): """ - resolution = datetime.resolution - - _ATTRS = ["year", "month", "day", "hour", "minute", "second", "microsecond"] - _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS] - _MONTHS_PER_QUARTER = 3 - _SECS_PER_MINUTE = float(60) - _SECS_PER_HOUR = float(60 * 60) - _SECS_PER_DAY = float(60 * 60 * 24) - _SECS_PER_WEEK = float(60 * 60 * 24 * 7) - _SECS_PER_MONTH = float(60 * 60 * 24 * 30.5) - _SECS_PER_YEAR = float(60 * 60 * 24 * 365.25) + resolution: ClassVar[timedelta] = dt_datetime.resolution + min: ClassVar["Arrow"] + max: ClassVar["Arrow"] + + _ATTRS: Final[List[str]] = [ + "year", + "month", + "day", + "hour", + "minute", + "second", + "microsecond", + ] + _ATTRS_PLURAL: Final[List[str]] = [f"{a}s" for a in _ATTRS] + _MONTHS_PER_QUARTER: Final[int] = 3 + _SECS_PER_MINUTE: Final[int] = 60 + _SECS_PER_HOUR: Final[int] = 60 * 60 + _SECS_PER_DAY: Final[int] = 60 * 60 * 24 + _SECS_PER_WEEK: Final[int] = 60 * 60 * 24 * 7 + _SECS_PER_MONTH: Final[float] = 60 * 60 * 24 * 30.5 + _SECS_PER_YEAR: Final[int] = 60 * 60 * 24 * 365 + + _SECS_MAP: Final[Mapping[TimeFrameLiteral, float]] = { + "second": 1.0, + "minute": _SECS_PER_MINUTE, + "hour": _SECS_PER_HOUR, + "day": _SECS_PER_DAY, + "week": _SECS_PER_WEEK, + "month": _SECS_PER_MONTH, + "year": _SECS_PER_YEAR, + } + + _datetime: dt_datetime def __init__( self, - year, - month, - day, - hour=0, - minute=0, - second=0, - microsecond=0, - tzinfo=None, - **kwargs - ): + year: int, + month: int, + day: int, + hour: int = 0, + minute: int = 0, + second: int = 0, + microsecond: int = 0, + tzinfo: Optional[TZ_EXPR] = None, + **kwargs: Any, + ) -> None: if tzinfo is None: tzinfo = dateutil_tz.tzutc() # detect that tzinfo is a pytz object (issue #626) @@ -93,24 +163,22 @@ def __init__( isinstance(tzinfo, dt_tzinfo) and hasattr(tzinfo, "localize") and hasattr(tzinfo, "zone") - and tzinfo.zone + and tzinfo.zone # type: ignore[attr-defined] ): - tzinfo = parser.TzinfoParser.parse(tzinfo.zone) - elif util.isstr(tzinfo): + tzinfo = parser.TzinfoParser.parse(tzinfo.zone) # type: ignore[attr-defined] + elif isinstance(tzinfo, str): tzinfo = parser.TzinfoParser.parse(tzinfo) fold = kwargs.get("fold", 0) - # use enfold here to cover direct arrow.Arrow init on 2.7/3.5 - self._datetime = dateutil_tz.enfold( - datetime(year, month, day, hour, minute, second, microsecond, tzinfo), - fold=fold, + self._datetime = dt_datetime( + year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold ) # factories: single object, both original and from datetime. @classmethod - def now(cls, tzinfo=None): + def now(cls, tzinfo: Optional[dt_tzinfo] = None) -> "Arrow": """Constructs an :class:`Arrow ` object, representing "now" in the given timezone. @@ -126,7 +194,7 @@ def now(cls, tzinfo=None): if tzinfo is None: tzinfo = dateutil_tz.tzlocal() - dt = datetime.now(tzinfo) + dt = dt_datetime.now(tzinfo) return cls( dt.year, @@ -141,7 +209,7 @@ def now(cls, tzinfo=None): ) @classmethod - def utcnow(cls): + def utcnow(cls) -> "Arrow": """Constructs an :class:`Arrow ` object, representing "now" in UTC time. @@ -152,7 +220,7 @@ def utcnow(cls): """ - dt = datetime.now(dateutil_tz.tzutc()) + dt = dt_datetime.now(dateutil_tz.tzutc()) return cls( dt.year, @@ -167,7 +235,11 @@ def utcnow(cls): ) @classmethod - def fromtimestamp(cls, timestamp, tzinfo=None): + def fromtimestamp( + cls, + timestamp: Union[int, float, str], + tzinfo: Optional[TZ_EXPR] = None, + ) -> "Arrow": """Constructs an :class:`Arrow ` object from a timestamp, converted to the given timezone. @@ -177,16 +249,14 @@ def fromtimestamp(cls, timestamp, tzinfo=None): if tzinfo is None: tzinfo = dateutil_tz.tzlocal() - elif util.isstr(tzinfo): + elif isinstance(tzinfo, str): tzinfo = parser.TzinfoParser.parse(tzinfo) if not util.is_timestamp(timestamp): - raise ValueError( - "The provided timestamp '{}' is invalid.".format(timestamp) - ) + raise ValueError(f"The provided timestamp {timestamp!r} is invalid.") timestamp = util.normalize_timestamp(float(timestamp)) - dt = datetime.fromtimestamp(timestamp, tzinfo) + dt = dt_datetime.fromtimestamp(timestamp, tzinfo) return cls( dt.year, @@ -201,7 +271,7 @@ def fromtimestamp(cls, timestamp, tzinfo=None): ) @classmethod - def utcfromtimestamp(cls, timestamp): + def utcfromtimestamp(cls, timestamp: Union[int, float, str]) -> "Arrow": """Constructs an :class:`Arrow ` object from a timestamp, in UTC time. :param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either. @@ -209,12 +279,10 @@ def utcfromtimestamp(cls, timestamp): """ if not util.is_timestamp(timestamp): - raise ValueError( - "The provided timestamp '{}' is invalid.".format(timestamp) - ) + raise ValueError(f"The provided timestamp {timestamp!r} is invalid.") timestamp = util.normalize_timestamp(float(timestamp)) - dt = datetime.utcfromtimestamp(timestamp) + dt = dt_datetime.utcfromtimestamp(timestamp) return cls( dt.year, @@ -229,7 +297,7 @@ def utcfromtimestamp(cls, timestamp): ) @classmethod - def fromdatetime(cls, dt, tzinfo=None): + def fromdatetime(cls, dt: dt_datetime, tzinfo: Optional[TZ_EXPR] = None) -> "Arrow": """Constructs an :class:`Arrow ` object from a ``datetime`` and optional replacement timezone. @@ -265,7 +333,7 @@ def fromdatetime(cls, dt, tzinfo=None): ) @classmethod - def fromdate(cls, date, tzinfo=None): + def fromdate(cls, date: date, tzinfo: Optional[TZ_EXPR] = None) -> "Arrow": """Constructs an :class:`Arrow ` object from a ``date`` and optional replacement timezone. Time values are set to 0. @@ -279,7 +347,9 @@ def fromdate(cls, date, tzinfo=None): return cls(date.year, date.month, date.day, tzinfo=tzinfo) @classmethod - def strptime(cls, date_str, fmt, tzinfo=None): + def strptime( + cls, date_str: str, fmt: str, tzinfo: Optional[TZ_EXPR] = None + ) -> "Arrow": """Constructs an :class:`Arrow ` object from a date string and format, in the style of ``datetime.strptime``. Optionally replaces the parsed timezone. @@ -295,7 +365,7 @@ def strptime(cls, date_str, fmt, tzinfo=None): """ - dt = datetime.strptime(date_str, fmt) + dt = dt_datetime.strptime(date_str, fmt) if tzinfo is None: tzinfo = dt.tzinfo @@ -311,10 +381,45 @@ def strptime(cls, date_str, fmt, tzinfo=None): fold=getattr(dt, "fold", 0), ) + @classmethod + def fromordinal(cls, ordinal: int) -> "Arrow": + """Constructs an :class:`Arrow ` object corresponding + to the Gregorian Ordinal. + + :param ordinal: an ``int`` corresponding to a Gregorian Ordinal. + + Usage:: + + >>> arrow.fromordinal(737741) + + + """ + + util.validate_ordinal(ordinal) + dt = dt_datetime.fromordinal(ordinal) + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + fold=getattr(dt, "fold", 0), + ) + # factories: ranges and spans @classmethod - def range(cls, frame, start, end=None, tz=None, limit=None): + def range( + cls, + frame: _T_FRAMES, + start: Union["Arrow", dt_datetime], + end: Union["Arrow", dt_datetime, None] = None, + tz: Optional[TZ_EXPR] = None, + limit: Optional[int] = None, + ) -> Generator["Arrow", None, None]: """Returns an iterator of :class:`Arrow ` objects, representing points in time between two inputs. @@ -383,7 +488,7 @@ def range(cls, frame, start, end=None, tz=None, limit=None): yield current values = [getattr(current, f) for f in cls._ATTRS] - current = cls(*values, tzinfo=tzinfo).shift( + current = cls(*values, tzinfo=tzinfo).shift( # type: ignore **{frame_relative: relative_steps} ) @@ -393,7 +498,13 @@ def range(cls, frame, start, end=None, tz=None, limit=None): if day_is_clipped and not cls._is_last_day_of_month(current): current = current.replace(day=original_day) - def span(self, frame, count=1, bounds="[)"): + def span( + self, + frame: _T_FRAMES, + count: int = 1, + bounds: _BOUNDS = "[)", + exact: bool = False, + ) -> Tuple["Arrow", "Arrow"]: """Returns two new :class:`Arrow ` objects, representing the timespan of the :class:`Arrow ` object in a given timeframe. @@ -403,6 +514,9 @@ def span(self, frame, count=1, bounds="[)"): whether to include or exclude the start and end values in the span. '(' excludes the start, '[' includes the start, ')' excludes the end, and ']' includes the end. If the bounds are not specified, the default bound '[)' is used. + :param exact: (optional) whether to have the start of the timespan begin exactly + at the time specified by ``start`` and the end of the timespan truncated + so as not to extend beyond ``end``. Supported frame values: year, quarter, month, week, day, hour, minute, second. @@ -436,20 +550,22 @@ def span(self, frame, count=1, bounds="[)"): else: attr = frame_absolute - index = self._ATTRS.index(attr) - frames = self._ATTRS[: index + 1] + floor = self + if not exact: + index = self._ATTRS.index(attr) + frames = self._ATTRS[: index + 1] - values = [getattr(self, f) for f in frames] + values = [getattr(self, f) for f in frames] - for _ in range(3 - len(values)): - values.append(1) + for _ in range(3 - len(values)): + values.append(1) - floor = self.__class__(*values, tzinfo=self.tzinfo) + floor = self.__class__(*values, tzinfo=self.tzinfo) # type: ignore - if frame_absolute == "week": - floor = floor.shift(days=-(self.isoweekday() - 1)) - elif frame_absolute == "quarter": - floor = floor.shift(months=-((self.month - 1) % 3)) + if frame_absolute == "week": + floor = floor.shift(days=-(self.isoweekday() - 1)) + elif frame_absolute == "quarter": + floor = floor.shift(months=-((self.month - 1) % 3)) ceil = floor.shift(**{frame_relative: count * relative_steps}) @@ -461,7 +577,7 @@ def span(self, frame, count=1, bounds="[)"): return floor, ceil - def floor(self, frame): + def floor(self, frame: _T_FRAMES) -> "Arrow": """Returns a new :class:`Arrow ` object, representing the "floor" of the timespan of the :class:`Arrow ` object in a given timeframe. Equivalent to the first element in the 2-tuple returned by @@ -477,7 +593,7 @@ def floor(self, frame): return self.span(frame)[0] - def ceil(self, frame): + def ceil(self, frame: _T_FRAMES) -> "Arrow": """Returns a new :class:`Arrow ` object, representing the "ceiling" of the timespan of the :class:`Arrow ` object in a given timeframe. Equivalent to the second element in the 2-tuple returned by @@ -494,7 +610,16 @@ def ceil(self, frame): return self.span(frame)[1] @classmethod - def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"): + def span_range( + cls, + frame: _T_FRAMES, + start: dt_datetime, + end: dt_datetime, + tz: Optional[TZ_EXPR] = None, + limit: Optional[int] = None, + bounds: _BOUNDS = "[)", + exact: bool = False, + ) -> Iterable[Tuple["Arrow", "Arrow"]]: """Returns an iterator of tuples, each :class:`Arrow ` objects, representing a series of timespans between two inputs. @@ -508,6 +633,9 @@ def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"): whether to include or exclude the start and end values in each span in the range. '(' excludes the start, '[' includes the start, ')' excludes the end, and ']' includes the end. If the bounds are not specified, the default bound '[)' is used. + :param exact: (optional) whether to have the first timespan start exactly + at the time specified by ``start`` and the final span truncated + so as not to extend beyond ``end``. **NOTE**: The ``end`` or ``limit`` must be provided. Call with ``end`` alone to return the entire range. Call with ``limit`` alone to return a maximum # of results from @@ -544,12 +672,36 @@ def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"): """ tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz) - start = cls.fromdatetime(start, tzinfo).span(frame)[0] + start = cls.fromdatetime(start, tzinfo).span(frame, exact=exact)[0] + end = cls.fromdatetime(end, tzinfo) _range = cls.range(frame, start, end, tz, limit) - return (r.span(frame, bounds=bounds) for r in _range) + if not exact: + for r in _range: + yield r.span(frame, bounds=bounds, exact=exact) + + for r in _range: + floor, ceil = r.span(frame, bounds=bounds, exact=exact) + if ceil > end: + ceil = end + if bounds[1] == ")": + ceil += relativedelta(microseconds=-1) + if floor == end: + break + elif floor + relativedelta(microseconds=-1) == end: + break + yield floor, ceil @classmethod - def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"): + def interval( + cls, + frame: _T_FRAMES, + start: dt_datetime, + end: dt_datetime, + interval: int = 1, + tz: Optional[TZ_EXPR] = None, + bounds: _BOUNDS = "[)", + exact: bool = False, + ) -> Iterable[Tuple["Arrow", "Arrow"]]: """Returns an iterator of tuples, each :class:`Arrow ` objects, representing a series of intervals between two inputs. @@ -562,6 +714,9 @@ def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"): whether to include or exclude the start and end values in the intervals. '(' excludes the start, '[' includes the start, ')' excludes the end, and ']' includes the end. If the bounds are not specified, the default bound '[)' is used. + :param exact: (optional) whether to have the first timespan start exactly + at the time specified by ``start`` and the final interval truncated + so as not to extend beyond ``end``. Supported frame values: year, quarter, month, week, day, hour, minute, second @@ -591,37 +746,42 @@ def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"): if interval < 1: raise ValueError("interval has to be a positive integer") - spanRange = iter(cls.span_range(frame, start, end, tz, bounds=bounds)) + spanRange = iter( + cls.span_range(frame, start, end, tz, bounds=bounds, exact=exact) + ) while True: try: intvlStart, intvlEnd = next(spanRange) for _ in range(interval - 1): - _, intvlEnd = next(spanRange) + try: + _, intvlEnd = next(spanRange) + except StopIteration: + continue yield intvlStart, intvlEnd except StopIteration: return # representations - def __repr__(self): - return "<{} [{}]>".format(self.__class__.__name__, self.__str__()) + def __repr__(self) -> str: + return f"<{self.__class__.__name__} [{self.__str__()}]>" - def __str__(self): + def __str__(self) -> str: return self._datetime.isoformat() - def __format__(self, formatstr): + def __format__(self, formatstr: str) -> str: if len(formatstr) > 0: return self.format(formatstr) return str(self) - def __hash__(self): + def __hash__(self) -> int: return self._datetime.__hash__() # attributes and properties - def __getattr__(self, name): + def __getattr__(self, name: str) -> int: if name == "week": return self.isocalendar()[1] @@ -630,15 +790,15 @@ def __getattr__(self, name): return int((self.month - 1) / self._MONTHS_PER_QUARTER) + 1 if not name.startswith("_"): - value = getattr(self._datetime, name, None) + value: Optional[int] = getattr(self._datetime, name, None) if value is not None: return value - return object.__getattribute__(self, name) + return cast(int, object.__getattribute__(self, name)) @property - def tzinfo(self): + def tzinfo(self) -> dt_tzinfo: """Gets the ``tzinfo`` of the :class:`Arrow ` object. Usage:: @@ -649,16 +809,11 @@ def tzinfo(self): """ - return self._datetime.tzinfo - - @tzinfo.setter - def tzinfo(self, tzinfo): - """ Sets the ``tzinfo`` of the :class:`Arrow ` object. """ - - self._datetime = self._datetime.replace(tzinfo=tzinfo) + # In Arrow, `_datetime` cannot be naive. + return cast(dt_tzinfo, self._datetime.tzinfo) @property - def datetime(self): + def datetime(self) -> dt_datetime: """Returns a datetime representation of the :class:`Arrow ` object. Usage:: @@ -672,7 +827,7 @@ def datetime(self): return self._datetime @property - def naive(self): + def naive(self) -> dt_datetime: """Returns a naive datetime representation of the :class:`Arrow ` object. @@ -688,8 +843,7 @@ def naive(self): return self._datetime.replace(tzinfo=None) - @property - def timestamp(self): + def timestamp(self) -> float: """Returns a timestamp representation of the :class:`Arrow ` object, in UTC time. @@ -700,16 +854,10 @@ def timestamp(self): """ - warnings.warn( - "For compatibility with the datetime.timestamp() method this property will be replaced with a method in " - "the 1.0.0 release, please switch to the .int_timestamp property for identical behaviour as soon as " - "possible.", - DeprecationWarning, - ) - return calendar.timegm(self._datetime.utctimetuple()) + return self._datetime.timestamp() @property - def int_timestamp(self): + def int_timestamp(self) -> int: """Returns a timestamp representation of the :class:`Arrow ` object, in UTC time. @@ -720,10 +868,10 @@ def int_timestamp(self): """ - return calendar.timegm(self._datetime.utctimetuple()) + return int(self.timestamp()) @property - def float_timestamp(self): + def float_timestamp(self) -> float: """Returns a floating-point representation of the :class:`Arrow ` object, in UTC time. @@ -734,35 +882,29 @@ def float_timestamp(self): """ - # IDEA get rid of this in 1.0.0 and wrap datetime.timestamp() - # Or for compatibility retain this but make it call the timestamp method - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - return self.timestamp + float(self.microsecond) / 1000000 + return self.timestamp() @property - def fold(self): + def fold(self) -> int: """ Returns the ``fold`` value of the :class:`Arrow ` object. """ - # in python < 3.6 _datetime will be a _DatetimeWithFold if fold=1 and a datetime with no fold attribute - # otherwise, so we need to return zero to cover the latter case - return getattr(self._datetime, "fold", 0) + return self._datetime.fold @property - def ambiguous(self): + def ambiguous(self) -> bool: """ Returns a boolean indicating whether the :class:`Arrow ` object is ambiguous.""" return dateutil_tz.datetime_ambiguous(self._datetime) @property - def imaginary(self): + def imaginary(self) -> bool: """Indicates whether the :class: `Arrow ` object exists in the current timezone.""" return not dateutil_tz.datetime_exists(self._datetime) # mutation and duplication. - def clone(self): + def clone(self) -> "Arrow": """Returns a new :class:`Arrow ` object, cloned from the current one. Usage: @@ -774,7 +916,7 @@ def clone(self): return self.fromdatetime(self._datetime) - def replace(self, **kwargs): + def replace(self, **kwargs: Any) -> "Arrow": """Returns a new :class:`Arrow ` object with attributes updated according to inputs. @@ -802,9 +944,9 @@ def replace(self, **kwargs): if key in self._ATTRS: absolute_kwargs[key] = value elif key in ["week", "quarter"]: - raise AttributeError("setting absolute {} is not supported".format(key)) + raise ValueError(f"Setting absolute {key} is not supported.") elif key not in ["tzinfo", "fold"]: - raise AttributeError('unknown attribute: "{}"'.format(key)) + raise ValueError(f"Unknown attribute: {key!r}.") current = self._datetime.replace(**absolute_kwargs) @@ -816,13 +958,12 @@ def replace(self, **kwargs): fold = kwargs.get("fold") - # TODO revisit this once we drop support for 2.7/3.5 if fold is not None: - current = dateutil_tz.enfold(current, fold=fold) + current = current.replace(fold=fold) return self.fromdatetime(current) - def shift(self, **kwargs): + def shift(self, **kwargs: Any) -> "Arrow": """Returns a new :class:`Arrow ` object with attributes updated according to inputs. @@ -860,10 +1001,9 @@ def shift(self, **kwargs): if key in self._ATTRS_PLURAL or key in additional_attrs: relative_kwargs[key] = value else: - raise AttributeError( - "Invalid shift time frame. Please select one of the following: {}.".format( - ", ".join(self._ATTRS_PLURAL + additional_attrs) - ) + supported_attr = ", ".join(self._ATTRS_PLURAL + additional_attrs) + raise ValueError( + f"Invalid shift time frame. Please select one of the following: {supported_attr}." ) # core datetime does not support quarters, translate to months. @@ -879,7 +1019,7 @@ def shift(self, **kwargs): return self.fromdatetime(current) - def to(self, tz): + def to(self, tz: TZ_EXPR) -> "Arrow": """Returns a new :class:`Arrow ` object, converted to the target timezone. @@ -927,11 +1067,12 @@ def to(self, tz): # string output and formatting - def format(self, fmt="YYYY-MM-DD HH:mm:ssZZ", locale="en_us"): + def format(self, fmt: str = "YYYY-MM-DD HH:mm:ssZZ", locale: str = "en_us") -> str: """Returns a string representation of the :class:`Arrow ` object, formatted according to a format string. :param fmt: the format string. + :param locale: the locale to format. Usage:: @@ -952,8 +1093,12 @@ def format(self, fmt="YYYY-MM-DD HH:mm:ssZZ", locale="en_us"): return formatter.DateTimeFormatter(locale).format(self._datetime, fmt) def humanize( - self, other=None, locale="en_us", only_distance=False, granularity="auto" - ): + self, + other: Union["Arrow", dt_datetime, None] = None, + locale: str = "en_us", + only_distance: bool = False, + granularity: Union[_GRANULARITY, List[_GRANULARITY]] = "auto", + ) -> str: """Returns a localized, humanized representation of a relative difference in time. :param other: (optional) an :class:`Arrow ` or ``datetime`` object. @@ -979,13 +1124,13 @@ def humanize( locale = locales.get_locale(locale) if other is None: - utc = datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc()) + utc = dt_datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc()) dt = utc.astimezone(self._datetime.tzinfo) elif isinstance(other, Arrow): dt = other._datetime - elif isinstance(other, datetime): + elif isinstance(other, dt_datetime): if other.tzinfo is None: dt = other.replace(tzinfo=self._datetime.tzinfo) else: @@ -993,161 +1138,151 @@ def humanize( else: raise TypeError( - "Invalid 'other' argument of type '{}'. " - "Argument must be of type None, Arrow, or datetime.".format( - type(other).__name__ - ) + f"Invalid 'other' argument of type {type(other).__name__!r}. " + "Argument must be of type None, Arrow, or datetime." ) if isinstance(granularity, list) and len(granularity) == 1: granularity = granularity[0] - delta = int(round(util.total_seconds(self._datetime - dt))) - sign = -1 if delta < 0 else 1 - diff = abs(delta) - delta = diff + _delta = int(round((self._datetime - dt).total_seconds())) + sign = -1 if _delta < 0 else 1 + delta_second = diff = abs(_delta) try: if granularity == "auto": if diff < 10: return locale.describe("now", only_distance=only_distance) - if diff < 45: - seconds = sign * delta + if diff < self._SECS_PER_MINUTE: + seconds = sign * delta_second return locale.describe( "seconds", seconds, only_distance=only_distance ) - elif diff < 90: + elif diff < self._SECS_PER_MINUTE * 2: return locale.describe("minute", sign, only_distance=only_distance) - elif diff < 2700: - minutes = sign * int(max(delta / 60, 2)) + elif diff < self._SECS_PER_HOUR: + minutes = sign * max(delta_second // self._SECS_PER_MINUTE, 2) return locale.describe( "minutes", minutes, only_distance=only_distance ) - elif diff < 5400: + elif diff < self._SECS_PER_HOUR * 2: return locale.describe("hour", sign, only_distance=only_distance) - elif diff < 79200: - hours = sign * int(max(delta / 3600, 2)) + elif diff < self._SECS_PER_DAY: + hours = sign * max(delta_second // self._SECS_PER_HOUR, 2) return locale.describe("hours", hours, only_distance=only_distance) - - # anything less than 48 hours should be 1 day - elif diff < 172800: + elif diff < self._SECS_PER_DAY * 2: return locale.describe("day", sign, only_distance=only_distance) - elif diff < 554400: - days = sign * int(max(delta / 86400, 2)) + elif diff < self._SECS_PER_WEEK: + days = sign * max(delta_second // self._SECS_PER_DAY, 2) return locale.describe("days", days, only_distance=only_distance) - elif diff < 907200: + elif diff < self._SECS_PER_WEEK * 2: return locale.describe("week", sign, only_distance=only_distance) - elif diff < 2419200: - weeks = sign * int(max(delta / 604800, 2)) + elif diff < self._SECS_PER_MONTH: + weeks = sign * max(delta_second // self._SECS_PER_WEEK, 2) return locale.describe("weeks", weeks, only_distance=only_distance) - elif diff < 3888000: + elif diff < self._SECS_PER_MONTH * 2: return locale.describe("month", sign, only_distance=only_distance) - elif diff < 29808000: + elif diff < self._SECS_PER_YEAR: + # TODO revisit for humanization during leap years self_months = self._datetime.year * 12 + self._datetime.month other_months = dt.year * 12 + dt.month - months = sign * int(max(abs(other_months - self_months), 2)) + months = sign * max(abs(other_months - self_months), 2) return locale.describe( "months", months, only_distance=only_distance ) - elif diff < 47260800: + elif diff < self._SECS_PER_YEAR * 2: return locale.describe("year", sign, only_distance=only_distance) else: - years = sign * int(max(delta / 31536000, 2)) + years = sign * max(delta_second // self._SECS_PER_YEAR, 2) return locale.describe("years", years, only_distance=only_distance) - elif util.isstr(granularity): + elif isinstance(granularity, str): + granularity = cast(TimeFrameLiteral, granularity) # type: ignore[assignment] + if granularity == "second": - delta = sign * delta + delta = sign * float(delta_second) if abs(delta) < 2: return locale.describe("now", only_distance=only_distance) elif granularity == "minute": - delta = sign * delta / self._SECS_PER_MINUTE + delta = sign * delta_second / self._SECS_PER_MINUTE elif granularity == "hour": - delta = sign * delta / self._SECS_PER_HOUR + delta = sign * delta_second / self._SECS_PER_HOUR elif granularity == "day": - delta = sign * delta / self._SECS_PER_DAY + delta = sign * delta_second / self._SECS_PER_DAY elif granularity == "week": - delta = sign * delta / self._SECS_PER_WEEK + delta = sign * delta_second / self._SECS_PER_WEEK elif granularity == "month": - delta = sign * delta / self._SECS_PER_MONTH + delta = sign * delta_second / self._SECS_PER_MONTH elif granularity == "year": - delta = sign * delta / self._SECS_PER_YEAR + delta = sign * delta_second / self._SECS_PER_YEAR else: - raise AttributeError( - "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" + raise ValueError( + "Invalid level of granularity. " + "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'." ) if trunc(abs(delta)) != 1: - granularity += "s" + granularity += "s" # type: ignore return locale.describe(granularity, delta, only_distance=only_distance) else: - timeframes = [] - if "year" in granularity: - years = sign * delta / self._SECS_PER_YEAR - delta %= self._SECS_PER_YEAR - timeframes.append(["year", years]) - - if "month" in granularity: - months = sign * delta / self._SECS_PER_MONTH - delta %= self._SECS_PER_MONTH - timeframes.append(["month", months]) - - if "week" in granularity: - weeks = sign * delta / self._SECS_PER_WEEK - delta %= self._SECS_PER_WEEK - timeframes.append(["week", weeks]) - - if "day" in granularity: - days = sign * delta / self._SECS_PER_DAY - delta %= self._SECS_PER_DAY - timeframes.append(["day", days]) - - if "hour" in granularity: - hours = sign * delta / self._SECS_PER_HOUR - delta %= self._SECS_PER_HOUR - timeframes.append(["hour", hours]) - - if "minute" in granularity: - minutes = sign * delta / self._SECS_PER_MINUTE - delta %= self._SECS_PER_MINUTE - timeframes.append(["minute", minutes]) - - if "second" in granularity: - seconds = sign * delta - timeframes.append(["second", seconds]) + timeframes: List[Tuple[TimeFrameLiteral, float]] = [] + + def gather_timeframes(_delta: float, _frame: TimeFrameLiteral) -> float: + if _frame in granularity: + value = sign * _delta / self._SECS_MAP[_frame] + _delta %= self._SECS_MAP[_frame] + if trunc(abs(value)) != 1: + timeframes.append( + (cast(TimeFrameLiteral, _frame + "s"), value) + ) + else: + timeframes.append((_frame, value)) + return _delta + + delta = float(delta_second) + frames: Tuple[TimeFrameLiteral, ...] = ( + "year", + "month", + "week", + "day", + "hour", + "minute", + "second", + ) + for frame in frames: + delta = gather_timeframes(delta, frame) if len(timeframes) < len(granularity): - raise AttributeError( + raise ValueError( "Invalid level of granularity. " "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'." ) - for tf in timeframes: - # Make granularity plural if the delta is not equal to 1 - if trunc(abs(tf[1])) != 1: - tf[0] += "s" return locale.describe_multi(timeframes, only_distance=only_distance) except KeyError as e: raise ValueError( - "Humanization of the {} granularity is not currently translated in the '{}' locale. " - "Please consider making a contribution to this locale.".format( - e, locale_name - ) + f"Humanization of the {e} granularity is not currently translated in the {locale_name!r} locale. " + "Please consider making a contribution to this locale." ) # query functions - def is_between(self, start, end, bounds="()"): + def is_between( + self, + start: "Arrow", + end: "Arrow", + bounds: _BOUNDS = "()", + ) -> bool: """Returns a boolean denoting whether the specified date and time is between the start and end dates and times. @@ -1181,42 +1316,28 @@ def is_between(self, start, end, bounds="()"): if not isinstance(start, Arrow): raise TypeError( - "Can't parse start date argument type of '{}'".format(type(start)) + f"Cannot parse start date argument type of {type(start)!r}." ) if not isinstance(end, Arrow): - raise TypeError( - "Can't parse end date argument type of '{}'".format(type(end)) - ) + raise TypeError(f"Cannot parse end date argument type of {type(start)!r}.") include_start = bounds[0] == "[" include_end = bounds[1] == "]" - target_timestamp = self.float_timestamp - start_timestamp = start.float_timestamp - end_timestamp = end.float_timestamp + target_ts = self.float_timestamp + start_ts = start.float_timestamp + end_ts = end.float_timestamp - if include_start and include_end: - return ( - target_timestamp >= start_timestamp - and target_timestamp <= end_timestamp - ) - elif include_start and not include_end: - return ( - target_timestamp >= start_timestamp and target_timestamp < end_timestamp - ) - elif not include_start and include_end: - return ( - target_timestamp > start_timestamp and target_timestamp <= end_timestamp - ) - else: - return ( - target_timestamp > start_timestamp and target_timestamp < end_timestamp - ) + return ( + (start_ts <= target_ts <= end_ts) + and (include_start or start_ts < target_ts) + and (include_end or target_ts < end_ts) + ) # datetime methods - def date(self): + def date(self) -> date: """Returns a ``date`` object with the same year, month and day. Usage:: @@ -1228,7 +1349,7 @@ def date(self): return self._datetime.date() - def time(self): + def time(self) -> dt_time: """Returns a ``time`` object with the same hour, minute, second, microsecond. Usage:: @@ -1240,7 +1361,7 @@ def time(self): return self._datetime.time() - def timetz(self): + def timetz(self) -> dt_time: """Returns a ``time`` object with the same hour, minute, second, microsecond and tzinfo. @@ -1253,7 +1374,7 @@ def timetz(self): return self._datetime.timetz() - def astimezone(self, tz): + def astimezone(self, tz: Optional[dt_tzinfo]) -> dt_datetime: """Returns a ``datetime`` object, converted to the specified timezone. :param tz: a ``tzinfo`` object. @@ -1269,7 +1390,7 @@ def astimezone(self, tz): return self._datetime.astimezone(tz) - def utcoffset(self): + def utcoffset(self) -> Optional[timedelta]: """Returns a ``timedelta`` object representing the whole number of minutes difference from UTC time. @@ -1282,7 +1403,7 @@ def utcoffset(self): return self._datetime.utcoffset() - def dst(self): + def dst(self) -> Optional[timedelta]: """Returns the daylight savings time adjustment. Usage:: @@ -1294,7 +1415,7 @@ def dst(self): return self._datetime.dst() - def timetuple(self): + def timetuple(self) -> struct_time: """Returns a ``time.struct_time``, in the current timezone. Usage:: @@ -1306,7 +1427,7 @@ def timetuple(self): return self._datetime.timetuple() - def utctimetuple(self): + def utctimetuple(self) -> struct_time: """Returns a ``time.struct_time``, in UTC time. Usage:: @@ -1318,7 +1439,7 @@ def utctimetuple(self): return self._datetime.utctimetuple() - def toordinal(self): + def toordinal(self) -> int: """Returns the proleptic Gregorian ordinal of the date. Usage:: @@ -1330,7 +1451,7 @@ def toordinal(self): return self._datetime.toordinal() - def weekday(self): + def weekday(self) -> int: """Returns the day of the week as an integer (0-6). Usage:: @@ -1342,7 +1463,7 @@ def weekday(self): return self._datetime.weekday() - def isoweekday(self): + def isoweekday(self) -> int: """Returns the ISO day of the week as an integer (1-7). Usage:: @@ -1354,7 +1475,7 @@ def isoweekday(self): return self._datetime.isoweekday() - def isocalendar(self): + def isocalendar(self) -> Tuple[int, int, int]: """Returns a 3-tuple, (ISO year, ISO week number, ISO weekday). Usage:: @@ -1366,7 +1487,7 @@ def isocalendar(self): return self._datetime.isocalendar() - def isoformat(self, sep="T"): + def isoformat(self, sep: str = "T", timespec: str = "auto") -> str: """Returns an ISO 8601 formatted representation of the date and time. Usage:: @@ -1376,9 +1497,9 @@ def isoformat(self, sep="T"): """ - return self._datetime.isoformat(sep) + return self._datetime.isoformat(sep, timespec) - def ctime(self): + def ctime(self) -> str: """Returns a ctime formatted representation of the date and time. Usage:: @@ -1390,7 +1511,7 @@ def ctime(self): return self._datetime.ctime() - def strftime(self, format): + def strftime(self, format: str) -> str: """Formats in the style of ``datetime.strftime``. :param format: the format string. @@ -1404,7 +1525,7 @@ def strftime(self, format): return self._datetime.strftime(format) - def for_json(self): + def for_json(self) -> str: """Serializes for the ``for_json`` protocol of simplejson. Usage:: @@ -1418,22 +1539,30 @@ def for_json(self): # math - def __add__(self, other): + def __add__(self, other: Any) -> "Arrow": if isinstance(other, (timedelta, relativedelta)): return self.fromdatetime(self._datetime + other, self._datetime.tzinfo) return NotImplemented - def __radd__(self, other): + def __radd__(self, other: Union[timedelta, relativedelta]) -> "Arrow": return self.__add__(other) - def __sub__(self, other): + @overload + def __sub__(self, other: Union[timedelta, relativedelta]) -> "Arrow": + pass # pragma: no cover + + @overload + def __sub__(self, other: Union[dt_datetime, "Arrow"]) -> timedelta: + pass # pragma: no cover + + def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]: if isinstance(other, (timedelta, relativedelta)): return self.fromdatetime(self._datetime - other, self._datetime.tzinfo) - elif isinstance(other, datetime): + elif isinstance(other, dt_datetime): return self._datetime - other elif isinstance(other, Arrow): @@ -1441,68 +1570,61 @@ def __sub__(self, other): return NotImplemented - def __rsub__(self, other): + def __rsub__(self, other: Any) -> timedelta: - if isinstance(other, datetime): + if isinstance(other, dt_datetime): return other - self._datetime return NotImplemented # comparisons - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return False return self._datetime == self._get_datetime(other) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return True return not self.__eq__(other) - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return NotImplemented return self._datetime > self._get_datetime(other) - def __ge__(self, other): + def __ge__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return NotImplemented return self._datetime >= self._get_datetime(other) - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return NotImplemented return self._datetime < self._get_datetime(other) - def __le__(self, other): + def __le__(self, other: Any) -> bool: - if not isinstance(other, (Arrow, datetime)): + if not isinstance(other, (Arrow, dt_datetime)): return NotImplemented return self._datetime <= self._get_datetime(other) - def __cmp__(self, other): - if sys.version_info[0] < 3: # pragma: no cover - if not isinstance(other, (Arrow, datetime)): - raise TypeError( - "can't compare '{}' to '{}'".format(type(self), type(other)) - ) - # internal methods @staticmethod - def _get_tzinfo(tz_expr): + def _get_tzinfo(tz_expr: Optional[TZ_EXPR]) -> dt_tzinfo: if tz_expr is None: return dateutil_tz.tzutc() @@ -1512,61 +1634,59 @@ def _get_tzinfo(tz_expr): try: return parser.TzinfoParser.parse(tz_expr) except parser.ParserError: - raise ValueError("'{}' not recognized as a timezone".format(tz_expr)) + raise ValueError(f"{tz_expr!r} not recognized as a timezone.") @classmethod - def _get_datetime(cls, expr): + def _get_datetime( + cls, expr: Union["Arrow", dt_datetime, int, float, str] + ) -> dt_datetime: """Get datetime object for a specified expression.""" if isinstance(expr, Arrow): return expr.datetime - elif isinstance(expr, datetime): + elif isinstance(expr, dt_datetime): return expr elif util.is_timestamp(expr): timestamp = float(expr) return cls.utcfromtimestamp(timestamp).datetime else: - raise ValueError( - "'{}' not recognized as a datetime or timestamp.".format(expr) - ) + raise ValueError(f"{expr!r} not recognized as a datetime or timestamp.") @classmethod - def _get_frames(cls, name): + def _get_frames(cls, name: _T_FRAMES) -> Tuple[str, str, int]: if name in cls._ATTRS: - return name, "{}s".format(name), 1 + return name, f"{name}s", 1 elif name[-1] == "s" and name[:-1] in cls._ATTRS: return name[:-1], name, 1 elif name in ["week", "weeks"]: return "week", "weeks", 1 elif name in ["quarter", "quarters"]: return "quarter", "months", 3 - - supported = ", ".join( - [ - "year(s)", - "month(s)", - "day(s)", - "hour(s)", - "minute(s)", - "second(s)", - "microsecond(s)", - "week(s)", - "quarter(s)", - ] - ) - raise AttributeError( - "range/span over frame {} not supported. Supported frames: {}".format( - name, supported + else: + supported = ", ".join( + [ + "year(s)", + "month(s)", + "day(s)", + "hour(s)", + "minute(s)", + "second(s)", + "microsecond(s)", + "week(s)", + "quarter(s)", + ] + ) + raise ValueError( + f"Range or span over frame {name} not supported. Supported frames: {supported}." ) - ) @classmethod - def _get_iteration_params(cls, end, limit): + def _get_iteration_params(cls, end: Any, limit: Optional[int]) -> Tuple[Any, int]: if end is None: if limit is None: - raise ValueError("one of 'end' or 'limit' is required") + raise ValueError("One of 'end' or 'limit' is required.") return cls.max, limit @@ -1576,9 +1696,9 @@ def _get_iteration_params(cls, end, limit): return end, limit @staticmethod - def _is_last_day_of_month(date): + def _is_last_day_of_month(date: "Arrow") -> bool: return date.day == calendar.monthrange(date.year, date.month)[1] -Arrow.min = Arrow.fromdatetime(datetime.min) -Arrow.max = Arrow.fromdatetime(datetime.max) +Arrow.min = Arrow.fromdatetime(dt_datetime.min) +Arrow.max = Arrow.fromdatetime(dt_datetime.max) diff --git a/arrow/constants.py b/arrow/constants.py index 81e37b26d..1bf36d6c3 100644 --- a/arrow/constants.py +++ b/arrow/constants.py @@ -1,9 +1,33 @@ -# -*- coding: utf-8 -*- - -# Output of time.mktime(datetime.max.timetuple()) on macOS -# This value must be hardcoded for compatibility with Windows -# Platform-independent max timestamps are hard to form -# https://stackoverflow.com/q/46133223 -MAX_TIMESTAMP = 253402318799.0 -MAX_TIMESTAMP_MS = MAX_TIMESTAMP * 1000 -MAX_TIMESTAMP_US = MAX_TIMESTAMP * 1000000 +import sys +from datetime import datetime + +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Final +else: + from typing import Final # pragma: no cover + +# datetime.max.timestamp() errors on Windows, so we must hardcode +# the highest possible datetime value that can output a timestamp. +# tl;dr platform-independent max timestamps are hard to form +# See: https://stackoverflow.com/q/46133223 +try: + # Get max timestamp. Works on POSIX-based systems like Linux and macOS, + # but will trigger an OverflowError, ValueError, or OSError on Windows + _MAX_TIMESTAMP = datetime.max.timestamp() +except (OverflowError, ValueError, OSError): # pragma: no cover + # Fallback for Windows if initial max timestamp call fails + # Must get max value of ctime on Windows based on architecture (x32 vs x64) + # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/ctime-ctime32-ctime64-wctime-wctime32-wctime64 + is_64bits = sys.maxsize > 2 ** 32 + _MAX_TIMESTAMP = ( + datetime(3000, 12, 31, 23, 59, 59, 999999).timestamp() + if is_64bits + else datetime(2038, 1, 18, 23, 59, 59, 999999).timestamp() + ) + +MAX_TIMESTAMP: Final[float] = _MAX_TIMESTAMP +MAX_TIMESTAMP_MS: Final[float] = MAX_TIMESTAMP * 1000 +MAX_TIMESTAMP_US: Final[float] = MAX_TIMESTAMP * 1_000_000 + +MAX_ORDINAL: Final[int] = datetime.max.toordinal() +MIN_ORDINAL: Final[int] = 1 diff --git a/arrow/factory.py b/arrow/factory.py index 05933e815..231510f58 100644 --- a/arrow/factory.py +++ b/arrow/factory.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Implements the :class:`ArrowFactory ` class, providing factory methods for common :class:`Arrow ` @@ -6,21 +5,21 @@ """ -from __future__ import absolute_import import calendar from datetime import date, datetime from datetime import tzinfo as dt_tzinfo from time import struct_time +from typing import Any, List, Optional, Tuple, Type, Union, overload from dateutil import tz as dateutil_tz from arrow import parser -from arrow.arrow import Arrow -from arrow.util import is_timestamp, iso_to_gregorian, isstr +from arrow.arrow import TZ_EXPR, Arrow +from arrow.util import is_timestamp, iso_to_gregorian -class ArrowFactory(object): +class ArrowFactory: """A factory for generating :class:`Arrow ` objects. :param type: (optional) the :class:`Arrow `-based class to construct from. @@ -28,10 +27,67 @@ class ArrowFactory(object): """ - def __init__(self, type=Arrow): + type: Type[Arrow] + + def __init__(self, type: Type[Arrow] = Arrow) -> None: self.type = type - def get(self, *args, **kwargs): + @overload + def get( + self, + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, + ) -> Arrow: + ... # pragma: no cover + + @overload + def get( + self, + __obj: Union[ + Arrow, + datetime, + date, + struct_time, + dt_tzinfo, + int, + float, + str, + Tuple[int, int, int], + ], + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, + ) -> Arrow: + ... # pragma: no cover + + @overload + def get( + self, + __arg1: Union[datetime, date], + __arg2: TZ_EXPR, + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, + ) -> Arrow: + ... # pragma: no cover + + @overload + def get( + self, + __arg1: str, + __arg2: Union[str, List[str]], + *, + locale: str = "en_us", + tzinfo: Optional[TZ_EXPR] = None, + normalize_whitespace: bool = False, + ) -> Arrow: + ... # pragma: no cover + + def get(self, *args: Any, **kwargs: Any) -> Arrow: """Returns an :class:`Arrow ` object based on flexible inputs. :param locale: (optional) a ``str`` specifying a locale for the parser. Defaults to 'en_us'. @@ -155,7 +211,7 @@ def get(self, *args, **kwargs): # () -> now, @ utc. if arg_count == 0: - if isstr(tz): + if isinstance(tz, str): tz = parser.TzinfoParser.parse(tz) return self.type.now(tz) @@ -167,12 +223,12 @@ def get(self, *args, **kwargs): if arg_count == 1: arg = args[0] - # (None) -> now, @ utc. + # (None) -> raises an exception if arg is None: - return self.type.utcnow() + raise TypeError("Cannot parse argument of type None.") # try (int, float) -> from timestamp with tz - elif not isstr(arg) and is_timestamp(arg): + elif not isinstance(arg, str) and is_timestamp(arg): if tz is None: # set to UTC by default tz = dateutil_tz.tzutc() @@ -195,7 +251,7 @@ def get(self, *args, **kwargs): return self.type.now(arg) # (str) -> parse. - elif isstr(arg): + elif isinstance(arg, str): dt = parser.DateTimeParser(locale).parse_iso(arg, normalize_whitespace) return self.type.fromdatetime(dt, tz) @@ -205,13 +261,11 @@ def get(self, *args, **kwargs): # (iso calendar) -> convert then from date elif isinstance(arg, tuple) and len(arg) == 3: - dt = iso_to_gregorian(*arg) - return self.type.fromdate(dt) + d = iso_to_gregorian(*arg) + return self.type.fromdate(d) else: - raise TypeError( - "Can't parse single argument of type '{}'".format(type(arg)) - ) + raise TypeError(f"Cannot parse single argument of type {type(arg)!r}.") elif arg_count == 2: @@ -220,29 +274,25 @@ def get(self, *args, **kwargs): if isinstance(arg_1, datetime): # (datetime, tzinfo/str) -> fromdatetime replace tzinfo. - if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + if isinstance(arg_2, (dt_tzinfo, str)): return self.type.fromdatetime(arg_1, arg_2) else: raise TypeError( - "Can't parse two arguments of types 'datetime', '{}'".format( - type(arg_2) - ) + f"Cannot parse two arguments of types 'datetime', {type(arg_2)!r}." ) elif isinstance(arg_1, date): # (date, tzinfo/str) -> fromdate replace tzinfo. - if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + if isinstance(arg_2, (dt_tzinfo, str)): return self.type.fromdate(arg_1, tzinfo=arg_2) else: raise TypeError( - "Can't parse two arguments of types 'date', '{}'".format( - type(arg_2) - ) + f"Cannot parse two arguments of types 'date', {type(arg_2)!r}." ) # (str, format) -> parse. - elif isstr(arg_1) and (isstr(arg_2) or isinstance(arg_2, list)): + elif isinstance(arg_1, str) and isinstance(arg_2, (str, list)): dt = parser.DateTimeParser(locale).parse( args[0], args[1], normalize_whitespace ) @@ -250,16 +300,14 @@ def get(self, *args, **kwargs): else: raise TypeError( - "Can't parse two arguments of types '{}' and '{}'".format( - type(arg_1), type(arg_2) - ) + f"Cannot parse two arguments of types {type(arg_1)!r} and {type(arg_2)!r}." ) # 3+ args -> datetime-like via constructor. else: return self.type(*args, **kwargs) - def utcnow(self): + def utcnow(self) -> Arrow: """Returns an :class:`Arrow ` object, representing "now" in UTC time. Usage:: @@ -271,7 +319,7 @@ def utcnow(self): return self.type.utcnow() - def now(self, tz=None): + def now(self, tz: Optional[TZ_EXPR] = None) -> Arrow: """Returns an :class:`Arrow ` object, representing "now" in the given timezone. diff --git a/arrow/formatter.py b/arrow/formatter.py index 9f9d7a44d..14eb44a20 100644 --- a/arrow/formatter.py +++ b/arrow/formatter.py @@ -1,44 +1,54 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division - -import calendar import re +import sys +from datetime import datetime, timedelta +from typing import Optional, Pattern, cast from dateutil import tz as dateutil_tz -from arrow import locales, util +from arrow import locales + +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Final +else: + from typing import Final # pragma: no cover -FORMAT_ATOM = "YYYY-MM-DD HH:mm:ssZZ" -FORMAT_COOKIE = "dddd, DD-MMM-YYYY HH:mm:ss ZZZ" -FORMAT_RFC822 = "ddd, DD MMM YY HH:mm:ss Z" -FORMAT_RFC850 = "dddd, DD-MMM-YY HH:mm:ss ZZZ" -FORMAT_RFC1036 = "ddd, DD MMM YY HH:mm:ss Z" -FORMAT_RFC1123 = "ddd, DD MMM YYYY HH:mm:ss Z" -FORMAT_RFC2822 = "ddd, DD MMM YYYY HH:mm:ss Z" -FORMAT_RFC3339 = "YYYY-MM-DD HH:mm:ssZZ" -FORMAT_RSS = "ddd, DD MMM YYYY HH:mm:ss Z" -FORMAT_W3C = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_ATOM: Final[str] = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_COOKIE: Final[str] = "dddd, DD-MMM-YYYY HH:mm:ss ZZZ" +FORMAT_RFC822: Final[str] = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC850: Final[str] = "dddd, DD-MMM-YY HH:mm:ss ZZZ" +FORMAT_RFC1036: Final[str] = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC1123: Final[str] = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC2822: Final[str] = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC3339: Final[str] = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_RSS: Final[str] = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_W3C: Final[str] = "YYYY-MM-DD HH:mm:ssZZ" -class DateTimeFormatter(object): + +class DateTimeFormatter: # This pattern matches characters enclosed in square brackets are matched as # an atomic group. For more info on atomic groups and how to they are # emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578 - _FORMAT_RE = re.compile( + _FORMAT_RE: Final[Pattern[str]] = re.compile( r"(\[(?:(?=(?P[^]]))(?P=literal))*\]|YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X|x|W)" ) - def __init__(self, locale="en_us"): + locale: locales.Locale + + def __init__(self, locale: str = "en_us") -> None: self.locale = locales.get_locale(locale) - def format(cls, dt, fmt): + def format(cls, dt: datetime, fmt: str) -> str: - return cls._FORMAT_RE.sub(lambda m: cls._format_token(dt, m.group(0)), fmt) + # FIXME: _format_token() is nullable + return cls._FORMAT_RE.sub( + lambda m: cast(str, cls._format_token(dt, m.group(0))), fmt + ) - def _format_token(self, dt, token): + def _format_token(self, dt: datetime, token: Optional[str]) -> Optional[str]: if token and token.startswith("[") and token.endswith("]"): return token[1:-1] @@ -53,18 +63,18 @@ def _format_token(self, dt, token): if token == "MMM": return self.locale.month_abbreviation(dt.month) if token == "MM": - return "{:02d}".format(dt.month) + return f"{dt.month:02d}" if token == "M": - return str(dt.month) + return f"{dt.month}" if token == "DDDD": - return "{:03d}".format(dt.timetuple().tm_yday) + return f"{dt.timetuple().tm_yday:03d}" if token == "DDD": - return str(dt.timetuple().tm_yday) + return f"{dt.timetuple().tm_yday}" if token == "DD": - return "{:02d}".format(dt.day) + return f"{dt.day:02d}" if token == "D": - return str(dt.day) + return f"{dt.day}" if token == "Do": return self.locale.ordinal_number(dt.day) @@ -74,48 +84,45 @@ def _format_token(self, dt, token): if token == "ddd": return self.locale.day_abbreviation(dt.isoweekday()) if token == "d": - return str(dt.isoweekday()) + return f"{dt.isoweekday()}" if token == "HH": - return "{:02d}".format(dt.hour) + return f"{dt.hour:02d}" if token == "H": - return str(dt.hour) + return f"{dt.hour}" if token == "hh": - return "{:02d}".format(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + return f"{dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12):02d}" if token == "h": - return str(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + return f"{dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)}" if token == "mm": - return "{:02d}".format(dt.minute) + return f"{dt.minute:02d}" if token == "m": - return str(dt.minute) + return f"{dt.minute}" if token == "ss": - return "{:02d}".format(dt.second) + return f"{dt.second:02d}" if token == "s": - return str(dt.second) + return f"{dt.second}" if token == "SSSSSS": - return str("{:06d}".format(int(dt.microsecond))) + return f"{dt.microsecond:06d}" if token == "SSSSS": - return str("{:05d}".format(int(dt.microsecond / 10))) + return f"{dt.microsecond // 10:05d}" if token == "SSSS": - return str("{:04d}".format(int(dt.microsecond / 100))) + return f"{dt.microsecond // 100:04d}" if token == "SSS": - return str("{:03d}".format(int(dt.microsecond / 1000))) + return f"{dt.microsecond // 1000:03d}" if token == "SS": - return str("{:02d}".format(int(dt.microsecond / 10000))) + return f"{dt.microsecond // 10000:02d}" if token == "S": - return str(int(dt.microsecond / 100000)) + return f"{dt.microsecond // 100000}" if token == "X": - # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 - return str(calendar.timegm(dt.utctimetuple())) + return f"{dt.timestamp()}" if token == "x": - # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 - ts = calendar.timegm(dt.utctimetuple()) + (dt.microsecond / 1000000) - return str(int(ts * 1000000)) + return f"{dt.timestamp() * 1_000_000:.0f}" if token == "ZZZ": return dt.tzname() @@ -123,17 +130,20 @@ def _format_token(self, dt, token): if token in ["ZZ", "Z"]: separator = ":" if token == "ZZ" else "" tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo - total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60) + # `dt` must be aware object. Otherwise, this line will raise AttributeError + # https://github.com/arrow-py/arrow/pull/883#discussion_r529866834 + # datetime awareness: https://docs.python.org/3/library/datetime.html#aware-and-naive-objects + total_minutes = int(cast(timedelta, tz.utcoffset(dt)).total_seconds() / 60) sign = "+" if total_minutes >= 0 else "-" total_minutes = abs(total_minutes) hour, minute = divmod(total_minutes, 60) - return "{}{:02d}{}{:02d}".format(sign, hour, separator, minute) + return f"{sign}{hour:02d}{separator}{minute:02d}" if token in ("a", "A"): return self.locale.meridian(dt.hour, token) if token == "W": year, week, day = dt.isocalendar() - return "{}-W{:02d}-{}".format(year, week, day) + return f"{year}-W{week:02d}-{day}" diff --git a/arrow/locales.py b/arrow/locales.py index 6833da5a7..73b57533c 100644 --- a/arrow/locales.py +++ b/arrow/locales.py @@ -1,14 +1,56 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - import inspect import sys from math import trunc - - -def get_locale(name): +from typing import ( + Any, + ClassVar, + Dict, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) + +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Literal +else: + from typing import Literal # pragma: no cover + +TimeFrameLiteral = Literal[ + "now", + "second", + "seconds", + "minute", + "minutes", + "hour", + "hours", + "day", + "days", + "week", + "weeks", + "month", + "months", + "year", + "years", + "2-hours", + "2-days", + "2-weeks", + "2-months", + "2-years", +] + +_TimeFrameElements = Union[ + str, Sequence[str], Mapping[str, str], Mapping[str, Sequence[str]] +] + + +def get_locale(name: str) -> "Locale": """Returns an appropriate :class:`Locale ` - corresponding to an inpute locale name. + corresponding to an input locale name. :param name: the name of the locale. @@ -17,22 +59,22 @@ def get_locale(name): locale_cls = _locales.get(name.lower()) if locale_cls is None: - raise ValueError("Unsupported locale '{}'".format(name)) + raise ValueError(f"Unsupported locale {name!r}.") return locale_cls() -def get_locale_by_class_name(name): +def get_locale_by_class_name(name: str) -> "Locale": """Returns an appropriate :class:`Locale ` corresponding to an locale class name. :param name: the name of the locale class. """ - locale_cls = globals().get(name) + locale_cls: Optional[Type[Locale]] = globals().get(name) if locale_cls is None: - raise ValueError("Unsupported locale '{}'".format(name)) + raise ValueError(f"Unsupported locale {name!r}.") return locale_cls() @@ -40,12 +82,12 @@ def get_locale_by_class_name(name): # base locale type. -class Locale(object): +class Locale: """ Represents locale-specific data and functionality. """ - names = [] + names: ClassVar[List[str]] = [] - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, _TimeFrameElements]] = { "now": "", "second": "", "seconds": "", @@ -63,25 +105,32 @@ class Locale(object): "years": "", } - meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + meridians: ClassVar[Dict[str, str]] = {"am": "", "pm": "", "AM": "", "PM": ""} + + past: ClassVar[str] + future: ClassVar[str] + and_word: ClassVar[Optional[str]] = None - past = None - future = None - and_word = None + month_names: ClassVar[List[str]] = [] + month_abbreviations: ClassVar[List[str]] = [] - month_names = [] - month_abbreviations = [] + day_names: ClassVar[List[str]] = [] + day_abbreviations: ClassVar[List[str]] = [] - day_names = [] - day_abbreviations = [] + ordinal_day_re: ClassVar[str] = r"(\d+)" - ordinal_day_re = r"(\d+)" + _month_name_to_ordinal: Optional[Dict[str, int]] - def __init__(self): + def __init__(self) -> None: self._month_name_to_ordinal = None - def describe(self, timeframe, delta=0, only_distance=False): + def describe( + self, + timeframe: TimeFrameLiteral, + delta: Union[float, int] = 0, + only_distance: bool = False, + ) -> str: """Describes a delta within a timeframe in plain language. :param timeframe: a string representing a timeframe. @@ -95,27 +144,30 @@ def describe(self, timeframe, delta=0, only_distance=False): return humanized - def describe_multi(self, timeframes, only_distance=False): + def describe_multi( + self, + timeframes: Sequence[Tuple[TimeFrameLiteral, Union[int, float]]], + only_distance: bool = False, + ) -> str: """Describes a delta within multiple timeframes in plain language. :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. :param only_distance: return only distance eg: "2 hours and 11 seconds" without "in" or "ago" keywords """ - humanized = "" - for index, (timeframe, delta) in enumerate(timeframes): - humanized += self._format_timeframe(timeframe, delta) - if index == len(timeframes) - 2 and self.and_word: - humanized += " " + self.and_word + " " - elif index < len(timeframes) - 1: - humanized += " " + parts = [ + self._format_timeframe(timeframe, delta) for timeframe, delta in timeframes + ] + if self.and_word: + parts.insert(-1, self.and_word) + humanized = " ".join(parts) if not only_distance: - humanized = self._format_relative(humanized, timeframe, delta) + humanized = self._format_relative(humanized, *timeframes[-1]) return humanized - def day_name(self, day): + def day_name(self, day: int) -> str: """Returns the day name for a specified day of the week. :param day: the ``int`` day of the week (1-7). @@ -124,7 +176,7 @@ def day_name(self, day): return self.day_names[day] - def day_abbreviation(self, day): + def day_abbreviation(self, day: int) -> str: """Returns the day abbreviation for a specified day of the week. :param day: the ``int`` day of the week (1-7). @@ -133,7 +185,7 @@ def day_abbreviation(self, day): return self.day_abbreviations[day] - def month_name(self, month): + def month_name(self, month: int) -> str: """Returns the month name for a specified month of the year. :param month: the ``int`` month of the year (1-12). @@ -142,7 +194,7 @@ def month_name(self, month): return self.month_names[month] - def month_abbreviation(self, month): + def month_abbreviation(self, month: int) -> str: """Returns the month abbreviation for a specified month of the year. :param month: the ``int`` month of the year (1-12). @@ -151,7 +203,7 @@ def month_abbreviation(self, month): return self.month_abbreviations[month] - def month_number(self, name): + def month_number(self, name: str) -> Optional[int]: """Returns the month number for a month specified by name or abbreviation. :param name: the month name or abbreviation. @@ -166,21 +218,21 @@ def month_number(self, name): return self._month_name_to_ordinal.get(name) - def year_full(self, year): + def year_full(self, year: int) -> str: """Returns the year for specific locale if available - :param name: the ``int`` year (4-digit) + :param year: the ``int`` year (4-digit) """ - return "{:04d}".format(year) + return f"{year:04d}" - def year_abbreviation(self, year): + def year_abbreviation(self, year: int) -> str: """Returns the year for specific locale if available - :param name: the ``int`` year (4-digit) + :param year: the ``int`` year (4-digit) """ - return "{:04d}".format(year)[2:] + return f"{year:04d}"[2:] - def meridian(self, hour, token): + def meridian(self, hour: int, token: Any) -> Optional[str]: """Returns the meridian indicator for a specified hour and format token. :param hour: the ``int`` hour of the day. @@ -191,24 +243,33 @@ def meridian(self, hour, token): return self.meridians["am"] if hour < 12 else self.meridians["pm"] if token == "A": return self.meridians["AM"] if hour < 12 else self.meridians["PM"] + return None - def ordinal_number(self, n): + def ordinal_number(self, n: int) -> str: """Returns the ordinal format of a given integer :param n: an integer """ return self._ordinal_number(n) - def _ordinal_number(self, n): - return "{}".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}" - def _name_to_ordinal(self, lst): - return dict(map(lambda i: (i[1].lower(), i[0] + 1), enumerate(lst[1:]))) + def _name_to_ordinal(self, lst: Sequence[str]) -> Dict[str, int]: + return {elem.lower(): i for i, elem in enumerate(lst[1:], 1)} - def _format_timeframe(self, timeframe, delta): - return self.timeframes[timeframe].format(trunc(abs(delta))) + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: + # TODO: remove cast + return cast(str, self.timeframes[timeframe]).format(trunc(abs(delta))) - def _format_relative(self, humanized, timeframe, delta): + def _format_relative( + self, + humanized: str, + timeframe: TimeFrameLiteral, + delta: Union[float, int], + ) -> str: if timeframe == "now": return humanized @@ -304,18 +365,23 @@ class EnglishLocale(Locale): ordinal_day_re = r"((?P[2-3]?1(?=st)|[2-3]?2(?=nd)|[2-3]?3(?=rd)|[1-3]?[04-9](?=th)|1[1-3](?=th))(st|nd|rd|th))" - def _ordinal_number(self, n): + def _ordinal_number(self, n: int) -> str: if n % 100 not in (11, 12, 13): remainder = abs(n) % 10 if remainder == 1: - return "{}st".format(n) + return f"{n}st" elif remainder == 2: - return "{}nd".format(n) + return f"{n}nd" elif remainder == 3: - return "{}rd".format(n) - return "{}th".format(n) - - def describe(self, timeframe, delta=0, only_distance=False): + return f"{n}rd" + return f"{n}th" + + def describe( + self, + timeframe: TimeFrameLiteral, + delta: Union[int, float] = 0, + only_distance: bool = False, + ) -> str: """Describes a delta within a timeframe in plain language. :param timeframe: a string representing a timeframe. @@ -323,7 +389,7 @@ def describe(self, timeframe, delta=0, only_distance=False): :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords """ - humanized = super(EnglishLocale, self).describe(timeframe, delta, only_distance) + humanized = super().describe(timeframe, delta, only_distance) if only_distance and timeframe == "now": humanized = "instantly" @@ -399,8 +465,8 @@ class ItalianLocale(Locale): ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" - def _ordinal_number(self, n): - return "{}º".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}º" class SpanishLocale(Locale): @@ -474,8 +540,8 @@ class SpanishLocale(Locale): ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" - def _ordinal_number(self, n): - return "{}º".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}º" class FrenchBaseLocale(Locale): @@ -487,7 +553,7 @@ class FrenchBaseLocale(Locale): timeframes = { "now": "maintenant", "second": "une seconde", - "seconds": "{0} quelques secondes", + "seconds": "{0} secondes", "minute": "une minute", "minutes": "{0} minutes", "hour": "une heure", @@ -534,10 +600,10 @@ class FrenchBaseLocale(Locale): r"((?P\b1(?=er\b)|[1-3]?[02-9](?=e\b)|[1-3]1(?=e\b))(er|e)\b)" ) - def _ordinal_number(self, n): + def _ordinal_number(self, n: int) -> str: if abs(n) == 1: - return "{}er".format(n) - return "{}e".format(n) + return f"{n}er" + return f"{n}e" class FrenchLocale(FrenchBaseLocale, Locale): @@ -656,11 +722,12 @@ class JapaneseLocale(Locale): past = "{0}前" future = "{0}後" + and_word = "" timeframes = { "now": "現在", - "second": "二番目の", - "seconds": "{0}数秒", + "second": "1秒", + "seconds": "{0}秒", "minute": "1分", "minutes": "{0}分", "hour": "1時間", @@ -790,7 +857,7 @@ class FinnishLocale(Locale): past = "{0} sitten" future = "{0} kuluttua" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, List[str]]] = { "now": ["juuri nyt", "juuri nyt"], "second": ["sekunti", "sekunti"], "seconds": ["{0} muutama sekunti", "{0} muutaman sekunnin"], @@ -852,13 +919,19 @@ class FinnishLocale(Locale): day_abbreviations = ["", "ma", "ti", "ke", "to", "pe", "la", "su"] - def _format_timeframe(self, timeframe, delta): + # TODO: Fix return type + def _format_timeframe(self, timeframe: TimeFrameLiteral, delta: Union[float, int]) -> Tuple[str, str]: # type: ignore return ( self.timeframes[timeframe][0].format(abs(delta)), self.timeframes[timeframe][1].format(abs(delta)), ) - def _format_relative(self, humanized, timeframe, delta): + def _format_relative( + self, + humanized: str, + timeframe: TimeFrameLiteral, + delta: Union[float, int], + ) -> str: if timeframe == "now": return humanized[0] @@ -867,8 +940,8 @@ def _format_relative(self, humanized, timeframe, delta): return direction.format(humanized[which]) - def _ordinal_number(self, n): - return "{}.".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}." class ChineseCNLocale(Locale): @@ -1123,23 +1196,28 @@ class KoreanLocale(Locale): day_names = ["", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] day_abbreviations = ["", "월", "화", "수", "목", "금", "토", "일"] - def _ordinal_number(self, n): + def _ordinal_number(self, n: int) -> str: ordinals = ["0", "첫", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉", "열"] if n < len(ordinals): - return "{}번째".format(ordinals[n]) - return "{}번째".format(n) - - def _format_relative(self, humanized, timeframe, delta): + return f"{ordinals[n]}번째" + return f"{n}번째" + + def _format_relative( + self, + humanized: str, + timeframe: TimeFrameLiteral, + delta: Union[float, int], + ) -> str: if timeframe in ("day", "days"): - special = self.special_dayframes.get(delta) + special = self.special_dayframes.get(int(delta)) if special: return special elif timeframe in ("year", "years"): - special = self.special_yearframes.get(delta) + special = self.special_yearframes.get(int(delta)) if special: return special - return super(KoreanLocale, self)._format_relative(humanized, timeframe, delta) + return super()._format_relative(humanized, timeframe, delta) # derived locale types & implementations. @@ -1215,13 +1293,15 @@ class DutchLocale(Locale): class SlavicBaseLocale(Locale): - def _format_timeframe(self, timeframe, delta): + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: form = self.timeframes[timeframe] delta = abs(delta) if isinstance(form, list): - if delta % 10 == 1 and delta % 100 != 11: form = form[0] elif 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): @@ -1239,7 +1319,7 @@ class BelarusianLocale(SlavicBaseLocale): past = "{0} таму" future = "праз {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "зараз", "second": "секунду", "seconds": "{0} некалькі секунд", @@ -1308,7 +1388,7 @@ class PolishLocale(SlavicBaseLocale): # The nouns should be in genitive case (Polish: "dopełniacz") # in order to correctly form `past` & `future` expressions. - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "teraz", "second": "sekundę", "seconds": ["{0} sekund", "{0} sekundy", "{0} sekund"], @@ -1377,7 +1457,7 @@ class RussianLocale(SlavicBaseLocale): past = "{0} назад" future = "через {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "сейчас", "second": "Второй", "seconds": "{0} несколько секунд", @@ -1513,7 +1593,7 @@ class BulgarianLocale(SlavicBaseLocale): past = "{0} назад" future = "напред {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "сега", "second": "секунда", "seconds": "{0} няколко секунди", @@ -1580,7 +1660,7 @@ class UkrainianLocale(SlavicBaseLocale): past = "{0} тому" future = "за {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "зараз", "second": "секунда", "seconds": "{0} кілька секунд", @@ -1646,7 +1726,7 @@ class MacedonianLocale(SlavicBaseLocale): past = "пред {0}" future = "за {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, List[str]]]] = { "now": "сега", "second": "една секунда", "seconds": ["{0} секунда", "{0} секунди", "{0} секунди"], @@ -1727,7 +1807,7 @@ class GermanBaseLocale(Locale): timeframes = { "now": "gerade eben", - "second": "eine Sekunde", + "second": "einer Sekunde", "seconds": "{0} Sekunden", "minute": "einer Minute", "minutes": "{0} Minuten", @@ -1744,12 +1824,16 @@ class GermanBaseLocale(Locale): } timeframes_only_distance = timeframes.copy() + timeframes_only_distance["second"] = "eine Sekunde" timeframes_only_distance["minute"] = "eine Minute" timeframes_only_distance["hour"] = "eine Stunde" timeframes_only_distance["day"] = "ein Tag" + timeframes_only_distance["days"] = "{0} Tage" timeframes_only_distance["week"] = "eine Woche" timeframes_only_distance["month"] = "ein Monat" + timeframes_only_distance["months"] = "{0} Monate" timeframes_only_distance["year"] = "ein Jahr" + timeframes_only_distance["years"] = "{0} Jahre" month_names = [ "", @@ -1796,10 +1880,15 @@ class GermanBaseLocale(Locale): day_abbreviations = ["", "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] - def _ordinal_number(self, n): - return "{}.".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}." - def describe(self, timeframe, delta=0, only_distance=False): + def describe( + self, + timeframe: TimeFrameLiteral, + delta: Union[int, float] = 0, + only_distance: bool = False, + ) -> str: """Describes a delta within a timeframe in plain language. :param timeframe: a string representing a timeframe. @@ -1808,9 +1897,7 @@ def describe(self, timeframe, delta=0, only_distance=False): """ if not only_distance: - return super(GermanBaseLocale, self).describe( - timeframe, delta, only_distance - ) + return super().describe(timeframe, delta, only_distance) # German uses a different case without 'in' or 'ago' humanized = self.timeframes_only_distance[timeframe].format(trunc(abs(delta))) @@ -1858,8 +1945,8 @@ class NorwegianLocale(Locale): timeframes = { "now": "nå nettopp", - "second": "et sekund", - "seconds": "{0} noen sekunder", + "second": "ett sekund", + "seconds": "{0} sekunder", "minute": "ett minutt", "minutes": "{0} minutter", "hour": "en time", @@ -1925,9 +2012,9 @@ class NewNorwegianLocale(Locale): timeframes = { "now": "no nettopp", - "second": "et sekund", - "seconds": "{0} nokre sekund", - "minute": "ett minutt", + "second": "eitt sekund", + "seconds": "{0} sekund", + "minute": "eitt minutt", "minutes": "{0} minutt", "hour": "ein time", "hours": "{0} timar", @@ -1935,7 +2022,7 @@ class NewNorwegianLocale(Locale): "days": "{0} dagar", "month": "en månad", "months": "{0} månader", - "year": "eit år", + "year": "eitt år", "years": "{0} år", } @@ -2128,8 +2215,8 @@ class TagalogLocale(Locale): meridians = {"am": "nu", "pm": "nh", "AM": "ng umaga", "PM": "ng hapon"} - def _ordinal_number(self, n): - return "ika-{}".format(n) + def _ordinal_number(self, n: int) -> str: + return f"ika-{n}" class VietnameseLocale(Locale): @@ -2360,7 +2447,7 @@ class ArabicLocale(Locale): past = "منذ {0}" future = "خلال {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, Mapping[str, str]]]] = { "now": "الآن", "second": "ثانية", "seconds": {"double": "ثانيتين", "ten": "{0} ثوان", "higher": "{0} ثانية"}, @@ -2419,13 +2506,15 @@ class ArabicLocale(Locale): ] day_abbreviations = ["", "إثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"] - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: form = self.timeframes[timeframe] delta = abs(delta) - if isinstance(form, dict): + if isinstance(form, Mapping): if delta == 2: form = form["double"] - elif delta > 2 and delta <= 10: + elif 2 < delta <= 10: form = form["ten"] else: form = form["higher"] @@ -2570,22 +2659,24 @@ class MoroccoArabicLocale(ArabicLocale): class IcelandicLocale(Locale): - def _format_timeframe(self, timeframe, delta): - - timeframe = self.timeframes[timeframe] + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: + form = self.timeframes[timeframe] if delta < 0: - timeframe = timeframe[0] + form = form[0] elif delta > 0: - timeframe = timeframe[1] + form = form[1] + # FIXME: handle when delta is 0 - return timeframe.format(abs(delta)) + return form.format(abs(delta)) # type: ignore names = ["is", "is_is"] past = "fyrir {0} síðan" future = "eftir {0}" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[Tuple[str, str], str]]] = { "now": "rétt í þessu", "second": ("sekúndu", "sekúndu"), "seconds": ("{0} nokkrum sekúndum", "nokkrar sekúndur"), @@ -2861,7 +2952,9 @@ class HindiLocale(Locale): class CzechLocale(Locale): names = ["cs", "cs_cz"] - timeframes = { + timeframes: ClassVar[ + Mapping[TimeFrameLiteral, Union[Mapping[str, Union[List[str], str]], str]] + ] = { "now": "Teď", "second": {"past": "vteřina", "future": "vteřina", "zero": "vteřina"}, "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekund"]}, @@ -2925,32 +3018,42 @@ class CzechLocale(Locale): ] day_abbreviations = ["", "po", "út", "st", "čt", "pá", "so", "ne"] - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: """Czech aware time frame format function, takes into account the differences between past and future forms.""" + abs_delta = abs(delta) form = self.timeframes[timeframe] - if isinstance(form, dict): - if delta == 0: - form = form["zero"] # And *never* use 0 in the singular! - elif delta > 0: - form = form["future"] - else: - form = form["past"] - delta = abs(delta) + + if isinstance(form, str): + return form.format(abs_delta) + + if delta == 0: + key = "zero" # And *never* use 0 in the singular! + elif delta > 0: + key = "future" + else: + key = "past" + form: Union[List[str], str] = form[key] if isinstance(form, list): - if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + if 2 <= abs_delta % 10 <= 4 and ( + abs_delta % 100 < 10 or abs_delta % 100 >= 20 + ): form = form[0] else: form = form[1] - return form.format(delta) + return form.format(abs_delta) class SlovakLocale(Locale): names = ["sk", "sk_sk"] - timeframes = { + timeframes: ClassVar[ + Mapping[TimeFrameLiteral, Union[Mapping[str, Union[List[str], str]], str]] + ] = { "now": "Teraz", "second": {"past": "sekundou", "future": "sekundu", "zero": "{0} sekúnd"}, "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekúnd"]}, @@ -3015,26 +3118,34 @@ class SlovakLocale(Locale): ] day_abbreviations = ["", "po", "ut", "st", "št", "pi", "so", "ne"] - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: """Slovak aware time frame format function, takes into account the differences between past and future forms.""" + abs_delta = abs(delta) form = self.timeframes[timeframe] - if isinstance(form, dict): - if delta == 0: - form = form["zero"] # And *never* use 0 in the singular! - elif delta > 0: - form = form["future"] - else: - form = form["past"] - delta = abs(delta) + + if isinstance(form, str): + return form.format(abs_delta) + + if delta == 0: + key = "zero" # And *never* use 0 in the singular! + elif delta > 0: + key = "future" + else: + key = "past" + form: Union[List[str], str] = form[key] if isinstance(form, list): - if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + if 2 <= abs_delta % 10 <= 4 and ( + abs_delta % 100 < 10 or abs_delta % 100 >= 20 + ): form = form[0] else: form = form[1] - return form.format(delta) + return form.format(abs_delta) class FarsiLocale(Locale): @@ -3183,9 +3294,11 @@ class HebrewLocale(Locale): day_names = ["", "שני", "שלישי", "רביעי", "חמישי", "שישי", "שבת", "ראשון"] day_abbreviations = ["", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳", "א׳"] - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: """Hebrew couple of aware""" - couple = "2-{}".format(timeframe) + couple = f"2-{timeframe}" single = timeframe.rstrip("s") if abs(delta) == 2 and couple in self.timeframes: key = couple @@ -3196,7 +3309,11 @@ def _format_timeframe(self, timeframe, delta): return self.timeframes[key].format(trunc(abs(delta))) - def describe_multi(self, timeframes, only_distance=False): + def describe_multi( + self, + timeframes: Sequence[Tuple[TimeFrameLiteral, Union[int, float]]], + only_distance: bool = False, + ) -> str: """Describes a delta within multiple timeframes in plain language. In Hebrew, the and word behaves a bit differently. @@ -3292,9 +3409,9 @@ class MarathiLocale(Locale): day_abbreviations = ["", "सोम", "मंगळ", "बुध", "गुरु", "शुक्र", "शनि", "रवि"] -def _map_locales(): +def _map_locales() -> Dict[str, Type[Locale]]: - locales = {} + locales: Dict[str, Type[Locale]] = {} for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): if issubclass(cls, Locale): # pragma: no branch @@ -3449,7 +3566,7 @@ class HungarianLocale(Locale): past = "{0} ezelőtt" future = "{0} múlva" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Union[str, Mapping[str, str]]]] = { "now": "éppen most", "second": {"past": "egy második", "future": "egy második"}, "seconds": {"past": "{0} másodpercekkel", "future": "{0} pár másodperc"}, @@ -3510,10 +3627,12 @@ class HungarianLocale(Locale): meridians = {"am": "de", "pm": "du", "AM": "DE", "PM": "DU"} - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: form = self.timeframes[timeframe] - if isinstance(form, dict): + if isinstance(form, Mapping): if delta > 0: form = form["future"] else: @@ -3590,8 +3709,8 @@ class EsperantoLocale(Locale): ordinal_day_re = r"((?P[1-3]?[0-9](?=a))a)" - def _ordinal_number(self, n): - return "{}a".format(n) + def _ordinal_number(self, n: int) -> str: + return f"{n}a" class ThaiLocale(Locale): @@ -3655,17 +3774,22 @@ class ThaiLocale(Locale): BE_OFFSET = 543 - def year_full(self, year): + def year_full(self, year: int) -> str: """Thai always use Buddhist Era (BE) which is CE + 543""" year += self.BE_OFFSET - return "{:04d}".format(year) + return f"{year:04d}" - def year_abbreviation(self, year): + def year_abbreviation(self, year: int) -> str: """Thai always use Buddhist Era (BE) which is CE + 543""" year += self.BE_OFFSET - return "{:04d}".format(year)[2:] - - def _format_relative(self, humanized, timeframe, delta): + return f"{year:04d}"[2:] + + def _format_relative( + self, + humanized: str, + timeframe: TimeFrameLiteral, + delta: Union[float, int], + ) -> str: """Thai normally doesn't have any space between words""" if timeframe == "now": return humanized @@ -3743,17 +3867,17 @@ class BengaliLocale(Locale): ] day_abbreviations = ["", "সোম", "মঙ্গল", "বুধ", "বৃহঃ", "শুক্র", "শনি", "রবি"] - def _ordinal_number(self, n): + def _ordinal_number(self, n: int) -> str: if n > 10 or n == 0: - return "{}তম".format(n) + return f"{n}তম" if n in [1, 5, 7, 8, 9, 10]: - return "{}ম".format(n) + return f"{n}ম" if n in [2, 3]: - return "{}য়".format(n) + return f"{n}য়" if n == 4: - return "{}র্থ".format(n) + return f"{n}র্থ" if n == 6: - return "{}ষ্ঠ".format(n) + return f"{n}ষ্ঠ" class RomanshLocale(Locale): @@ -4111,7 +4235,7 @@ class EstonianLocale(Locale): future = "{0} pärast" and_word = "ja" - timeframes = { + timeframes: ClassVar[Mapping[TimeFrameLiteral, Mapping[str, str]]] = { "now": {"past": "just nüüd", "future": "just nüüd"}, "second": {"past": "üks sekund", "future": "ühe sekundi"}, "seconds": {"past": "{0} sekundit", "future": "{0} sekundi"}, @@ -4170,13 +4294,15 @@ class EstonianLocale(Locale): ] day_abbreviations = ["", "Esm", "Teis", "Kolm", "Nelj", "Re", "Lau", "Püh"] - def _format_timeframe(self, timeframe, delta): + def _format_timeframe( + self, timeframe: TimeFrameLiteral, delta: Union[float, int] + ) -> str: form = self.timeframes[timeframe] if delta > 0: - form = form["future"] + _form = form["future"] else: - form = form["past"] - return form.format(abs(delta)) + _form = form["past"] + return _form.format(abs(delta)) class SwahiliLocale(Locale): @@ -4264,4 +4390,4 @@ class SwahiliLocale(Locale): ] -_locales = _map_locales() +_locales: Dict[str, Type[Locale]] = _map_locales() diff --git a/arrow/parser.py b/arrow/parser.py index 243fd1721..a6a617dbc 100644 --- a/arrow/parser.py +++ b/arrow/parser.py @@ -1,18 +1,34 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - import re +import sys from datetime import datetime, timedelta +from datetime import tzinfo as dt_tzinfo +from functools import lru_cache +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Match, + Optional, + Pattern, + SupportsFloat, + SupportsInt, + Tuple, + Union, + cast, + overload, +) from dateutil import tz from arrow import locales -from arrow.util import iso_to_gregorian, next_weekday, normalize_timestamp +from arrow.util import next_weekday, normalize_timestamp -try: - from functools import lru_cache -except ImportError: # pragma: no cover - from backports.functools_lru_cache import lru_cache # pragma: no cover +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Literal, TypedDict +else: + from typing import Literal, TypedDict # pragma: no cover class ParserError(ValueError): @@ -28,30 +44,87 @@ class ParserMatchError(ParserError): pass -class DateTimeParser(object): - - _FORMAT_RE = re.compile( +_WEEKDATE_ELEMENT = Union[str, bytes, SupportsInt, bytearray] + +_FORMAT_TYPE = Literal[ + "YYYY", + "YY", + "MM", + "M", + "DDDD", + "DDD", + "DD", + "D", + "HH", + "H", + "hh", + "h", + "mm", + "m", + "ss", + "s", + "X", + "x", + "ZZZ", + "ZZ", + "Z", + "S", + "W", + "MMMM", + "MMM", + "Do", + "dddd", + "ddd", + "d", + "a", + "A", +] + + +class _Parts(TypedDict, total=False): + year: int + month: int + day_of_year: int + day: int + hour: int + minute: int + second: int + microsecond: int + timestamp: float + expanded_timestamp: int + tzinfo: dt_tzinfo + am_pm: Literal["am", "pm"] + day_of_week: int + weekdate: Tuple[_WEEKDATE_ELEMENT, _WEEKDATE_ELEMENT, Optional[_WEEKDATE_ELEMENT]] + + +class DateTimeParser: + _FORMAT_RE: ClassVar[Pattern[str]] = re.compile( r"(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|x|X|W)" ) - _ESCAPE_RE = re.compile(r"\[[^\[\]]*\]") - - _ONE_OR_TWO_DIGIT_RE = re.compile(r"\d{1,2}") - _ONE_OR_TWO_OR_THREE_DIGIT_RE = re.compile(r"\d{1,3}") - _ONE_OR_MORE_DIGIT_RE = re.compile(r"\d+") - _TWO_DIGIT_RE = re.compile(r"\d{2}") - _THREE_DIGIT_RE = re.compile(r"\d{3}") - _FOUR_DIGIT_RE = re.compile(r"\d{4}") - _TZ_Z_RE = re.compile(r"([\+\-])(\d{2})(?:(\d{2}))?|Z") - _TZ_ZZ_RE = re.compile(r"([\+\-])(\d{2})(?:\:(\d{2}))?|Z") - _TZ_NAME_RE = re.compile(r"\w[\w+\-/]+") + _ESCAPE_RE: ClassVar[Pattern[str]] = re.compile(r"\[[^\[\]]*\]") + + _ONE_OR_TWO_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d{1,2}") + _ONE_OR_TWO_OR_THREE_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d{1,3}") + _ONE_OR_MORE_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d+") + _TWO_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d{2}") + _THREE_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d{3}") + _FOUR_DIGIT_RE: ClassVar[Pattern[str]] = re.compile(r"\d{4}") + _TZ_Z_RE: ClassVar[Pattern[str]] = re.compile(r"([\+\-])(\d{2})(?:(\d{2}))?|Z") + _TZ_ZZ_RE: ClassVar[Pattern[str]] = re.compile(r"([\+\-])(\d{2})(?:\:(\d{2}))?|Z") + _TZ_NAME_RE: ClassVar[Pattern[str]] = re.compile(r"\w[\w+\-/]+") # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will # break cases like "15 Jul 2000" and a format list (see issue #447) - _TIMESTAMP_RE = re.compile(r"^\-?\d+\.?\d+$") - _TIMESTAMP_EXPANDED_RE = re.compile(r"^\-?\d+$") - _TIME_RE = re.compile(r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$") - _WEEK_DATE_RE = re.compile(r"(?P\d{4})[\-]?W(?P\d{2})[\-]?(?P\d)?") + _TIMESTAMP_RE: ClassVar[Pattern[str]] = re.compile(r"^\-?\d+\.?\d+$") + _TIMESTAMP_EXPANDED_RE: ClassVar[Pattern[str]] = re.compile(r"^\-?\d+$") + _TIME_RE: ClassVar[Pattern[str]] = re.compile( + r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$" + ) + _WEEK_DATE_RE: ClassVar[Pattern[str]] = re.compile( + r"(?P\d{4})[\-]?W(?P\d{2})[\-]?(?P\d)?" + ) - _BASE_INPUT_RE_MAP = { + _BASE_INPUT_RE_MAP: ClassVar[Dict[_FORMAT_TYPE, Pattern[str]]] = { "YYYY": _FOUR_DIGIT_RE, "YY": _TWO_DIGIT_RE, "MM": _TWO_DIGIT_RE, @@ -77,9 +150,12 @@ class DateTimeParser(object): "W": _WEEK_DATE_RE, } - SEPARATORS = ["-", "/", "."] + SEPARATORS: ClassVar[List[str]] = ["-", "/", "."] + + locale: locales.Locale + _input_re_map: Dict[_FORMAT_TYPE, Pattern[str]] - def __init__(self, locale="en_us", cache_size=0): + def __init__(self, locale: str = "en_us", cache_size: int = 0) -> None: self.locale = locales.get_locale(locale) self._input_re_map = self._BASE_INPUT_RE_MAP.copy() @@ -108,13 +184,15 @@ def __init__(self, locale="en_us", cache_size=0): } ) if cache_size > 0: - self._generate_pattern_re = lru_cache(maxsize=cache_size)( + self._generate_pattern_re = lru_cache(maxsize=cache_size)( # type: ignore self._generate_pattern_re ) # TODO: since we support more than ISO 8601, we should rename this function # IDEA: break into multiple functions - def parse_iso(self, datetime_string, normalize_whitespace=False): + def parse_iso( + self, datetime_string: str, normalize_whitespace: bool = False + ) -> datetime: if normalize_whitespace: datetime_string = re.sub(r"\s+", " ", datetime_string.strip()) @@ -125,9 +203,8 @@ def parse_iso(self, datetime_string, normalize_whitespace=False): num_spaces = datetime_string.count(" ") if has_space_divider and num_spaces != 1 or has_t_divider and num_spaces > 0: raise ParserError( - "Expected an ISO 8601-like string, but was given '{}'. Try passing in a format string to resolve this.".format( - datetime_string - ) + f"Expected an ISO 8601-like string, but was given {datetime_string!r}. " + "Try passing in a format string to resolve this." ) has_time = has_space_divider or has_t_divider @@ -164,11 +241,12 @@ def parse_iso(self, datetime_string, normalize_whitespace=False): time_parts = re.split(r"[\+\-Z]", time_string, 1, re.IGNORECASE) - time_components = self._TIME_RE.match(time_parts[0]) + time_components: Optional[Match[str]] = self._TIME_RE.match(time_parts[0]) if time_components is None: raise ParserError( - "Invalid time component provided. Please specify a format or provide a valid time component in the basic or extended ISO 8601 time format." + "Invalid time component provided. " + "Please specify a format or provide a valid time component in the basic or extended ISO 8601 time format." ) ( @@ -200,23 +278,28 @@ def parse_iso(self, datetime_string, normalize_whitespace=False): elif has_seconds: time_string = "HH{time_sep}mm{time_sep}ss".format(time_sep=time_sep) elif has_minutes: - time_string = "HH{time_sep}mm".format(time_sep=time_sep) + time_string = f"HH{time_sep}mm" else: time_string = "HH" if has_space_divider: - formats = ["{} {}".format(f, time_string) for f in formats] + formats = [f"{f} {time_string}" for f in formats] else: - formats = ["{}T{}".format(f, time_string) for f in formats] + formats = [f"{f}T{time_string}" for f in formats] if has_time and has_tz: # Add "Z" or "ZZ" to the format strings to indicate to # _parse_token() that a timezone needs to be parsed - formats = ["{}{}".format(f, tz_format) for f in formats] + formats = [f"{f}{tz_format}" for f in formats] return self._parse_multiformat(datetime_string, formats) - def parse(self, datetime_string, fmt, normalize_whitespace=False): + def parse( + self, + datetime_string: str, + fmt: Union[List[str], str], + normalize_whitespace: bool = False, + ) -> datetime: if normalize_whitespace: datetime_string = re.sub(r"\s+", " ", datetime_string) @@ -224,34 +307,48 @@ def parse(self, datetime_string, fmt, normalize_whitespace=False): if isinstance(fmt, list): return self._parse_multiformat(datetime_string, fmt) - fmt_tokens, fmt_pattern_re = self._generate_pattern_re(fmt) + try: + fmt_tokens: List[_FORMAT_TYPE] + fmt_pattern_re: Pattern[str] + fmt_tokens, fmt_pattern_re = self._generate_pattern_re(fmt) + except re.error as e: + raise ParserMatchError( + f"Failed to generate regular expression pattern: {e}." + ) match = fmt_pattern_re.search(datetime_string) if match is None: raise ParserMatchError( - "Failed to match '{}' when parsing '{}'".format(fmt, datetime_string) + f"Failed to match {fmt!r} when parsing {datetime_string!r}." ) - parts = {} + parts: _Parts = {} for token in fmt_tokens: + value: Union[Tuple[str, str, str], str] if token == "Do": value = match.group("value") elif token == "W": value = (match.group("year"), match.group("week"), match.group("day")) else: value = match.group(token) - self._parse_token(token, value, parts) + + if value is None: + raise ParserMatchError( + f"Unable to find a match group for the specified token {token!r}." + ) + + self._parse_token(token, value, parts) # type: ignore return self._build_datetime(parts) - def _generate_pattern_re(self, fmt): + def _generate_pattern_re(self, fmt: str) -> Tuple[List[_FORMAT_TYPE], Pattern[str]]: # fmt is a string of tokens like 'YYYY-MM-DD' # we construct a new string by replacing each # token by its pattern: # 'YYYY-MM-DD' -> '(?P\d{4})-(?P\d{2})-(?P
\d{2})' - tokens = [] + tokens: List[_FORMAT_TYPE] = [] offset = 0 # Escape all special RegEx chars @@ -269,12 +366,12 @@ def _generate_pattern_re(self, fmt): fmt_pattern = escaped_fmt for m in self._FORMAT_RE.finditer(escaped_fmt): - token = m.group(0) + token: _FORMAT_TYPE = cast(_FORMAT_TYPE, m.group(0)) try: input_re = self._input_re_map[token] except KeyError: - raise ParserError("Unrecognized token '{}'".format(token)) - input_pattern = "(?P<{}>{})".format(token, input_re.pattern) + raise ParserError(f"Unrecognized token {token!r}.") + input_pattern = f"(?P<{token}>{input_re.pattern})" tokens.append(token) # a pattern doesn't have the same length as the token # it replaces! We keep the difference in the offset variable. @@ -309,12 +406,17 @@ def _generate_pattern_re(self, fmt): # see the documentation. starting_word_boundary = ( - r"(?\s])" # This is the list of punctuation that is ok before the pattern (i.e. "It can't not be these characters before the pattern") - r"(\b|^)" # The \b is to block cases like 1201912 but allow 201912 for pattern YYYYMM. The ^ was necessary to allow a negative number through i.e. before epoch numbers + r"(?\s])" # This is the list of punctuation that is ok before the + # pattern (i.e. "It can't not be these characters before the pattern") + r"(\b|^)" + # The \b is to block cases like 1201912 but allow 201912 for pattern YYYYMM. The ^ was necessary to allow a + # negative number through i.e. before epoch numbers ) ending_word_boundary = ( - r"(?=[\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)\<\>]?" # Positive lookahead stating that these punctuation marks can appear after the pattern at most 1 time + r"(?=[\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)\<\>]?" # Positive lookahead stating that these punctuation marks + # can appear after the pattern at most 1 time r"(?!\S))" # Don't allow any non-whitespace character after the punctuation ) bounded_fmt_pattern = r"{}{}{}".format( @@ -323,7 +425,76 @@ def _generate_pattern_re(self, fmt): return tokens, re.compile(bounded_fmt_pattern, flags=re.IGNORECASE) - def _parse_token(self, token, value, parts): + @overload + def _parse_token( + self, + token: Literal[ + "YYYY", + "YY", + "MM", + "M", + "DDDD", + "DDD", + "DD", + "D", + "Do", + "HH", + "hh", + "h", + "H", + "mm", + "m", + "ss", + "s", + "x", + ], + value: Union[str, bytes, SupportsInt, bytearray], + parts: _Parts, + ) -> None: + ... # pragma: no cover + + @overload + def _parse_token( + self, + token: Literal["X"], + value: Union[str, bytes, SupportsFloat, bytearray], + parts: _Parts, + ) -> None: + ... # pragma: no cover + + @overload + def _parse_token( + self, + token: Literal["MMMM", "MMM", "dddd", "ddd", "S"], + value: Union[str, bytes, bytearray], + parts: _Parts, + ) -> None: + ... # pragma: no cover + + @overload + def _parse_token( + self, + token: Literal["a", "A", "ZZZ", "ZZ", "Z"], + value: Union[str, bytes], + parts: _Parts, + ) -> None: + ... # pragma: no cover + + @overload + def _parse_token( + self, + token: Literal["W"], + value: Tuple[_WEEKDATE_ELEMENT, _WEEKDATE_ELEMENT, Optional[_WEEKDATE_ELEMENT]], + parts: _Parts, + ) -> None: + ... # pragma: no cover + + def _parse_token( + self, + token: Any, + value: Any, + parts: _Parts, + ) -> None: if token == "YYYY": parts["year"] = int(value) @@ -333,7 +504,8 @@ def _parse_token(self, token, value, parts): parts["year"] = 1900 + value if value > 68 else 2000 + value elif token in ["MMMM", "MMM"]: - parts["month"] = self.locale.month_number(value.lower()) + # FIXME: month_number() is nullable + parts["month"] = self.locale.month_number(value.lower()) # type: ignore elif token in ["MM", "M"]: parts["month"] = int(value) @@ -374,7 +546,7 @@ def _parse_token(self, token, value, parts): # We have the *most significant* digits of an arbitrary-precision integer. # We want the six most significant digits as an integer, rounded. # IDEA: add nanosecond support somehow? Need datetime support for it first. - value = value.ljust(7, str("0")) + value = value.ljust(7, "0") # floating-point (IEEE-754) defaults to half-to-even rounding seventh_digit = int(value[6]) @@ -406,21 +578,24 @@ def _parse_token(self, token, value, parts): parts["weekdate"] = value @staticmethod - def _build_datetime(parts): - + def _build_datetime(parts: _Parts) -> datetime: weekdate = parts.get("weekdate") if weekdate is not None: - # we can use strptime (%G, %V, %u) in python 3.6 but these tokens aren't available before that + year, week = int(weekdate[0]), int(weekdate[1]) if weekdate[2] is not None: - day = int(weekdate[2]) + _day = int(weekdate[2]) else: # day not given, default to 1 - day = 1 + _day = 1 + + date_string = f"{year}-{week}-{_day}" + + # tokens for ISO 8601 weekdates + dt = datetime.strptime(date_string, "%G-%V-%u") - dt = iso_to_gregorian(year, week, day) parts["year"] = dt.year parts["month"] = dt.month parts["day"] = dt.day @@ -441,9 +616,9 @@ def _build_datetime(parts): day_of_year = parts.get("day_of_year") if day_of_year is not None: - year = parts.get("year") + _year = parts.get("year") month = parts.get("month") - if year is None: + if _year is None: raise ParserError( "Year component is required with the DDD and DDDD tokens." ) @@ -453,19 +628,19 @@ def _build_datetime(parts): "Month component is not allowed with the DDD and DDDD tokens." ) - date_string = "{}-{}".format(year, day_of_year) + date_string = f"{_year}-{day_of_year}" try: dt = datetime.strptime(date_string, "%Y-%j") except ValueError: raise ParserError( - "The provided day of year '{}' is invalid.".format(day_of_year) + f"The provided day of year {day_of_year!r} is invalid." ) parts["year"] = dt.year parts["month"] = dt.month parts["day"] = dt.day - day_of_week = parts.get("day_of_week") + day_of_week: Optional[int] = parts.get("day_of_week") day = parts.get("day") # If day is passed, ignore day of week @@ -530,9 +705,9 @@ def _build_datetime(parts): + increment ) - def _parse_multiformat(self, string, formats): + def _parse_multiformat(self, string: str, formats: Iterable[str]) -> datetime: - _datetime = None + _datetime: Optional[datetime] = None for fmt in formats: try: @@ -542,27 +717,30 @@ def _parse_multiformat(self, string, formats): pass if _datetime is None: + supported_formats = ", ".join(formats) raise ParserError( - "Could not match input '{}' to any of the following formats: {}".format( - string, ", ".join(formats) - ) + f"Could not match input {string!r} to any of the following formats: {supported_formats}." ) return _datetime # generates a capture group of choices separated by an OR operator @staticmethod - def _generate_choice_re(choices, flags=0): + def _generate_choice_re( + choices: Iterable[str], flags: Union[int, re.RegexFlag] = 0 + ) -> Pattern[str]: return re.compile(r"({})".format("|".join(choices)), flags=flags) -class TzinfoParser(object): - _TZINFO_RE = re.compile(r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$") +class TzinfoParser: + _TZINFO_RE: ClassVar[Pattern[str]] = re.compile( + r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$" + ) @classmethod - def parse(cls, tzinfo_string): + def parse(cls, tzinfo_string: str) -> dt_tzinfo: - tzinfo = None + tzinfo: Optional[dt_tzinfo] = None if tzinfo_string == "local": tzinfo = tz.tzlocal() @@ -575,10 +753,11 @@ def parse(cls, tzinfo_string): iso_match = cls._TZINFO_RE.match(tzinfo_string) if iso_match: + sign: Optional[str] + hours: str + minutes: Union[str, int, None] sign, hours, minutes = iso_match.groups() - if minutes is None: - minutes = 0 - seconds = int(hours) * 3600 + int(minutes) * 60 + seconds = int(hours) * 3600 + int(minutes or 0) * 60 if sign == "-": seconds *= -1 @@ -589,8 +768,6 @@ def parse(cls, tzinfo_string): tzinfo = tz.gettz(tzinfo_string) if tzinfo is None: - raise ParserError( - 'Could not parse timezone expression "{}"'.format(tzinfo_string) - ) + raise ParserError(f"Could not parse timezone expression {tzinfo_string!r}.") return tzinfo diff --git a/arrow/py.typed b/arrow/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/arrow/util.py b/arrow/util.py index acce8878d..8679131ee 100644 --- a/arrow/util.py +++ b/arrow/util.py @@ -1,15 +1,20 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - import datetime -import numbers +from typing import Any, Optional, cast from dateutil.rrule import WEEKLY, rrule -from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US +from arrow.constants import ( + MAX_ORDINAL, + MAX_TIMESTAMP, + MAX_TIMESTAMP_MS, + MAX_TIMESTAMP_US, + MIN_ORDINAL, +) -def next_weekday(start_date, weekday): +def next_weekday( + start_date: Optional[datetime.date], weekday: int +) -> datetime.datetime: """Get next weekday from the specified start date. :param start_date: Datetime object representing the start date. @@ -32,23 +37,17 @@ def next_weekday(start_date, weekday): """ if weekday < 0 or weekday > 6: raise ValueError("Weekday must be between 0 (Monday) and 6 (Sunday).") - return rrule(freq=WEEKLY, dtstart=start_date, byweekday=weekday, count=1)[0] - - -def total_seconds(td): - """Get total seconds for timedelta.""" - return td.total_seconds() + return cast( + datetime.datetime, + rrule(freq=WEEKLY, dtstart=start_date, byweekday=weekday, count=1)[0], + ) -def is_timestamp(value): +def is_timestamp(value: Any) -> bool: """Check if value is a valid timestamp.""" if isinstance(value, bool): return False - if not ( - isinstance(value, numbers.Integral) - or isinstance(value, float) - or isinstance(value, str) - ): + if not isinstance(value, (int, float, str)): return False try: float(value) @@ -57,22 +56,28 @@ def is_timestamp(value): return False -def normalize_timestamp(timestamp): +def validate_ordinal(value: Any) -> None: + """Raise the corresponding exception if value is an invalid Gregorian ordinal.""" + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"Ordinal must be an integer (got type {type(value)}).") + if not (MIN_ORDINAL <= value <= MAX_ORDINAL): + raise ValueError(f"Ordinal {value} is out of range.") + + +def normalize_timestamp(timestamp: float) -> float: """Normalize millisecond and microsecond timestamps into normal timestamps.""" if timestamp > MAX_TIMESTAMP: if timestamp < MAX_TIMESTAMP_MS: - timestamp /= 1e3 + timestamp /= 1000 elif timestamp < MAX_TIMESTAMP_US: - timestamp /= 1e6 + timestamp /= 1_000_000 else: - raise ValueError( - "The specified timestamp '{}' is too large.".format(timestamp) - ) + raise ValueError(f"The specified timestamp {timestamp!r} is too large.") return timestamp # Credit to https://stackoverflow.com/a/1700069 -def iso_to_gregorian(iso_year, iso_week, iso_day): +def iso_to_gregorian(iso_year: int, iso_week: int, iso_day: int) -> datetime.date: """Converts an ISO week date tuple into a datetime object.""" if not 1 <= iso_week <= 53: @@ -90,26 +95,11 @@ def iso_to_gregorian(iso_year, iso_week, iso_day): return gregorian -def validate_bounds(bounds): +def validate_bounds(bounds: str) -> None: if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]": raise ValueError( - 'Invalid bounds. Please select between "()", "(]", "[)", or "[]".' + "Invalid bounds. Please select between '()', '(]', '[)', or '[]'." ) -# Python 2.7 / 3.0+ definitions for isstr function. - -try: # pragma: no cover - basestring - - def isstr(s): - return isinstance(s, basestring) # noqa: F821 - - -except NameError: # pragma: no cover - - def isstr(s): - return isinstance(s, str) - - -__all__ = ["next_weekday", "total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"] +__all__ = ["next_weekday", "is_timestamp", "validate_ordinal", "iso_to_gregorian"] diff --git a/docs/conf.py b/docs/conf.py index aaf3c5082..907d78c0c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,28 +1,26 @@ -# -*- coding: utf-8 -*- - +# mypy: ignore-errors # -- Path setup -------------------------------------------------------------- -import io import os import sys sys.path.insert(0, os.path.abspath("..")) about = {} -with io.open("../arrow/_version.py", "r", encoding="utf-8") as f: +with open("../arrow/_version.py", encoding="utf-8") as f: exec(f.read(), about) # -- Project information ----------------------------------------------------- -project = u"Arrow 🏹" -copyright = "2020, Chris Smith" +project = "Arrow 🏹" +copyright = "2021, Chris Smith" author = "Chris Smith" release = about["__version__"] # -- General configuration --------------------------------------------------- -extensions = ["sphinx.ext.autodoc"] +extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints"] templates_path = [] diff --git a/requirements.txt b/requirements.txt index df565d838..002349a56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,10 @@ -backports.functools_lru_cache==1.6.1; python_version == "2.7" -dateparser==0.7.* -pre-commit==1.21.*; python_version <= "3.5" -pre-commit==2.6.*; python_version >= "3.6" -pytest==4.6.*; python_version == "2.7" -pytest==6.0.*; python_version >= "3.5" -pytest-cov==2.10.* -pytest-mock==2.0.*; python_version == "2.7" -pytest-mock==3.2.*; python_version >= "3.5" +dateparser==1.0.* +pre-commit==2.10.* +pytest==6.2.* +pytest-cov==2.11.* +pytest-mock==3.5.* python-dateutil==2.8.* pytz==2019.* simplejson==3.17.* -sphinx==1.8.*; python_version == "2.7" -sphinx==3.2.*; python_version >= "3.5" +sphinx==3.5.* +sphinx-autodoc-typehints==1.11.* diff --git a/setup.cfg b/setup.cfg index 2a9acf13d..bfb63588d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,34 @@ -[bdist_wheel] -universal = 1 +[mypy] +python_version = 3.6 + +allow_any_expr = True +allow_any_decorated = True +allow_any_explicit = True +disallow_any_generics = True +disallow_subclassing_any = True + +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +disallow_untyped_decorators = True + +no_implicit_optional = True + +warn_redundant_casts = True +warn_unused_ignores = True +no_warn_no_return = True +warn_return_any = True +warn_unreachable = True + +strict_equality = True +no_implicit_reexport = True + +## + +allow_redefinition = True + + +# Type annotation for test codes and migration files are not mandatory + +[mypy-*.tests.*,tests.*] +ignore_errors = True diff --git a/setup.py b/setup.py index dc4f0e77d..14dff60db 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,11 @@ -# -*- coding: utf-8 -*- -import io - +# mypy: ignore-errors from setuptools import setup -with io.open("README.rst", "r", encoding="utf-8") as f: +with open("README.rst", encoding="utf-8") as f: readme = f.read() about = {} -with io.open("arrow/_version.py", "r", encoding="utf-8") as f: +with open("arrow/_version.py", encoding="utf-8") as f: exec(f.read(), about) setup( @@ -21,21 +19,20 @@ author_email="crsmithdev@gmail.com", license="Apache 2.0", packages=["arrow"], + package_data={"arrow": ["py.typed"]}, zip_safe=False, - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + python_requires=">=3.6", install_requires=[ "python-dateutil>=2.7.0", - "backports.functools_lru_cache>=1.2.1;python_version=='2.7'", + "typing_extensions; python_version<'3.8'", ], classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", diff --git a/tests/conftest.py b/tests/conftest.py index 5bc8a4af2..4043bc3b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from datetime import datetime import pytest diff --git a/tests/test_api.py b/tests/test_api.py index 9b19a27cd..5576aaf84 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import arrow diff --git a/tests/test_arrow.py b/tests/test_arrow.py index b0bd20a5e..2132040ca 100644 --- a/tests/test_arrow.py +++ b/tests/test_arrow.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -import calendar import pickle import sys import time @@ -186,6 +182,24 @@ def test_strptime(self): 2013, 2, 3, 12, 30, 45, tzinfo=tz.gettz("Europe/Paris") ) + def test_fromordinal(self): + + timestamp = 1607066909.937968 + with pytest.raises(TypeError): + arrow.Arrow.fromordinal(timestamp) + with pytest.raises(ValueError): + arrow.Arrow.fromordinal(int(timestamp)) + + ordinal = arrow.Arrow.utcnow().toordinal() + + with pytest.raises(TypeError): + arrow.Arrow.fromordinal(str(ordinal)) + + result = arrow.Arrow.fromordinal(ordinal) + dt = datetime.fromordinal(ordinal) + + assert result.naive == dt + @pytest.mark.usefixtures("time_2013_02_03") class TestTestArrowRepresentation: @@ -193,7 +207,7 @@ def test_repr(self): result = self.arrow.__repr__() - assert result == "".format(self.arrow._datetime.isoformat()) + assert result == f"" def test_str(self): @@ -209,7 +223,7 @@ def test_hash(self): def test_format(self): - result = "{:YYYY-MM-DD}".format(self.arrow) + result = f"{self.arrow:YYYY-MM-DD}" assert result == "2013-02-03" @@ -221,7 +235,7 @@ def test_bare_format(self): def test_format_no_format_string(self): - result = "{}".format(self.arrow) + result = f"{self.arrow}" assert result == str(self.arrow) @@ -271,8 +285,7 @@ def test_getattr_dt_value(self): def test_tzinfo(self): - self.arrow.tzinfo = tz.gettz("PST") - assert self.arrow.tzinfo == tz.gettz("PST") + assert self.arrow.tzinfo == tz.tzutc() def test_naive(self): @@ -280,24 +293,15 @@ def test_naive(self): def test_timestamp(self): - assert self.arrow.timestamp == calendar.timegm( - self.arrow._datetime.utctimetuple() - ) - - with pytest.warns(DeprecationWarning): - self.arrow.timestamp + assert self.arrow.timestamp() == self.arrow._datetime.timestamp() def test_int_timestamp(self): - assert self.arrow.int_timestamp == calendar.timegm( - self.arrow._datetime.utctimetuple() - ) + assert self.arrow.int_timestamp == int(self.arrow._datetime.timestamp()) def test_float_timestamp(self): - result = self.arrow.float_timestamp - self.arrow.timestamp - - assert result == self.arrow.microsecond + assert self.arrow.float_timestamp == self.arrow._datetime.timestamp() def test_getattr_fold(self): @@ -351,7 +355,7 @@ def test_gt(self): assert not (self.arrow > self.arrow.datetime) with pytest.raises(TypeError): - self.arrow > "abc" + self.arrow > "abc" # noqa: B015 assert self.arrow < arrow_cmp assert self.arrow < arrow_cmp.datetime @@ -359,7 +363,7 @@ def test_gt(self): def test_ge(self): with pytest.raises(TypeError): - self.arrow >= "abc" + self.arrow >= "abc" # noqa: B015 assert self.arrow >= self.arrow assert self.arrow >= self.arrow.datetime @@ -372,7 +376,7 @@ def test_lt(self): assert not (self.arrow < self.arrow.datetime) with pytest.raises(TypeError): - self.arrow < "abc" + self.arrow < "abc" # noqa: B015 assert self.arrow < arrow_cmp assert self.arrow < arrow_cmp.datetime @@ -380,7 +384,7 @@ def test_lt(self): def test_le(self): with pytest.raises(TypeError): - self.arrow <= "abc" + self.arrow <= "abc" # noqa: B015 assert self.arrow <= self.arrow assert self.arrow <= self.arrow.datetime @@ -522,6 +526,20 @@ def test_isoformat(self): assert result == self.arrow._datetime.isoformat() + def test_isoformat_timespec(self): + + result = self.arrow.isoformat(timespec="hours") + assert result == self.arrow._datetime.isoformat(timespec="hours") + + result = self.arrow.isoformat(timespec="microseconds") + assert result == self.arrow._datetime.isoformat() + + result = self.arrow.isoformat(timespec="milliseconds") + assert result == self.arrow._datetime.isoformat(timespec="milliseconds") + + result = self.arrow.isoformat(sep="x", timespec="seconds") + assert result == self.arrow._datetime.isoformat(sep="x", timespec="seconds") + def test_simplejson(self): result = json.dumps({"v": self.arrow.for_json()}, for_json=True) @@ -672,7 +690,7 @@ def test_pickle_and_unpickle(self): class TestArrowReplace: def test_not_attr(self): - with pytest.raises(AttributeError): + with pytest.raises(ValueError): arrow.Arrow.utcnow().replace(abc=1) def test_replace(self): @@ -713,12 +731,12 @@ def test_replace_fold_and_other(self): def test_replace_week(self): - with pytest.raises(AttributeError): + with pytest.raises(ValueError): arrow.Arrow.utcnow().replace(week=1) def test_replace_quarter(self): - with pytest.raises(AttributeError): + with pytest.raises(ValueError): arrow.Arrow.utcnow().replace(quarter=1) def test_replace_quarter_and_fold(self): @@ -739,10 +757,10 @@ def test_not_attr(self): now = arrow.Arrow.utcnow() - with pytest.raises(AttributeError): + with pytest.raises(ValueError): now.shift(abc=1) - with pytest.raises(AttributeError): + with pytest.raises(ValueError): now.shift(week=1) def test_shift(self): @@ -922,9 +940,6 @@ def test_shift_kiritimati(self): 1995, 1, 1, 12, 30, tzinfo="Pacific/Kiritimati" ) - @pytest.mark.skipif( - sys.version_info < (3, 6), reason="unsupported before python 3.6" - ) def shift_imaginary_seconds(self): # offset has a seconds component monrovia = arrow.Arrow(1972, 1, 6, 23, tzinfo="Africa/Monrovia") @@ -1135,7 +1150,7 @@ def test_imaginary(self): def test_unsupported(self): - with pytest.raises(AttributeError): + with pytest.raises(ValueError): next(arrow.Arrow.range("abc", datetime.utcnow(), datetime.utcnow())) def test_range_over_months_ending_on_different_days(self): @@ -1472,6 +1487,150 @@ def test_bounds_param_is_passed(self): (arrow.Arrow(2013, 4, 1), arrow.Arrow(2013, 7, 1)), ] + def test_exact_bound_exclude(self): + + result = list( + arrow.Arrow.span_range( + "hour", + datetime(2013, 5, 5, 12, 30), + datetime(2013, 5, 5, 17, 15), + bounds="[)", + exact=True, + ) + ) + + expected = [ + ( + arrow.Arrow(2013, 5, 5, 12, 30), + arrow.Arrow(2013, 5, 5, 13, 29, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 13, 30), + arrow.Arrow(2013, 5, 5, 14, 29, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 14, 30), + arrow.Arrow(2013, 5, 5, 15, 29, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 15, 30), + arrow.Arrow(2013, 5, 5, 16, 29, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 16, 30), + arrow.Arrow(2013, 5, 5, 17, 14, 59, 999999), + ), + ] + + assert result == expected + + def test_exact_floor_equals_end(self): + result = list( + arrow.Arrow.span_range( + "minute", + datetime(2013, 5, 5, 12, 30), + datetime(2013, 5, 5, 12, 40), + exact=True, + ) + ) + + expected = [ + ( + arrow.Arrow(2013, 5, 5, 12, 30), + arrow.Arrow(2013, 5, 5, 12, 30, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 31), + arrow.Arrow(2013, 5, 5, 12, 31, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 32), + arrow.Arrow(2013, 5, 5, 12, 32, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 33), + arrow.Arrow(2013, 5, 5, 12, 33, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 34), + arrow.Arrow(2013, 5, 5, 12, 34, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 35), + arrow.Arrow(2013, 5, 5, 12, 35, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 36), + arrow.Arrow(2013, 5, 5, 12, 36, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 37), + arrow.Arrow(2013, 5, 5, 12, 37, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 38), + arrow.Arrow(2013, 5, 5, 12, 38, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 12, 39), + arrow.Arrow(2013, 5, 5, 12, 39, 59, 999999), + ), + ] + + assert result == expected + + def test_exact_bound_include(self): + result = list( + arrow.Arrow.span_range( + "hour", + datetime(2013, 5, 5, 2, 30), + datetime(2013, 5, 5, 6, 00), + bounds="(]", + exact=True, + ) + ) + + expected = [ + ( + arrow.Arrow(2013, 5, 5, 2, 30, 00, 1), + arrow.Arrow(2013, 5, 5, 3, 30, 00, 0), + ), + ( + arrow.Arrow(2013, 5, 5, 3, 30, 00, 1), + arrow.Arrow(2013, 5, 5, 4, 30, 00, 0), + ), + ( + arrow.Arrow(2013, 5, 5, 4, 30, 00, 1), + arrow.Arrow(2013, 5, 5, 5, 30, 00, 0), + ), + ( + arrow.Arrow(2013, 5, 5, 5, 30, 00, 1), + arrow.Arrow(2013, 5, 5, 6, 00), + ), + ] + + assert result == expected + + def test_small_interval_exact_open_bounds(self): + result = list( + arrow.Arrow.span_range( + "minute", + datetime(2013, 5, 5, 2, 30), + datetime(2013, 5, 5, 2, 31), + bounds="()", + exact=True, + ) + ) + + expected = [ + ( + arrow.Arrow(2013, 5, 5, 2, 30, 00, 1), + arrow.Arrow(2013, 5, 5, 2, 30, 59, 999999), + ), + ] + + assert result == expected + class TestArrowInterval: def test_incorrect_input(self): @@ -1521,12 +1680,36 @@ def test_bounds_param_is_passed(self): (arrow.Arrow(2013, 5, 5, 16), arrow.Arrow(2013, 5, 5, 18)), ] + def test_exact(self): + result = list( + arrow.Arrow.interval( + "hour", + datetime(2013, 5, 5, 12, 30), + datetime(2013, 5, 5, 17, 15), + 4, + exact=True, + ) + ) + + expected = [ + ( + arrow.Arrow(2013, 5, 5, 12, 30), + arrow.Arrow(2013, 5, 5, 16, 29, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 16, 30), + arrow.Arrow(2013, 5, 5, 17, 14, 59, 999999), + ), + ] + + assert result == expected + @pytest.mark.usefixtures("time_2013_02_15") class TestArrowSpan: def test_span_attribute(self): - with pytest.raises(AttributeError): + with pytest.raises(ValueError): self.arrow.span("span") def test_span_year(self): @@ -1639,6 +1822,44 @@ def test_bounds_are_validated(self): with pytest.raises(ValueError): floor, ceil = self.arrow.span("hour", bounds="][") + def test_exact(self): + + result_floor, result_ceil = self.arrow.span("hour", exact=True) + + expected_floor = datetime(2013, 2, 15, 3, 41, 22, 8923, tzinfo=tz.tzutc()) + expected_ceil = datetime(2013, 2, 15, 4, 41, 22, 8922, tzinfo=tz.tzutc()) + + assert result_floor == expected_floor + assert result_ceil == expected_ceil + + def test_exact_inclusive_inclusive(self): + + floor, ceil = self.arrow.span("minute", bounds="[]", exact=True) + + assert floor == datetime(2013, 2, 15, 3, 41, 22, 8923, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 42, 22, 8923, tzinfo=tz.tzutc()) + + def test_exact_exclusive_inclusive(self): + + floor, ceil = self.arrow.span("day", bounds="(]", exact=True) + + assert floor == datetime(2013, 2, 15, 3, 41, 22, 8924, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 16, 3, 41, 22, 8923, tzinfo=tz.tzutc()) + + def test_exact_exclusive_exclusive(self): + + floor, ceil = self.arrow.span("second", bounds="()", exact=True) + + assert floor == datetime(2013, 2, 15, 3, 41, 22, 8924, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 41, 23, 8922, tzinfo=tz.tzutc()) + + def test_all_parameters_specified(self): + + floor, ceil = self.arrow.span("week", bounds="()", exact=True, count=2) + + assert floor == datetime(2013, 2, 15, 3, 41, 22, 8924, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 3, 1, 3, 41, 22, 8922, tzinfo=tz.tzutc()) + @pytest.mark.usefixtures("time_2013_01_01") class TestArrowHumanize: @@ -1716,7 +1937,7 @@ def test_granularity(self): == "3 years" ) - with pytest.raises(AttributeError): + with pytest.raises(ValueError): self.now.humanize(later108, granularity="years") def test_multiple_granularity(self): @@ -1756,7 +1977,7 @@ def test_multiple_granularity(self): self.now.humanize(later105, granularity=["hour", "day", "minute"]) == "a day 3 hours and 46 minutes ago" ) - with pytest.raises(AttributeError): + with pytest.raises(ValueError): self.now.humanize(later105, granularity=["error", "second"]) later108onlydistance = self.now.shift(seconds=10 ** 8) @@ -1772,11 +1993,12 @@ def test_multiple_granularity(self): ) == "37 months and 4 weeks" ) + # this will change when leap years are implemented assert ( self.now.humanize( later108onlydistance, only_distance=True, granularity=["year", "second"] ) - == "3 years and 5327200 seconds" + == "3 years and 5392000 seconds" ) one_min_one_sec_ago = self.now.shift(minutes=-1, seconds=-1) @@ -1861,11 +2083,6 @@ def test_day(self): # humanize other argument does not take raw datetime.date objects self.now.humanize(less_than_48_hours_date) - # convert from date to arrow object - less_than_48_hours_date = arrow.Arrow.fromdate(less_than_48_hours_date) - assert self.now.humanize(less_than_48_hours_date) == "a day ago" - assert less_than_48_hours_date.humanize(self.now) == "in a day" - assert self.now.humanize(later, only_distance=True) == "a day" assert later.humanize(self.now, only_distance=True) == "a day" @@ -1909,16 +2126,27 @@ def test_weeks(self): assert self.now.humanize(later, only_distance=True) == "2 weeks" assert later.humanize(self.now, only_distance=True) == "2 weeks" + @pytest.mark.xfail(reason="known issue with humanize month limits") def test_month(self): later = self.now.shift(months=1) + # TODO this test now returns "4 weeks ago", we need to fix this to be correct on a per month basis assert self.now.humanize(later) == "a month ago" assert later.humanize(self.now) == "in a month" assert self.now.humanize(later, only_distance=True) == "a month" assert later.humanize(self.now, only_distance=True) == "a month" + def test_month_plus_4_days(self): + + # TODO needed for coverage, remove when month limits are fixed + later = self.now.shift(months=1, days=4) + + assert self.now.humanize(later) == "a month ago" + assert later.humanize(self.now) == "in a month" + + @pytest.mark.xfail(reason="known issue with humanize month limits") def test_months(self): later = self.now.shift(months=2) @@ -1954,7 +2182,7 @@ def test_years(self): result = arw.humanize(self.datetime) - assert result == "in 2 years" + assert result == "in a year" def test_arrow(self): @@ -1998,6 +2226,16 @@ def test_none(self): assert result == "just now" + def test_week_limit(self): + # regression test for issue #848 + arw = arrow.Arrow.utcnow() + + later = arw.shift(weeks=+1) + + result = arw.humanize(later) + + assert result == "a week ago" + def test_untranslated_granularity(self, mocker): arw = arrow.Arrow.utcnow() @@ -2033,7 +2271,7 @@ def test_years(self): result = arw.humanize(self.datetime, locale="ru") - assert result == "2 года назад" + assert result == "год назад" class TestArrowIsBetween: @@ -2041,45 +2279,43 @@ def test_start_before_end(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 8)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) - result = target.is_between(start, end) - assert not result + assert not target.is_between(start, end) def test_exclusive_exclusive_bounds(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 27)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 10)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 36)) - result = target.is_between(start, end, "()") - assert result - result = target.is_between(start, end) - assert result + assert target.is_between(start, end, "()") def test_exclusive_exclusive_bounds_same_date(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) - result = target.is_between(start, end, "()") - assert not result + assert not target.is_between(start, end, "()") def test_inclusive_exclusive_bounds(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 6)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 4)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 6)) - result = target.is_between(start, end, "[)") - assert not result + assert not target.is_between(start, end, "[)") def test_exclusive_inclusive_bounds(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) - result = target.is_between(start, end, "(]") - assert result + assert target.is_between(start, end, "(]") def test_inclusive_inclusive_bounds_same_date(self): target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) - result = target.is_between(start, end, "[]") - assert result + assert target.is_between(start, end, "[]") + + def test_inclusive_inclusive_bounds_target_before_start(self): + target = arrow.Arrow.fromdatetime(datetime(2020, 12, 24)) + start = arrow.Arrow.fromdatetime(datetime(2020, 12, 25)) + end = arrow.Arrow.fromdatetime(datetime(2020, 12, 26)) + assert not target.is_between(start, end, "[]") def test_type_error_exception(self): with pytest.raises(TypeError): diff --git a/tests/test_factory.py b/tests/test_factory.py index 2b8df5168..5e0020d65 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import time from datetime import date, datetime @@ -20,16 +19,14 @@ def test_no_args(self): def test_timestamp_one_arg_no_arg(self): - no_arg = self.factory.get(1406430900).timestamp - one_arg = self.factory.get("1406430900", "X").timestamp + no_arg = self.factory.get(1406430900).timestamp() + one_arg = self.factory.get("1406430900", "X").timestamp() assert no_arg == one_arg def test_one_arg_none(self): - - assert_datetime_equality( - self.factory.get(None), datetime.utcnow().replace(tzinfo=tz.tzutc()) - ) + with pytest.raises(TypeError): + self.factory.get(None) def test_struct_time(self): @@ -289,7 +286,7 @@ def test_two_args_str_list(self): def test_two_args_unicode_unicode(self): - result = self.factory.get(u"2013-01-01", u"YYYY-MM-DD") + result = self.factory.get("2013-01-01", "YYYY-MM-DD") assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.tzutc()) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index e97aeb5dc..06831f1e0 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from datetime import datetime import pytest @@ -113,14 +112,13 @@ def test_sub_second(self): def test_timestamp(self): - timestamp = 1588437009.8952794 - dt = datetime.utcfromtimestamp(timestamp) - expected = str(int(timestamp)) + dt = datetime.now(tz=dateutil_tz.UTC) + expected = str(dt.timestamp()) assert self.formatter._format_token(dt, "X") == expected # Must round because time.time() may return a float with greater # than 6 digits of precision - expected = str(int(timestamp * 1000000)) + expected = str(int(dt.timestamp() * 1000000)) assert self.formatter._format_token(dt, "x") == expected def test_timezone(self): diff --git a/tests/test_locales.py b/tests/test_locales.py index 006ccdd5b..642013ba5 100644 --- a/tests/test_locales.py +++ b/tests/test_locales.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from arrow import arrow, locales @@ -1350,3 +1347,23 @@ def test_ordinal_number(self): assert self.locale.ordinal_number(11) == "11번째" assert self.locale.ordinal_number(12) == "12번째" assert self.locale.ordinal_number(100) == "100번째" + + +@pytest.mark.usefixtures("lang_locale") +class TestJapaneseLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "現在" + assert self.locale._format_timeframe("second", 1) == "1秒" + assert self.locale._format_timeframe("seconds", 30) == "30秒" + assert self.locale._format_timeframe("minute", 1) == "1分" + assert self.locale._format_timeframe("minutes", 40) == "40分" + assert self.locale._format_timeframe("hour", 1) == "1時間" + assert self.locale._format_timeframe("hours", 23) == "23時間" + assert self.locale._format_timeframe("day", 1) == "1日" + assert self.locale._format_timeframe("days", 12) == "12日" + assert self.locale._format_timeframe("week", 1) == "1週間" + assert self.locale._format_timeframe("weeks", 38) == "38週間" + assert self.locale._format_timeframe("month", 1) == "1ヶ月" + assert self.locale._format_timeframe("months", 11) == "11ヶ月" + assert self.locale._format_timeframe("year", 1) == "1年" + assert self.locale._format_timeframe("years", 12) == "12年" diff --git a/tests/test_parser.py b/tests/test_parser.py index 9fb4e68f3..bdfb30614 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import calendar import os import time @@ -223,44 +220,21 @@ def test_parse_year_two_digit(self): def test_parse_timestamp(self): tz_utc = tz.tzutc() - int_timestamp = int(time.time()) + float_timestamp = time.time() + int_timestamp = int(float_timestamp) self.expected = datetime.fromtimestamp(int_timestamp, tz=tz_utc) - assert self.parser.parse("{:d}".format(int_timestamp), "X") == self.expected + assert self.parser.parse(f"{int_timestamp:d}", "X") == self.expected - float_timestamp = time.time() self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) - assert self.parser.parse("{:f}".format(float_timestamp), "X") == self.expected + assert self.parser.parse(f"{float_timestamp:f}", "X") == self.expected # test handling of ns timestamp (arrow will round to 6 digits regardless) self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) - assert ( - self.parser.parse("{:f}123".format(float_timestamp), "X") == self.expected - ) + assert self.parser.parse(f"{float_timestamp:f}123", "X") == self.expected # test ps timestamp (arrow will round to 6 digits regardless) self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) - assert ( - self.parser.parse("{:f}123456".format(float_timestamp), "X") - == self.expected - ) - - # NOTE: negative timestamps cannot be handled by datetime on Window - # Must use timedelta to handle them. ref: https://stackoverflow.com/questions/36179914 - if os.name != "nt": - # regression test for issue #662 - negative_int_timestamp = -int_timestamp - self.expected = datetime.fromtimestamp(negative_int_timestamp, tz=tz_utc) - assert ( - self.parser.parse("{:d}".format(negative_int_timestamp), "X") - == self.expected - ) - - negative_float_timestamp = -float_timestamp - self.expected = datetime.fromtimestamp(negative_float_timestamp, tz=tz_utc) - assert ( - self.parser.parse("{:f}".format(negative_float_timestamp), "X") - == self.expected - ) + assert self.parser.parse(f"{float_timestamp:f}123456", "X") == self.expected # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will # break cases like "15 Jul 2000" and a format list (see issue #447) @@ -276,6 +250,24 @@ def test_parse_timestamp(self): with pytest.raises(ParserError): self.parser.parse(".1565982019", "X") + # NOTE: negative timestamps cannot be handled by datetime on Windows + # Must use timedelta to handle them: https://stackoverflow.com/questions/36179914 + @pytest.mark.skipif( + os.name == "nt", reason="negative timestamps are not supported on Windows" + ) + def test_parse_negative_timestamp(self): + # regression test for issue #662 + tz_utc = tz.tzutc() + float_timestamp = time.time() + int_timestamp = int(float_timestamp) + negative_int_timestamp = -int_timestamp + self.expected = datetime.fromtimestamp(negative_int_timestamp, tz=tz_utc) + assert self.parser.parse(f"{negative_int_timestamp:d}", "X") == self.expected + + negative_float_timestamp = -float_timestamp + self.expected = datetime.fromtimestamp(negative_float_timestamp, tz=tz_utc) + assert self.parser.parse(f"{negative_float_timestamp:f}", "X") == self.expected + def test_parse_expanded_timestamp(self): # test expanded timestamps that include milliseconds # and microseconds as multiples rather than decimals @@ -283,18 +275,18 @@ def test_parse_expanded_timestamp(self): tz_utc = tz.tzutc() timestamp = 1569982581.413132 - timestamp_milli = int(round(timestamp * 1000)) - timestamp_micro = int(round(timestamp * 1000000)) + timestamp_milli = round(timestamp * 1000) + timestamp_micro = round(timestamp * 1_000_000) # "x" token should parse integer timestamps below MAX_TIMESTAMP normally self.expected = datetime.fromtimestamp(int(timestamp), tz=tz_utc) assert self.parser.parse("{:d}".format(int(timestamp)), "x") == self.expected self.expected = datetime.fromtimestamp(round(timestamp, 3), tz=tz_utc) - assert self.parser.parse("{:d}".format(timestamp_milli), "x") == self.expected + assert self.parser.parse(f"{timestamp_milli:d}", "x") == self.expected self.expected = datetime.fromtimestamp(timestamp, tz=tz_utc) - assert self.parser.parse("{:d}".format(timestamp_micro), "x") == self.expected + assert self.parser.parse(f"{timestamp_micro:d}", "x") == self.expected # anything above max µs timestamp should fail with pytest.raises(ValueError): @@ -302,7 +294,7 @@ def test_parse_expanded_timestamp(self): # floats are not allowed with the "x" token with pytest.raises(ParserMatchError): - self.parser.parse("{:f}".format(timestamp), "x") + self.parser.parse(f"{timestamp:f}", "x") def test_parse_names(self): @@ -345,7 +337,7 @@ def test_parse_tz_name_zzz(self, full_tz_name): self.expected = datetime(2013, 1, 1, tzinfo=tz.gettz(full_tz_name)) assert ( - self.parser.parse("2013-01-01 {}".format(full_tz_name), "YYYY-MM-DD ZZZ") + self.parser.parse(f"2013-01-01 {full_tz_name}", "YYYY-MM-DD ZZZ") == self.expected ) @@ -939,9 +931,7 @@ def test_time(self): for sep in time_seperators: assert time_re.findall("12") == [("12", "", "", "", "")] - assert time_re.findall("12{sep}35".format(sep=sep)) == [ - ("12", "35", "", "", "") - ] + assert time_re.findall(f"12{sep}35") == [("12", "35", "", "", "")] assert time_re.findall("12{sep}35{sep}46".format(sep=sep)) == [ ("12", "35", "46", "", "") ] @@ -1655,3 +1645,22 @@ def test_escape(self): assert self.parser.parse( "Dec 31, 2017 |^${}().*+?<>-& 2:00 AM", format ) == datetime(2017, 12, 31, 2, 0) + + +@pytest.mark.usefixtures("dt_parser") +class TestFuzzInput: + # Regression test for issue #860 + def test_no_match_group(self): + fmt_str = str(b"[|\x1f\xb9\x03\x00\x00\x00\x00:-yI:][\x01yI:yI:I") + payload = str(b"") + + with pytest.raises(parser.ParserMatchError): + self.parser.parse(payload, fmt_str) + + # Regression test for issue #854 + def test_regex_module_error(self): + fmt_str = str(b"struct n[X+,N-M)MMXdMM]<") + payload = str(b"") + + with pytest.raises(parser.ParserMatchError): + self.parser.parse(payload, fmt_str) diff --git a/tests/test_util.py b/tests/test_util.py index e48b4de06..3b32e1bc5 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import time from datetime import datetime @@ -37,10 +36,6 @@ def test_next_weekday(self): with pytest.raises(ValueError): util.next_weekday(datetime(1970, 1, 1), -1) - def test_total_seconds(self): - td = datetime(2019, 1, 1) - datetime(2018, 1, 1) - assert util.total_seconds(td) == td.total_seconds() - def test_is_timestamp(self): timestamp_float = time.time() timestamp_int = int(timestamp_float) @@ -61,6 +56,63 @@ class InvalidTimestamp: full_datetime = "2019-06-23T13:12:42" assert not util.is_timestamp(full_datetime) + def test_validate_ordinal(self): + timestamp_float = 1607066816.815537 + timestamp_int = int(timestamp_float) + timestamp_str = str(timestamp_int) + + with pytest.raises(TypeError): + util.validate_ordinal(timestamp_float) + with pytest.raises(TypeError): + util.validate_ordinal(timestamp_str) + with pytest.raises(TypeError): + util.validate_ordinal(True) + with pytest.raises(TypeError): + util.validate_ordinal(False) + + with pytest.raises(ValueError): + util.validate_ordinal(timestamp_int) + with pytest.raises(ValueError): + util.validate_ordinal(-1 * timestamp_int) + with pytest.raises(ValueError): + util.validate_ordinal(0) + + try: + util.validate_ordinal(1) + except (ValueError, TypeError) as exp: + pytest.fail(f"Exception raised when shouldn't have ({type(exp)}).") + + try: + util.validate_ordinal(datetime.max.toordinal()) + except (ValueError, TypeError) as exp: + pytest.fail(f"Exception raised when shouldn't have ({type(exp)}).") + + ordinal = datetime.utcnow().toordinal() + ordinal_str = str(ordinal) + ordinal_float = float(ordinal) + 0.5 + + with pytest.raises(TypeError): + util.validate_ordinal(ordinal_str) + with pytest.raises(TypeError): + util.validate_ordinal(ordinal_float) + with pytest.raises(ValueError): + util.validate_ordinal(-1 * ordinal) + + try: + util.validate_ordinal(ordinal) + except (ValueError, TypeError) as exp: + pytest.fail(f"Exception raised when shouldn't have ({type(exp)}).") + + full_datetime = "2019-06-23T13:12:42" + + class InvalidOrdinal: + pass + + with pytest.raises(TypeError): + util.validate_ordinal(InvalidOrdinal()) + with pytest.raises(TypeError): + util.validate_ordinal(full_datetime) + def test_normalize_timestamp(self): timestamp = 1591161115.194556 millisecond_timestamp = 1591161115194 diff --git a/tests/utils.py b/tests/utils.py index 2a048feb3..95b47c166 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,6 @@ -# -*- coding: utf-8 -*- import pytz from dateutil.zoneinfo import get_zonefile_instance -from arrow import util - def make_full_tz_list(): dateutil_zones = set(get_zonefile_instance().zones) @@ -13,4 +10,4 @@ def make_full_tz_list(): def assert_datetime_equality(dt1, dt2, within=10): assert dt1.tzinfo == dt2.tzinfo - assert abs(util.total_seconds(dt1 - dt2)) < within + assert abs((dt1 - dt2).total_seconds()) < within diff --git a/tox.ini b/tox.ini index 46576b12e..4faae0236 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,11 @@ [tox] minversion = 3.18.0 -envlist = py{py3,27,35,36,37,38,39},lint,docs +envlist = py{py3,36,37,38,39} skip_missing_interpreters = true [gh-actions] python = pypy3: pypy3 - 2.7: py27 - 3.5: py35 3.6: py36 3.7: py37 3.8: py38 @@ -33,6 +31,7 @@ changedir = docs deps = doc8 sphinx + sphinx-autodoc-typehints python-dateutil allowlist_externals = make commands =