diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 94d0525..c7047ca 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: with: python-version: "3.12" enable-cache: true - cache-dependency-glob: "pyproject.toml" + cache-dependency-glob: "uv.lock" - name: Install package with check dependencies run: uv sync --group check diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 7364816..7583831 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -25,16 +25,12 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build + python-version: "3.12" - name: Build package - run: python -m build + run: uv build - name: Publish package uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b with: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ef56dff..66bdd49 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -42,8 +42,8 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ matrix.python }} - cache: "pip" - cache-dependency-path: "**/pyproject.toml" + enable-cache: true + cache-dependency-glob: "uv.lock" - name: Install package with check dependencies run: uv sync --group test diff --git a/docs/_static/custom.css b/docs/_static/custom.css index aa35e82..6ba85cf 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,4 +1,9 @@ +.sidebar-logo { + width: 200px; + height: auto; +} + div.sphinxsidebar .powered_by a { text-decoration: none; border-bottom: none; diff --git a/docs/_static/undate_logo.svg b/docs/_static/undate_logo.svg new file mode 100644 index 0000000..52bba2e --- /dev/null +++ b/docs/_static/undate_logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/_static/undate_logo_dark.svg b/docs/_static/undate_logo_dark.svg new file mode 100644 index 0000000..bc907b7 --- /dev/null +++ b/docs/_static/undate_logo_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index ff5d7d3..0f16646 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,6 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # - import sys from pathlib import Path @@ -39,8 +38,12 @@ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "myst_parser", + "sphinx_pyodide", ] +# build static output for noscript fallback +pyodide_build_output = True + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -59,30 +62,34 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "alabaster" +html_theme = "furo" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +html_css_files = ["custom.css"] html_theme_options = { - "logo": "undate_logo.png", - "logo_name": False, - "github_user": "dh-tech", - "github_repo": "undate-python", - "github_button": False, - "github_banner": True, -} - -html_sidebars = { - "**": [ - "about.html", - "navigation.html", - "localtoc.html", - "searchbox.html", - "sidebar_dhtech.html", - ], + "light_logo": "undate_logo.svg", + "dark_logo": "undate_logo_dark.svg", + "source_repository": "https://github.com/dh-tech/undate-python", + "source_branch": "main", + "source_directory": "docs/", + "light_css_variables": { + "color-brand-primary": "#6670bb", + "color-brand-content": "#6670bb", + "color-background-primary": "#f0f1f8", + "color-background-secondary": "#e5e7f2", + "sidebar-logo-width": "200px", + }, + "dark_css_variables": { + "color-brand-primary": "#9198d0", + "color-brand-content": "#9198d0", + "color-background-primary": "#1d202f", + "color-background-secondary": "#151825", + "sidebar-logo-width": "200px", + }, } # turn on relative links; make sure both github and sphinx links work diff --git a/docs/example_usage.rst b/docs/example_usage.rst new file mode 100644 index 0000000..0b3f850 --- /dev/null +++ b/docs/example_usage.rst @@ -0,0 +1,232 @@ +Example Usage +-------------- + +Often humanities and cultural data include imprecise or uncertain temporal information. We want to store that information but also work with it in a structured way, not just treat it as text for display. Different projects may need to work with or convert between different date formats or even different calendars. + +An ``undate.Undate`` is analogous to python’s builtin ``datetime.date`` object, but with support for varying degrees of precision and unknown information. You can initialize an ``Undate`` with either strings or numbers for whichever parts of the date are known or partially known. An ``Undate`` can take an optional label. + +.. pyodide:: + :packages: ./wheels/PyMeeus-0.5.12-py3-none-any.whl,./wheels/undate-0.8.0.dev0-py3-none-any.whl + + from undate import __version__ + + print(f"Running undate v{__version__}") + + +.. pyodide:: + from undate import Undate + + november7 = Undate(2000, 11, 7) + november = Undate(2000, 11) + year2k = Undate(2000) + november7_some_year = Undate(month=11, day=7) + + partially_known_year = Undate("19XX") + partially_known_month = Undate(2022, "1X") + + easter1916 = Undate(1916, 4, 23, label="Easter 1916") + +You can convert an ``Undate`` to string using a date formatter (current default is ISO8601): + + +.. pyodide:: + print([str(d) for d in [november7, november, year2k, november7_some_year]]) + + +If enough information is known, an ``Undate`` object can report on its duration: + +.. pyodide:: + december = Undate(2000, 12) + feb_leapyear = Undate(2024, 2) + feb_regularyear = Undate(2023, 2) + + example_dates = [ + november7, november, december, year2k, + november7_some_year, feb_regularyear, feb_leapyear + ] + for d in example_dates: + print(f"{d!s:<10} duration in days: {d.duration().days:>2}") + +If enough of the date is known and the precision supports it, you can +check if one date falls within another date: + +.. pyodide:: + november7 = Undate(2000, 11, 7) + november2000 = Undate(2000, 11) + year2k = Undate(2000) + ad100 = Undate(100) + november7 in november + + yes_no = {True: "✅", False: "❌"} + for range in [november2000, year2k, ad100]: + print(f"{november7!s:>10} within {range!s:<10}? {yes_no[november7 in range]}") + if november2000 != range: # don't test against itself + print(f"{november2000!s:>10} within {range!s:<10}? {yes_no[november2000 in range]}") + + +For dates that are imprecise or partially known, ``undate`` calculates +earliest and latest possible dates for comparison purposes so you can +sort dates ... + +.. pyodide:: + november7_2020 = Undate(2020, 11, 7) + november_2001 = Undate(2001, 11) + year2k = Undate(2000) + ad100 = Undate(100) + for date in sorted([november7_2020, november_2001, year2k, ad100]): + # print the date in ISO/EDTF format along with the python representation + print(f"{date!s:>10} : {repr(date)}") + + +You can also compare with equals, greater than, and less than. You +can also compare with python ``datetime.date`` objects. + +.. pyodide:: + from datetime import date + + jan2001 = date(2001, 1, 1) + + print(f"{november7_2020!s:>10} before {november_2001!s:<10} ? {yes_no[november7_2020 > november_2001]}") + print(f"{year2k!s:>10} after {ad100!s:<10} ? {yes_no[year2k > ad100]}") + print(f"{year2k!s:>10} after {jan2001} ? {yes_no[year2k > jan2001]}") + + +When dates cannot be compared due to ambiguity or precision, comparison +methods raise a ``NotImplementedError``. + +.. pyodide:: + :show-errors: + + november_2020 = Undate(2020, 11) + try: + november7_2020 > november_2020 + except NotImplementedError as err: + print(err) + +An ``UndateInterval`` is a date range between two ``Undate`` objects. +Intervals can be open-ended, allow for optional labels, and can +calculate duration if enough information is known. ``UndateIntervals`` +are inclusive (i.e., a closed interval), and include both the earliest +and latest date as part of the range. + +.. pyodide:: + from undate import UndateInterval + + century19 = UndateInterval(Undate(1801), Undate(1900), label="19th century") + century20 = UndateInterval(Undate(1901), Undate(2000), label="20th century") + before2000 = UndateInterval(latest=Undate(2000)) # before 2000 + after1900 = UndateInterval(Undate(1900)) # after 1900 + + for interval in [century19, century20, before2000, after1900]: + print(f"{repr(interval)}\n{interval}\n") + +Intervals can calculate duration if enough information is known: + +.. pyodide:: + jan2000_interval = UndateInterval( + Undate(2000, 1, 1), + Undate(2000, 1, 31), + label="January 2000" + ) + for interval in [century19, century20, jan2000_interval]: + print(f"{interval.label}: {interval.duration().days:,} days") + +Parsing and Formatting +~~~~~~~~~~~~~~~~~~~~~~ + +You can initialize ``Undate`` or ``UndateInterval`` objects by parsing a +date string with a named converter, and output an ``Undate`` in a +different format than it was parsed. The ``"ISO8601"`` and ``"EDTF"`` +converters handle the most common structured formats: + +.. pyodide:: + print(repr(Undate.parse("2002", "ISO8601"))) + print(repr(Undate.parse("2002-05", "EDTF"))) + print(repr(Undate.parse("--05-03", "ISO8601"))) + print(repr(Undate.parse("1800/1900", "EDTF"))) + + # convert between formats + print(Undate.parse("--05-03", "ISO8601").format("EDTF")) + +The ``"Gregorian"`` converter parses dates with full or abbreviated month +names across multiple languages: + +.. pyodide:: + :editable: + :show-errors: + + dates = [ + "7 November 2000", + "Nov 2000", + "avril 1362", + "2022 Ugushyingo 26", + ] + for date in dates: + print(repr(Undate.parse(date, "Gregorian"))) + + +The ``"holidays"`` converter parses Christian liturgical dates, including +movable feasts: + +.. pyodide:: + :editable: + :show-errors: + + holidays = [ + "Epiphany 1942", + "Easter 1942", + "Ash Wednesday 1942", + ] + undate_holidays = [Undate.parse(hol, "holidays") for hol in holidays] + + for holidate in undate_holidays: + print(f"{holidate.label}: {holidate!s} (earliest: {holidate.earliest}, latest: {holidate.latest})") + + +Calendars +~~~~~~~~~ + +All ``Undate`` objects are calendar aware, and date converters include +support for parsing and working with dates from other calendars. The +Gregorian calendar is used by default; currently ``undate`` supports the +Islamic Hijri calendar and the Hebrew Anno Mundi calendar. + +Dates are stored with the year, month, day and appropriate precision for +the original calendar; internally, earliest and latest dates are +calculated in Gregorian / Proleptic Gregorian calendar for standardized +comparison across dates from different calendars. + +.. pyodide:: + tammuz4816 = Undate.parse("26 Tammuz 4816", "Hebrew") + rajab495 = Undate.parse("Rajab 495", "Islamic") + y2k = Undate.parse("2001", "EDTF") + + for d in [tammuz4816, rajab495, y2k]: + print(f"{d!s:<10} {repr(d)}") + +.. pyodide:: + print("Earliest Gregorian equivalent:") + for d in [rajab495, tammuz4816, y2k]: + print(f" {str(d):<20} earliest: {d.earliest}") + + print("\nPrecision:") + for d in [rajab495, tammuz4816, y2k]: + print(f" {str(d):<20} precision: {d.precision}") + +.. pyodide:: + print("Sorted by Gregorian date:") + for d in sorted([rajab495, tammuz4816, y2k]): + print(f" {repr(d)}") + +Try it yourself +~~~~~~~~~~~~~~~ + +.. pyodide:: + :editable: + :show-errors: + + # try your own dates here + from undate import Undate, UndateInterval + + mydate = Undate(2025, 6) + print(mydate) diff --git a/docs/index.rst b/docs/index.rst index 4f302f9..294c40d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,14 +1,92 @@ -undate documentation -==================== +undate +====== -**undate** is a python library for working with uncertain or partially known dates. +.. image:: _static/undate_logo.svg + :alt: undate + :width: 350px + :class: only-light +.. image:: _static/undate_logo_dark.svg + :alt: undate + :width: 350px + :class: only-dark + +**undate** is a Python library for working with uncertain or partially known dates. + +.. image:: https://zenodo.org/badge/DOI/10.5281/zenodo.11068867.svg + :target: https://doi.org/10.5281/zenodo.11068867 +.. image:: https://img.shields.io/badge/License-Apache_2.0-blue.svg + :target: https://opensource.org/licenses/Apache-2.0 +.. image:: https://readthedocs.org/projects/undate-python/badge/?version=latest + :target: https://undate-python.readthedocs.io/en/latest/?badge=latest +.. image:: https://github.com/dh-tech/undate-python/actions/workflows/unit_tests.yml/badge.svg + :target: https://github.com/dh-tech/undate-python/actions/workflows/unit_tests.yml +.. image:: https://codecov.io/gh/dh-tech/undate-python/branch/main/graph/badge.svg?token=GE7HZE8C9D + :target: https://codecov.io/gh/dh-tech/undate-python + +.. note:: + This is beta software; it is still in development and not fully feature complete. + If you use it, please let us know and share your feedback. + +``undate`` supports parsing, formatting, and reasoning with dates in varying +precision and calendars. Dates with different precision and from different original +calendars can be used together. Supported formats include: + +- portions of EDTF (Extended Date Time Format) +- ISO8601 +- parsing and calendar conversion for dates in Hebrew Anno Mundi and Islamic Hijri calendars +- Gregorian dates with full or abbreviated month names in any order for multiple languages + (English, Spanish, French, German, Kinyarwanda, Ganda, Tigrinya) +- Christian liturgical dates (fixed holidays and movable feasts) + +For unambiguous dates, there is an experimental omnibus parser which combines all available parsers. + +For more about the origin and goals of ``undate``, read our 2025 software paper: + + Rebecca Sutton Koeser, Julia Damerow, Robert Casties, and Cole Crawford. + "`Undate: Humanistic Dates for Computation `_." + *Computational Humanities Research*, August 5, 2025. + +Quick Start +----------- + +An ``Undate`` is analogous to Python's ``datetime.date``, but with support for +varying precision and unknown information: + +.. code-block:: python + + from undate import Undate, UndateInterval + + november = Undate(2000, 11) # year and month known + year2k = Undate(2000) # year only + partially_known = Undate("19XX") # partially unknown year + + print([str(d) for d in [november, year2k, partially_known]]) + # ['2000-11', '2000', '19XX'] + +Dates can be parsed from multiple formats and calendars, sorted, and compared +even when precision differs. See :doc:`example_usage` for interactive examples. + +Installation +------------ + +Install the latest published version from PyPI: + +.. code-block:: console + + pip install undate + +To install a development version or specific branch: + +.. code-block:: console + + pip install git+https://github.com/dh-tech/undate-python@develop#egg=undate .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: - readme + example_usage undate/index CONTRIBUTING DEVELOPER_NOTES diff --git a/docs/readme.md b/docs/readme.md deleted file mode 100644 index 4ec94a5..0000000 --- a/docs/readme.md +++ /dev/null @@ -1,4 +0,0 @@ -```{include} ../README.md -:relative-docs: / -:relative-images: -``` diff --git a/pyproject.toml b/pyproject.toml index 09c8ca5..23fb665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,13 @@ classifiers = [ ] [dependency-groups] -docs = ["sphinx>=7.0.0", "alabaster", "myst-parser", "myst-parser[linkify]"] +docs = [ + "sphinx>=8.0.0", + "furo", + "myst-parser", + "myst-parser[linkify]", + "sphinx-pyodide", +] test = ["pytest>=9", "pytest-ordering", "pytest-cov"] notebooks = ["jupyterlab", "pandas", "treon", "altair"] check = [ { include-group = "docs" }, {include-group = "notebooks"}, "mypy", "ruff"] @@ -60,9 +66,10 @@ dev = [ "twine", "wheel", "build", + "sphinx-autobuild>=2024.10.3", { include-group = "test" }, { include-group = "check" }, - { include-group = "docs" } + { include-group = "docs" }, ] [project.urls] @@ -122,3 +129,7 @@ ignore = [] [tool.ruff.lint.per-file-ignores] # for test files, don't require docstrings or return type annotations "tests/**.py" = ["D", "ANN", "RUF"] + +# uncomment for local development of sphinx-pyodide +#[tool.uv.sources] +#sphinx-pyodide = { path = "../sphinx-pyodide", editable = true }