diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d79e325..de49a27 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,4 +1,4 @@ -name: style check +name: style + docs check on: pull_request: @@ -13,10 +13,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" cache: 'pip' cache-dependency-path: '**/setup.cfg' - name: Install package with dependencies @@ -24,3 +24,5 @@ jobs: if: steps.python-cache.outputs.cache-hit != 'true' - name: Run black run: black src --check --diff + - name: Check that documentation can be built + run: tox -e docs diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..bdaab28 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 70b0c63..15b2668 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -10,7 +10,8 @@ on: pull_request: env: - COV_PYTHON_VERSION: "3.9" + # python version used to calculate and submit code coverage + COV_PYTHON_VERSION: "3.10" jobs: python-unit: diff --git a/.gitignore b/.gitignore index 0406c69..31599aa 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ wheels/ .installed.cfg *.egg MANIFEST +docs/_build/ # Environments .tox diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..672b526 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Change Log + +## 0.1 + +Pre-alpha version with preliminary `Undate` and `UndateInterval` classes +with support for ISO8601 date format diff --git a/README.md b/README.md index 9b8e0e4..2529ea4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # undate-python -`undate` is a python library for working with uncertain or partially known dates. +**undate** is a python library for working with uncertain or partially known dates. + +It was initially created as part of a [DH-Tech](https://dh-tech.github.io/) hackathon in November 2022. + +--- + +⚠️ **WARNING:** this is pre-alpha software and is **NOT** feature complete! Use with caution. ⚠️ + +--- -It was initially created as part of a DH-Tech hackathon in November 2022. [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Documentation Status](https://readthedocs.org/projects/undate-python/badge/?version=latest)](https://undate-python.readthedocs.io/en/latest/?badge=latest) [![unit tests](https://github.com/dh-tech/undate-python/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/dh-tech/undate-python/actions/workflows/unit_tests.yml) [![codecov](https://codecov.io/gh/dh-tech/undate-python/branch/main/graph/badge.svg?token=GE7HZE8C9D)](https://codecov.io/gh/dh-tech/undate-python) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -12,15 +20,24 @@ It was initially created as part of a DH-Tech hackathon in November 2022. [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) +## Documentation + +Project documentation is available on ReadTheDocs https://undate-python.readthedocs.io/en/latest/ + ## License This software is licensed under the [Apache 2.0 License](LICENSE.md). ## Installation +To install the most recent release from PyPI: +```sh +pip install undate +``` + To install the latest development version from GitHub: ```sh -pip install git+https://github.com/dh-tech/undate-python.git@main#egg=undate +pip install git+https://github.com/dh-tech/undate-python.git@develop#egg=undate ``` To install a specific release or branch, run the following (replace `[tag-name]` with the tag or branch you want to install): diff --git a/docs/conf.py b/docs/conf.py index 5e9ca6b..2185873 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,14 +17,14 @@ # -- Project information ----------------------------------------------------- -project = 'Undate' -copyright = '2022, DHtech' -author = 'DHtech Community' +project = "Undate" +copyright = "2022, DHtech" +author = "DHtech Community" # The full version, including alpha/beta/rc tags -release = '0.0.1.dev' +release = "0.0.1.dev" -master_doc = 'index' +master_doc = "index" # -- General configuration --------------------------------------------------- @@ -32,19 +32,15 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'm2r2' -] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "m2r2"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] source_suffix = [".rst", ".md"] @@ -53,11 +49,11 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # 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_static_path = ["_static"] -html_logo = '_static/logo.png' +html_logo = "_static/logo.png" diff --git a/docs/index.rst b/docs/index.rst index 52acdca..e01b84d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Undate documentation ==================== -This project ... +**undate** is a python library for working with uncertain or partially known dates. .. toctree:: diff --git a/pytest.ini b/pytest.ini index 241ec4a..a0a5fea 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = - last: run marked tests after all others \ No newline at end of file + last: run marked tests after all others + first: run marked tests before all others \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 2dc4afd..8d04412 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,20 +1,24 @@ [metadata] name = undate version = attr: undate.__version__ -author = 'DHTech' -author_email = 'dhtech.community@gmail.com' -description = "library for working with uncertain, fuzzy, or " - + "partially unknown dates and date intervals" +author = DHTech +author_email = "dhtech.community@gmail.com" +description = "library for working with uncertain, fuzzy, or partially unknown dates and date intervals" long_description = file: README.md -license="Apache License, Version 2.0", +license="Apache License, Version 2.0" long_description_content_type = text/markdown -url = https://github.com/dh-tech/hackathon-2022 +url = https://github.com/dh-tech/undate-python project_urls = - Project Home = https://dh-tech.github.io + Project Home = https://github.com/dh-tech/undate-python Bug Tracker = https://github.com/dh-tech/undate-python/issues +keywords = "dates dating uncertainty uncertain-dates unknown partially-known digital-humanities" classifiers = Development Status :: 2 - Pre-Alpha Programming Language :: Python :: 3 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Intended Audience :: Developers License :: OSI Approved :: Apache Software License Operating System :: OS Independent @@ -22,6 +26,13 @@ classifiers = Topic :: Utilities Typing :: Typed +# When supported python versions change, update all the following places: +# - classifiers +# - minimum version required in python_requires +# - tox envlist +# - gh-actions +# - python versions in matrix config in unit_tests.yml + [options] package_dir = = src @@ -29,6 +40,7 @@ packages = find: python_requires = >=3.8 install_requires = python-dateutil + [options.extras_require] all = %(dev)s @@ -37,13 +49,61 @@ dev = black>=22.10.0 pre-commit>=2.20.0 tox - sphinx twine wheel - pytest-cov + build + %(docs)s test = pytest>=7.2 pytest-ordering + pytest-cov +docs = + sphinx + sphinx_rtd_theme + m2r2 [options.packages.find] where = src + +[tox:tox] +envlist = py38, py39, py310, py311 +isolated_build = True + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + +[pytest] +minversion = 6.0 +addopts = -ra -q +testpaths = + tests + +[testenv] +deps = + -e ./[test] +commands = pytest {posargs} + +[testenv:flake8] +deps = + flake8 +commands = + flake8 --ignore=E501,E402,F401 src/undate/ tests/ + +[testenv:coverage] +deps = + -e ./[test] +commands = + pytest --cov=./ --cov-report=xml + +[testenv:docs] +description = invoke sphinx-build to build the HTML docs +# NOTE: base python should match whatever we're using in GitHub Actions +basepython = python3.10 +deps = + -e ./[docs] +commands = sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" --color -W -bhtml {posargs} + python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' diff --git a/src/undate/__init__.py b/src/undate/__init__.py index a664584..3dc1f76 100644 --- a/src/undate/__init__.py +++ b/src/undate/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0.dev" +__version__ = "0.1.0" diff --git a/src/undate/dateformat/base.py b/src/undate/dateformat/base.py index 5c12d83..eafebe5 100644 --- a/src/undate/dateformat/base.py +++ b/src/undate/dateformat/base.py @@ -1,18 +1,26 @@ -# base class for date format parsers -from typing import Dict - -"""Base class for date format parsing and serializing +""" +Base class for date format parsing and serializing To add support for a new date format: - create a new file under undate/dateformat - extend BaseDateFormat and implement parse and to_string methods as desired/appropriate -- Add your new formatter to [... details TBD ...] - so that it will be included in the available formatters + +It should be loaded automatically and included in the formatters +returned by :meth:`BaseDateFormat.available_formatters` """ +import importlib +import logging +import pkgutil +from typing import Dict +from functools import lru_cache # functools.cache not available until 3.9 + + +logger = logging.getLogger(__name__) + class BaseDateFormat: """Base class for parsing and formatting dates for specific formats.""" @@ -30,10 +38,34 @@ def to_string(self, undate) -> str: # convert an undate or interval to string representation for this format raise NotImplementedError + # cache import class method to ensure we only import once @classmethod - def available_formatters(cls) -> Dict[str, "BaseDateFormat"]: - # FIXME: workaround for circular import problem" + @lru_cache + def import_formatters(cls): + """Import all undate.dateformat formatters + so that they will be included in available formatters + even if not explicitly imported. Only import once. + returns the count of modules imported.""" + logger.debug("Loading formatters under undate.dateformat") + import undate.dateformat + + # load packages under this path with curent package prefix + formatter_path = undate.dateformat.__path__ + formatter_prefix = f"{undate.dateformat.__name__}." - from undate.dateformat.iso8601 import ISO8601DateFormat + import_count = 0 + for importer, modname, ispkg in pkgutil.iter_modules( + formatter_path, formatter_prefix + ): + # import everything except the current file + if not modname.endswith(".base"): + importlib.import_module(modname) + import_count += 1 + return import_count + + @classmethod + def available_formatters(cls) -> Dict[str, "BaseDateFormat"]: + # ensure undate formatters are imported + cls.import_formatters() return {c.name: c for c in cls.__subclasses__()} # type: ignore diff --git a/src/undate/dateformat/iso8601.py b/src/undate/dateformat/iso8601.py index 5bf9896..22aff07 100644 --- a/src/undate/dateformat/iso8601.py +++ b/src/undate/dateformat/iso8601.py @@ -4,7 +4,6 @@ class ISO8601DateFormat(BaseDateFormat): - # NOTE: do we care about validation? could use regex # but maybe be permissive, warn if invalid but we can parse diff --git a/src/undate/undate.py b/src/undate/undate.py index 6f83b93..566f869 100644 --- a/src/undate/undate.py +++ b/src/undate/undate.py @@ -20,6 +20,8 @@ class Undate: earliest: Union[datetime.date, None] = None latest: Union[datetime.date, None] = None + #: A string to label a specific undate, e.g. "German Unity Date 2022" for Oct. 3, 2022. + #: Labels are not taken into account when comparing undate objects. label: Union[str, None] = None formatter: Union[BaseDateFormat, None] = None @@ -29,6 +31,7 @@ def __init__( month: Optional[int] = None, day: Optional[int] = None, formatter: Optional[BaseDateFormat] = None, + label: Optional[str] = None, ): # TODO: support initializing for unknown values in each of these # e.g., maybe values could be string or int; if string with @@ -63,10 +66,14 @@ def __init__( formatter = BaseDateFormat.available_formatters()[self.DEFAULT_FORMAT]() self.formatter = formatter + self.label = label + def __str__(self) -> str: return self.formatter.to_string(self) def __repr__(self) -> str: + if self.label: + return "" % (self.label, self) return "" % self def __eq__(self, other: "Undate") -> bool: @@ -95,21 +102,32 @@ class UndateInterval: :type earliest: `undate.Undate` :param latest: Latest undate :type latest: `undate.Undate` + :param label: A string to label a specific undate interval, similar to labels of `undate.Undate`. + :type label: `str` """ # date range between two uncertain dates def __init__( - self, earliest: Union[Undate, None] = None, latest: Union[Undate, None] = None + self, + earliest: Union[Undate, None] = None, + latest: Union[Undate, None] = None, + label: Union[str, None] = None, ): # for now, assume takes two undate objects self.earliest = earliest self.latest = latest + self.label = label def __str__(self) -> str: # using EDTF syntax for open ranges return "%s/%s" % (self.earliest or "..", self.latest or "") + def __repr__(self) -> str: + if self.label: + return "" % (self.label, self) + return "" % self + def __eq__(self, other) -> bool: # consider interval equal if both dates are equal return self.earliest == other.earliest and self.latest == other.latest diff --git a/tests/test_dateformat/test_base.py b/tests/test_dateformat/test_base.py index 40682a7..63568f0 100644 --- a/tests/test_dateformat/test_base.py +++ b/tests/test_dateformat/test_base.py @@ -1,3 +1,5 @@ +import logging + import pytest from undate.dateformat.base import BaseDateFormat @@ -29,6 +31,23 @@ def test_parse_to_string(self): BaseDateFormat().to_string(1991) +@pytest.mark.first +def test_import_formatters_import_only_once(caplog): + # run first so we can confirm it runs once + with caplog.at_level(logging.DEBUG): + import_count = BaseDateFormat.import_formatters() + # should import at least one thing (iso8601) + assert import_count >= 1 + # should have log entry + assert "Loading formatters" in caplog.text + + # if we clear the log and run again, should not do anything + caplog.clear() + with caplog.at_level(logging.DEBUG): + BaseDateFormat.import_formatters() + assert "Loading formatters" not in caplog.text + + @pytest.mark.last def test_formatters_unique_error(): # confirm that our uniqe formatters check fails when it should diff --git a/tests/test_undate.py b/tests/test_undate.py index 628d7b5..9217ea4 100644 --- a/tests/test_undate.py +++ b/tests/test_undate.py @@ -12,6 +12,13 @@ def test_str(self): assert str(Undate(2022)) == "2022" assert str(Undate(month=11, day=7)) == "--11-07" + def test_repr(self): + assert repr(Undate(2022, 11, 7)) == "" + assert ( + repr(Undate(2022, 11, 7, label="A Special Day")) + == "" + ) + def test_invalid_date(self): # invalid month should raise an error with pytest.raises(ValueError): @@ -66,6 +73,16 @@ def test_str(self): == "2022-11-01/2023-11-07" ) + def test_repr(self): + assert ( + repr(UndateInterval(Undate(2022), Undate(2023))) + == "" + ) + assert ( + repr(UndateInterval(Undate(2022), Undate(2023), label="Fancy Epoch")) + == "" + ) + def test_str_open_range(self): # 900 - assert str(UndateInterval(Undate(900))) == "0900/" diff --git a/tox.ini b/tox.ini deleted file mode 100644 index cb8a73e..0000000 --- a/tox.ini +++ /dev/null @@ -1,46 +0,0 @@ -[tox] -envlist = py38, py39, py310, py311 -isolated_build = True - -[gh-actions] -python = - 3.8: py38 - 3.9: py39 - 3.10: py310 - 3.11: py311 - -[pytest] -minversion = 6.0 -addopts = -ra -q -testpaths = - tests - -[testenv] -deps = - pytest - -e ./[test] -commands = pytest {posargs} - -[testenv:flake8] -deps = - flake8 -commands = - flake8 --ignore=E501,E402,F401 src/undate/ tests/ - -[testenv:coverage] -deps = - pytest - pytest-cov -commands = - pytest --cov=./ --cov-report=xml - -[testenv:docs] -description = invoke sphinx-build to build the HTML docs -basepython = python3.10 -deps = - -e ./[docs] - sphinx - sphinx_rtd_theme - m2r2 -commands = sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" --color -W -bhtml {posargs} - python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))'