diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 34d9c4f2..ef9f98ee 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -43,7 +43,7 @@ jobs: - name: Install dependencies run: | pip install -U pip setuptools wheel - pip install -U tox tox-gh-actions + pip install -U tox tox-gh-actions "Cython>=3.0.0a11" - name: Test with tox run: tox - name: Upload coverage to Codecov diff --git a/Makefile b/Makefile index f55a3dce..04e768b6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: auto test docs clean +.PHONY: auto test docs clean compile-cython auto: build310 @@ -15,7 +15,15 @@ build36 build37 build38 build39 build310: clean pip install -U pip setuptools wheel; \ pip install -r requirements/requirements-tests.txt; \ pip install -r requirements/requirements-docs.txt; \ - pre-commit install + pre-commit install; \ + python setup.py build_ext --inplace + +compile-cython: + . venv/bin/activate; \ + python setup.py build_ext --inplace + +clean-cython: + rm -f ./arrow/*.c ./arrow/*.so ./arrow/*.pyd test: rm -f .coverage coverage.xml @@ -38,14 +46,14 @@ live-docs: clean-docs . venv/bin/activate; \ sphinx-autobuild docs docs/_build/html -clean: clean-dist +clean: clean-dist clean-cython rm -rf venv .pytest_cache ./**/__pycache__ rm -f .coverage coverage.xml ./**/*.pyc clean-dist: rm -rf dist build .egg .eggs arrow.egg-info -build-dist: +build-dist: compile-cython . venv/bin/activate; \ pip install -U pip setuptools twine wheel; \ python setup.py sdist bdist_wheel diff --git a/arrow/arrow.py b/arrow/arrow.py index e855eee0..dedd0c3c 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -1148,7 +1148,7 @@ def humanize( """ locale_name = locale - locale = locales.get_locale(locale) + locale_cls = locales.get_locale(locale) if other is None: utc = dt_datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc()) @@ -1179,41 +1179,55 @@ def humanize( try: if granularity == "auto": if diff < 10: - return locale.describe("now", only_distance=only_distance) + return locale_cls.describe("now", only_distance=only_distance) if diff < self._SECS_PER_MINUTE: seconds = sign * delta_second - return locale.describe( + return locale_cls.describe( "seconds", seconds, only_distance=only_distance ) elif diff < self._SECS_PER_MINUTE * 2: - return locale.describe("minute", sign, only_distance=only_distance) + return locale_cls.describe( + "minute", sign, only_distance=only_distance + ) elif diff < self._SECS_PER_HOUR: minutes = sign * max(delta_second // self._SECS_PER_MINUTE, 2) - return locale.describe( + return locale_cls.describe( "minutes", minutes, only_distance=only_distance ) elif diff < self._SECS_PER_HOUR * 2: - return locale.describe("hour", sign, only_distance=only_distance) + return locale_cls.describe( + "hour", sign, only_distance=only_distance + ) 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) + return locale_cls.describe( + "hours", hours, only_distance=only_distance + ) elif diff < self._SECS_PER_DAY * 2: - return locale.describe("day", sign, only_distance=only_distance) + return locale_cls.describe("day", sign, only_distance=only_distance) 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) + return locale_cls.describe( + "days", days, only_distance=only_distance + ) elif diff < self._SECS_PER_WEEK * 2: - return locale.describe("week", sign, only_distance=only_distance) + return locale_cls.describe( + "week", sign, only_distance=only_distance + ) 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) + return locale_cls.describe( + "weeks", weeks, only_distance=only_distance + ) elif diff < self._SECS_PER_MONTH * 2: - return locale.describe("month", sign, only_distance=only_distance) + return locale_cls.describe( + "month", sign, only_distance=only_distance + ) elif diff < self._SECS_PER_YEAR: # TODO revisit for humanization during leap years self_months = self._datetime.year * 12 + self._datetime.month @@ -1221,15 +1235,19 @@ def humanize( months = sign * max(abs(other_months - self_months), 2) - return locale.describe( + return locale_cls.describe( "months", months, only_distance=only_distance ) elif diff < self._SECS_PER_YEAR * 2: - return locale.describe("year", sign, only_distance=only_distance) + return locale_cls.describe( + "year", sign, only_distance=only_distance + ) else: years = sign * max(delta_second // self._SECS_PER_YEAR, 2) - return locale.describe("years", years, only_distance=only_distance) + return locale_cls.describe( + "years", years, only_distance=only_distance + ) elif isinstance(granularity, str): granularity = cast(TimeFrameLiteral, granularity) # type: ignore[assignment] @@ -1237,7 +1255,7 @@ def humanize( if granularity == "second": delta = sign * float(delta_second) if abs(delta) < 2: - return locale.describe("now", only_distance=only_distance) + return locale_cls.describe("now", only_distance=only_distance) elif granularity == "minute": delta = sign * delta_second / self._SECS_PER_MINUTE elif granularity == "hour": @@ -1260,7 +1278,9 @@ def humanize( if trunc(abs(delta)) != 1: granularity += "s" # type: ignore[assignment] - return locale.describe(granularity, delta, only_distance=only_distance) + return locale_cls.describe( + granularity, delta, only_distance=only_distance + ) else: @@ -1304,7 +1324,9 @@ def gather_timeframes(_delta: float, _frame: TimeFrameLiteral) -> float: "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter' or 'year'." ) - return locale.describe_multi(timeframes, only_distance=only_distance) + return locale_cls.describe_multi( + timeframes, only_distance=only_distance + ) except KeyError as e: raise ValueError( @@ -1410,9 +1432,7 @@ def dehumanize(self, input_string: str, locale: str = "en_us") -> "Arrow": # Add change value to the correct unit (incorporates the plurality that exists within timeframe i.e second v.s seconds) time_unit_to_change = str(unit) - time_unit_to_change += ( - "s" if (str(time_unit_to_change)[-1] != "s") else "" - ) + time_unit_to_change += "s" if time_unit_to_change[-1] != "s" else "" time_object_info[time_unit_to_change] = change_value unit_visited[time_unit_to_change] = True @@ -1663,7 +1683,8 @@ def isocalendar(self) -> Tuple[int, int, int]: """ - return self._datetime.isocalendar() + cal = tuple(self._datetime.isocalendar()) + return (cal[0], cal[1], cal[2]) def isoformat(self, sep: str = "T", timespec: str = "auto") -> str: """Returns an ISO 8601 formatted representation of the date and time. diff --git a/requirements/requirements.txt b/requirements/requirements.txt index bcdff0e8..14ac874f 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,2 +1,3 @@ +Cython>=3.0.0a11 python-dateutil>=2.7.0 typing_extensions; python_version < '3.8' diff --git a/setup.py b/setup.py index 52563cf9..f359fec7 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,22 @@ # mypy: ignore-errors from pathlib import Path -from setuptools import setup +from Cython.Build import build_ext, cythonize +from setuptools import Extension, setup readme = Path("README.rst").read_text(encoding="utf-8") version = Path("arrow/_version.py").read_text(encoding="utf-8") about = {} exec(version, about) +extensions = [ + Extension( + "*", + ["arrow/*.py"], + define_macros=[("CYTHON_TRACE", "1")], + ) +] + setup( name="arrow", version=about["__version__"], @@ -46,4 +55,8 @@ "Bug Reports": "https://github.com/arrow-py/arrow/issues", "Documentation": "https://arrow.readthedocs.io", }, + ext_modules=cythonize( + extensions, language_level="3", compiler_directives={"linetrace": True} + ), + cmdclass={"build_ext": build_ext}, ) diff --git a/tests/test_locales.py b/tests/test_locales.py index 4bbbd3dc..dd7a3fb4 100644 --- a/tests/test_locales.py +++ b/tests/test_locales.py @@ -77,13 +77,10 @@ def test_get_locale_by_class_name(self, mocker): mock_locale_cls = mocker.Mock() mock_locale_obj = mock_locale_cls.return_value = mocker.Mock() - globals_fn = mocker.Mock() - globals_fn.return_value = {"NonExistentLocale": mock_locale_cls} - with pytest.raises(ValueError): arrow.locales.get_locale_by_class_name("NonExistentLocale") - mocker.patch.object(locales, "globals", globals_fn) + mocker.patch.object(locales, "NonExistentLocale", mock_locale_cls, create=True) result = arrow.locales.get_locale_by_class_name("NonExistentLocale") mock_locale_cls.assert_called_once_with() diff --git a/tox.ini b/tox.ini index 11d70cb2..167f40cb 100644 --- a/tox.ini +++ b/tox.ini @@ -47,3 +47,6 @@ include_trailing_comma = true [flake8] per-file-ignores = arrow/__init__.py:F401,tests/*:ANN001,ANN201 ignore = E203,E501,W503,ANN101,ANN102,ANN401 + +[coverage:run] +plugins = Cython.Coverage