pax_global_header00006660000000000000000000000064152355630320014516gustar00rootroot0000000000000052 comment=9ba83236180cfec15778ee83a56074dea48c9aa3 pudo-prefixdate-2d2bbba/000077500000000000000000000000001523556303200153565ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/.bumpversion.cfg000066400000000000000000000003301523556303200204620ustar00rootroot00000000000000[bumpversion] current_version = 0.6.1 commit = True tag = True [bumpversion:file:pyproject.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:prefixdate/__init__.py] pudo-prefixdate-2d2bbba/.github/000077500000000000000000000000001523556303200167165ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/.github/FUNDING.yml000066400000000000000000000000741523556303200205340ustar00rootroot00000000000000# These are supported funding model platforms github: pudo pudo-prefixdate-2d2bbba/.github/dependabot.yml000066400000000000000000000004131523556303200215440ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip open-pull-requests-limit: 10 directory: "/" schedule: interval: monthly - package-ecosystem: "github-actions" open-pull-requests-limit: 10 directory: "/" schedule: interval: monthly pudo-prefixdate-2d2bbba/.github/workflows/000077500000000000000000000000001523556303200207535ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/.github/workflows/build.yml000066400000000000000000000025511523556303200226000ustar00rootroot00000000000000name: Build and publish on: push: branches: [main] tags: ["v*"] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e ".[dev]" - name: Lint with ruff run: | make lint - name: Validate type annotations run: | make typecheck - name: Run tests run: | pytest --cov=prefixdate tests/ publish: needs: test if: startsWith(github.ref, 'refs/tags') runs-on: ubuntu-latest environment: deploy permissions: id-token: write steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: "3.13" - name: Build an sdist and a wheel run: | python -m pip install --upgrade pip build python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true pudo-prefixdate-2d2bbba/.gitignore000066400000000000000000000002101523556303200173370ustar00rootroot00000000000000out.png dump.json .DS_Store __pycache__ *.pyc .idea/ .vscode/ *.egg-info dist/ build/ .mypy_cache/ .tox .pytest_cache .coverage htmlcov/pudo-prefixdate-2d2bbba/LICENSE000066400000000000000000000021671523556303200163710ustar00rootroot00000000000000MIT License Copyright (c) 2017-2019 Journalism Development Network, Inc. Copyright (c) 2020-2025 Friedrich Lindenberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pudo-prefixdate-2d2bbba/Makefile000066400000000000000000000002771523556303200170240ustar00rootroot00000000000000.PHONY: lint typecheck test lint: ruff check prefixdate tests typecheck: mypy --strict prefixdate test: lint typecheck pytest --cov-report html --cov-report term --cov=prefixdate tests/pudo-prefixdate-2d2bbba/README.md000066400000000000000000000103001523556303200166270ustar00rootroot00000000000000# Prefix date parser This is a helper class to parse dates with varied degrees of precision. For example, a data source might state a date as `2001`, `2001-4` or `2001-04-02`, with the implication that only the year, month or day is known. This library will process such partial dates into a structured format and allow their validation and re-formatting (e.g. turning `2001-4` into `2001-04` above). The library does not support the complexities of the ISO 8601 and RFC 3339 standards including date ranges and calendar-week/day-of-year notations. ## Installation Install `prefixdate` using PyPI. It requires Python 3.11 or newer and has no dependencies: ```bash $ pip install prefixdate ``` ## Usage The library provides a variety of helper functions to parse and format partial dates: ```python from prefixdate import parse, normalize_date, Precision # Parse returns a `DatePrefix` object: date = parse('2001-3') assert date.text == '2001-03' date = parse(2001) assert date.text == '2001' assert date.precision == Precision.YEAR date = parse(None) assert date.text is None assert date.precision == Precision.EMPTY # This will also be the outcome for invalid dates! # Normalize to a standard string: assert normalize_date('2001-1') == '2001-01' assert normalize_date('2001-00-00') == '2001' assert normalize_date('Boo!') is None # This also works for datetimes: from datetime import UTC, date, datetime now = datetime.now(UTC).isoformat() minute = normalize_date(now, precision=Precision.MINUTE) # You can also feed in None, date and datetime: normalize_date(datetime.now(UTC)) normalize_date(date.today()) normalize_date(None) ``` You can also use the `parse_parts` helper, which is similar to the constructor for a `datetime`: ```python from prefixdate import parse_parts, Precision date = parse_parts(2001, '3', None) assert date.precision == Precision.MONTH assert date.text == '2001-03' ``` ### Format strings For dates which are not already stored in an ISO 8601-like string format, you can supply one or many format strings for `datetime.strptime`. The format strings will be analysed to determine how precise the resulting dates are expected to be. ```python from prefixdate import parse_format, parse_formats, Precision date = parse_format('YEAR 2021', 'YEAR %Y') assert date.precision == Precision.YEAR assert date.text == '2021' # You can try out multiple formats in sequence. The first non-empty prefix # will be returned: date = parse_formats('2021', ['%Y-%m-%d', '%Y-%m', '%Y']) assert date.precision == Precision.YEAR assert date.text == '2021' ``` #### Two-digit years A `%y` format leaves the century open. `strptime` resolves it in a fixed 1969-2068 window, so a date outside of that lands in the wrong century. Pass `two_digit_year_base` to choose the 100 years to read the year in yourself -- a base of 1926 covers 1926 to 2025: ```python from prefixdate import parse_format date = parse_format('24-03', '%y-%m', two_digit_year_base=1926) assert date.text == '2024-03' date = parse_format('68-03', '%y-%m', two_digit_year_base=1926) assert date.text == '1968-03' ``` Without a base year the `strptime` window applies and a warning is logged. `parse_formats` takes the same argument. #### Inspecting format strings The helpers used to analyse formats are public, in case you want to apply the same reasoning before parsing: ```python from prefixdate import Precision, format_precision, has_two_digit_year from prefixdate import resolve_two_digit_year from datetime import datetime # The precision a format string implies: assert format_precision('%Y-%m') == Precision.MONTH # Whether it reads the year as two digits: assert has_two_digit_year('%y-%m') is True # Move an already-parsed date into the century starting at a base year: dt = resolve_two_digit_year(datetime(2024, 3, 1), 1926) assert dt.year == 2024 ``` ## Caveats * Datetimes are always converted to UTC. `DatePrefix.dt` keeps its timezone and is aware (`tzinfo=timezone.utc`), while `DatePrefix.text` reads as naive because the UTC offset sits past the precision the text is cut to. * Does not process milliseconds yet: fractional seconds are matched, but discarded, so `DatePrefix.dt.microsecond` is always `0`. * Does not process invalid dates, like Feb 31st. pudo-prefixdate-2d2bbba/prefixdate/000077500000000000000000000000001523556303200175115ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/prefixdate/__init__.py000066400000000000000000000026671523556303200216350ustar00rootroot00000000000000from prefixdate.formats import ( format_precision, has_two_digit_year, parse_format, parse_formats, resolve_two_digit_year, ) from prefixdate.parse import DatePrefix, Raw from prefixdate.precision import Precision Part = str | int | None def parse(raw: Raw, precision: Precision = Precision.FULL) -> DatePrefix: """Parse the given input date string and return a `DatePrefix` object that holds a datetime, text version and the precision of the date.""" return DatePrefix(raw, precision=precision) def normalize_date(raw: Raw, precision: Precision = Precision.FULL) -> str | None: """Take the given input date string and parse it into the normalised format to the precision given as an argument.""" return parse(raw, precision=precision).text def parse_parts( year: Part = None, month: Part = None, day: Part = None, hour: Part = None, minute: Part = None, second: Part = None, precision: Precision = Precision.FULL, ) -> DatePrefix: """Try to build a date prefix from the date components as given until one of them is null.""" raw = f"{year}-{month}-{day}T{hour}:{minute}:{second}" return parse(raw, precision=precision) __all__ = [ "DatePrefix", "Precision", "format_precision", "has_two_digit_year", "normalize_date", "parse", "parse_format", "parse_formats", "parse_parts", "resolve_two_digit_year", ] __version__ = "0.6.1" pudo-prefixdate-2d2bbba/prefixdate/formats.py000066400000000000000000000075631523556303200215510ustar00rootroot00000000000000import logging import re from collections.abc import Iterable from datetime import date, datetime from functools import lru_cache from prefixdate.parse import DatePrefix, Raw from prefixdate.precision import Precision log = logging.getLogger(__name__) MONTH_FORMATS = re.compile(r"(%b|%B|%m|%c|%x)") DAY_FORMATS = re.compile(r"(%d|%w|%c|%x)") HOUR_FORMATS = re.compile(r"(%H|%I|%c|%X)") MINUTE_FORMATS = re.compile(r"(%M|%c|%X)") SECOND_FORMATS = re.compile(r"(%S|%c|%X)") @lru_cache(maxsize=1000) def format_precision(format: str) -> Precision: """Determine the precision of a `datetime.strptime` format string so that it can be used in constructing a `DatePrefix`. This will check if the format string mentions directives with increasing precision. A format string that defines no date but only time directives will be considered `Precision.EMPTY`. """ if MONTH_FORMATS.search(format) is None: return Precision.YEAR if DAY_FORMATS.search(format) is None: return Precision.MONTH if HOUR_FORMATS.search(format) is None: return Precision.DAY if MINUTE_FORMATS.search(format) is None: return Precision.HOUR if SECOND_FORMATS.search(format) is None: return Precision.MINUTE return Precision.SECOND @lru_cache(maxsize=1000) def has_two_digit_year(format: str) -> bool: """Check if a `datetime.strptime` format string reads the year as two digits.""" return "%y" in format.replace("%%", "") def resolve_two_digit_year(dt: datetime, base_year: int) -> datetime: """Read a two-digit year in the 100 years that start at `base_year`. A base year of 1926 means that "68" is 1968 and "24" is 2024. `strptime` instead reads 00-68 as 20xx and 69-99 as 19xx, so a date outside of that fixed window ends up in the wrong century. Raises `ValueError` if the resolved date does not exist, i.e. 29 February in a year which is not a leap year. """ return dt.replace(year=base_year + (dt.year - base_year) % 100) def parse_format( raw: Raw, format: str, two_digit_year_base: int | None = None ) -> DatePrefix: """Parse the given raw input using the supplied format string. The precision of the result is inferred from the format string. `two_digit_year_base` is the first year of the 100 years that a `%y` format is read in: a base year of 1926 reads "68" as 1968 and "24" as 2024. Without it, `strptime`'s fixed 1969-2068 window applies and a warning is logged. """ if isinstance(raw, int): raw = str(raw) elif isinstance(raw, (datetime, date, DatePrefix)): return DatePrefix(raw) elif raw is None: return DatePrefix(None, precision=Precision.EMPTY) try: dt = datetime.strptime(raw, format) precision = format_precision(format) if has_two_digit_year(format): if two_digit_year_base is None: log.warning( "Date %r is parsed with the two-digit year format %s and no " "base year, so it may be in the wrong century", raw, format, ) else: dt = resolve_two_digit_year(dt, two_digit_year_base) return DatePrefix(dt, precision=precision) except (ValueError, TypeError): log.debug("Date %r does not match format %s", raw, format) return DatePrefix(None, precision=Precision.EMPTY) def parse_formats( raw: Raw, formats: Iterable[str], two_digit_year_base: int | None = None ) -> DatePrefix: """Run `parse_format` using an iterable of format strings, returning the first non-empty result from parsing.""" prefix = DatePrefix(None, precision=Precision.EMPTY) for format in formats: prefix = parse_format(raw, format, two_digit_year_base=two_digit_year_base) if prefix.precision != Precision.EMPTY: return prefix return prefix pudo-prefixdate-2d2bbba/prefixdate/parse.py000066400000000000000000000106571523556303200212060ustar00rootroot00000000000000import logging import re from datetime import UTC, date, datetime, timedelta, timezone from functools import total_ordering from re import Match from typing import Union, cast from prefixdate.precision import Precision log = logging.getLogger(__name__) Raw = Union[None, str, date, datetime, int, "DatePrefix"] REGEX = re.compile( r"^\s*((?P[12]\d{3}\b)" r"(-(?P\d{1,2}\b)" r"(-(?P\d{1,2})" r"([T ]" r"((?P\d{1,2}\b)" r"(:(?P\d{1,2}\b)" r"(:(?P\d{1,2}\b)" r"(\.\d{4,6}\b)?" r"(Z|(?P[-+])(?P\d{2})(:?(?P\d{2}\b))" r"?)?)?)?)?)?)?)?)?.*" ) @total_ordering class DatePrefix: """A date that is specified in terms of a value and an additional precision, which defines how well specified the date is. A datetime representation is provided, but it is not aware of the precision aspect.""" __slots__ = ["dt", "precision", "text"] def __init__(self, raw: Raw, precision: Precision = Precision.FULL): self.precision, self.dt = self._parse(raw, precision) self.text: str | None = None if self.dt is not None and self.precision != Precision.EMPTY: self.dt = self.dt if self.dt.tzinfo is not None and self.dt.tzinfo != UTC: self.dt = self.dt.astimezone(UTC) self.text = self.dt.isoformat()[: self.precision.value] def _parse(self, raw: Raw, pcn: Precision) -> tuple[Precision, datetime | None]: try: match = cast(Match[str], REGEX.match(raw)) # type: ignore except TypeError: if isinstance(raw, datetime): return (pcn, raw) if isinstance(raw, date): return self._parse(raw.isoformat(), pcn) if isinstance(raw, int) and 1000 < raw < 9999: return self._parse(str(raw), Precision.YEAR) if isinstance(raw, DatePrefix): return (raw.precision, raw.dt) if raw is not None: log.debug("Date value is invalid: %s", raw) return (Precision.EMPTY, None) pcn, year = self._extract(match, "year", 1000, pcn, Precision.EMPTY) pcn, month = self._extract(match, "month", 1, pcn, Precision.YEAR) pcn, day = self._extract(match, "day", 1, pcn, Precision.MONTH) pcn, hour = self._extract(match, "hour", 0, pcn, Precision.DAY) pcn, minute = self._extract(match, "minute", 0, pcn, Precision.HOUR) pcn, second = self._extract(match, "second", 0, pcn, Precision.MINUTE) try: tz = self._tzinfo(match) dt = datetime(year, month, day, hour, minute, second, tzinfo=tz) return (pcn, dt) except ValueError: log.debug("Date string is invalid: %s", raw) return (Precision.EMPTY, None) def _extract( self, match: Match[str], group: str, lowest: int, pcn: Precision, fail: Precision, ) -> tuple[Precision, int]: try: value = int(match.group(group)) if value >= lowest: return (pcn, value) except (ValueError, TypeError, AttributeError): pass precision = Precision(min(pcn.value, fail.value)) return (precision, lowest) def _tzinfo(self, match: Match[str]) -> timezone | None: """Parse the time zone information from a datetime string.""" # This is probably a bit rough-and-ready, there are good libraries # for this. Do we want to depend on one of them? try: sign = -1 if match.group("tzsign") == "-" else 1 hours = sign * int(match.group("tzhour")) minutes = sign * int(match.group("tzminute")) delta = timedelta(hours=hours, minutes=minutes) return timezone(delta) except (ValueError, TypeError, AttributeError): pass return None def __eq__(self, other: object) -> bool: return str(self) == str(other) def __lt__(self, other: object) -> bool: # cf. https://docs.python.org/3/library/functools.html#functools.total_ordering if isinstance(other, DatePrefix): return str(self) < str(other) return NotImplemented def __str__(self) -> str: return self.text or "" def __repr__(self) -> str: return f"" def __hash__(self) -> int: return hash(repr(self)) pudo-prefixdate-2d2bbba/prefixdate/precision.py000066400000000000000000000004231523556303200220550ustar00rootroot00000000000000from enum import Enum class Precision(Enum): """A date precision, defined by the offset of relevant date parts in an ISO 8601 datetime string.""" EMPTY = 0 YEAR = 4 MONTH = 7 DAY = 10 HOUR = 13 MINUTE = 16 SECOND = 19 FULL = SECOND pudo-prefixdate-2d2bbba/prefixdate/py.typed000066400000000000000000000000001523556303200211760ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/pyproject.toml000066400000000000000000000030571523556303200202770ustar00rootroot00000000000000[build-system] requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] name = "prefixdate" version = "0.6.1" description = "Parse and process date string of varied precision as prefixes in Python." readme = "README.md" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Friedrich Lindenberg", email = "friedrich@pudo.org" }] classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] keywords = ["date", "partial date", "iso8601", "rfc3339"] requires-python = ">=3.11" [project.urls] Homepage = "https://github.com/pudo/prefixdate" Repository = "https://github.com/pudo/prefixdate.git" Issues = "https://github.com/pudo/prefixdate/issues" [project.optional-dependencies] dev = [ "ruff", "mypy", "build", "wheel", "pytest", "pytest-cov", "bump2version", ] [tool.hatch.build.targets.wheel] packages = ["prefixdate"] [tool.ruff.lint] # We take ruff's default rule selection as-is, so a ruff upgrade brings in new # checks by design. `ignore` records the deliberate exceptions: ignore = [ # Partial dates are naive by design: a date given to year precision has no # meaningful zone, and `DatePrefix` only converts to UTC when the input # carried an offset. Requiring tz-aware construction here would mean # inventing a zone the input never specified. "DTZ001", "DTZ007", ] pudo-prefixdate-2d2bbba/tests/000077500000000000000000000000001523556303200165205ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/tests/__init__.py000066400000000000000000000000001523556303200206170ustar00rootroot00000000000000pudo-prefixdate-2d2bbba/tests/test_formats.py000066400000000000000000000056211523556303200216100ustar00rootroot00000000000000from datetime import datetime from pytest import raises from prefixdate.formats import ( Precision, format_precision, has_two_digit_year, parse_format, parse_formats, resolve_two_digit_year, ) def test_format_precision(): assert format_precision("la la %c bla") == Precision.SECOND assert format_precision("%Y bla") == Precision.YEAR assert format_precision("%m %Y") == Precision.MONTH assert format_precision("%d %b %Y") == Precision.DAY assert format_precision("%Y-%m-%dXX%H") == Precision.HOUR assert format_precision("%Y%m%d%H%M") == Precision.MINUTE def test_parse_format(): prefix = parse_format("2021 bla", "%Y bla") assert prefix.text == "2021" assert prefix.precision == Precision.YEAR second = parse_format(prefix, "%Y bla") assert second == prefix prefix = parse_format("2021 blubb", "%Y bla") assert prefix.text is None prefix = parse_format(None, "%Y bla") assert prefix.text is None prefix = parse_format(20210110, "%Y%m%d") assert prefix.text == "2021-01-10" assert prefix.precision == Precision.DAY def test_parse_formats(): prefix = parse_formats(None, ["%Y bla"]) assert prefix.text is None prefix = parse_formats("2021", []) assert prefix.text is None prefix = parse_formats("2021", ["%Y"]) assert prefix.text == "2021" prefix = parse_formats("2021", ["%Y-%m", "%Y"]) assert prefix.text == "2021" def test_has_two_digit_year(): assert has_two_digit_year("%d-%m-%y") assert has_two_digit_year("%y") assert not has_two_digit_year("%d-%m-%Y") assert not has_two_digit_year("%Y") # A literal percent sign followed by "y" is not a directive. assert not has_two_digit_year("%d %%y") def test_two_digit_year_base(): # Without a base year, the fixed strptime window applies. assert parse_format("16-07-68", "%d-%m-%y").text == "2068-07-16" # The base year selects the century. assert parse_format("16-07-68", "%d-%m-%y", 1926).text == "1968-07-16" assert parse_format("16-07-24", "%d-%m-%y", 1926).text == "2024-07-16" assert parse_format("16-07-68", "%d-%m-%y", 2000).text == "2068-07-16" # Reduced precision is kept. assert parse_format("07-68", "%m-%y", 1926).text == "1968-07" assert parse_format("68", "%y", 1926).text == "1968" # A four-digit year keeps its century. assert parse_format("16-07-1868", "%d-%m-%Y", 1926).text == "1868-07-16" # 29 February in a year which is not a leap year does not resolve at all: # 1900 does not exist, and guessing another century would hide the problem. assert parse_format("29-02-00", "%d-%m-%y", 1850).text is None with raises(ValueError): resolve_two_digit_year(datetime(2000, 2, 29), 1850) # The base year reaches parse_format through parse_formats. prefix = parse_formats("16-07-68", ["%Y-%m-%d", "%d-%m-%y"], 1926) assert prefix.text == "1968-07-16" pudo-prefixdate-2d2bbba/tests/test_parse.py000066400000000000000000000042451523556303200212500ustar00rootroot00000000000000from datetime import UTC, datetime import pytest from prefixdate import Precision, normalize_date, parse, parse_parts def test_normalize(): assert normalize_date(None) is None assert normalize_date("2001") == "2001" assert normalize_date("27171") is None assert normalize_date(2001) == "2001" assert normalize_date(201) is None assert normalize_date("2001-01-") == "2001-01" assert normalize_date("2001-1") == "2001-01" assert normalize_date("2001-W19") == "2001" assert normalize_date("2001-05-18") == "2001-05-18" assert normalize_date("2001-02-31") is None assert normalize_date("2001-05-18", Precision.YEAR) == "2001" text = "2021-07-01T13:43:22.175889+00:00" assert normalize_date(text, Precision.MINUTE) == "2021-07-01T13:43" text = "2021-07-01T13:43:22.175889+06:00" assert normalize_date(text, Precision.MINUTE) == "2021-07-01T07:43" text = "2021-07-01T13:43:22.175889-08:45" assert normalize_date(text, Precision.MINUTE) == "2021-07-01T22:28" prefix = parse("2017-5-2T10:00:00") assert prefix.text == "2017-05-02T10:00:00" assert prefix == "2017-05-02T10:00:00" assert prefix.precision == Precision.SECOND prefix = parse("2017-5-2T10:00") assert prefix.precision == Precision.MINUTE text = "2017-04-04T10:30:29" prefix = parse(text) assert prefix.text == text assert prefix.precision == Precision.SECOND now = datetime.now(UTC) assert parse(now).dt == now assert parse(now.date()).text == now.date().isoformat() assert str(parse(2001)) == "2001" assert repr(parse(2001)) == f"" # feed a prefix to parse: prefix = parse(now) out = parse(prefix) assert out == prefix early = parse("2017-04-04T10:30:29") late = parse("2017-04-09T10:30:29") assert early < late assert late > early with pytest.raises(TypeError): assert late > "banana" assert hash(late) is not None def test_parse_parts(): assert parse_parts(year=None).text is None assert parse_parts(year=2001, month=3, day=0).text == "2001-03" assert parse_parts(year=2001, month="03", day="0").text == "2001-03"