Repository: reichlab/cladetime

Issue:
Incorporate timestamp when using an "as_of" date to get the VersionID of an S3 object
This is a follow up to #23 

That PR added the ability to look up an S3 versionID for a specific date, but as [noted in that PR review](https://github.com/reichlab/virus-clade-utils/pull/23#pullrequestreview-2333861144), we really should make that feature "timestamp aware."

**Definition of Done**

- [x] If no `as_of` date is supplied when downloading files from Nextstrain's S3 bucket, the default date should be UTC `datetime.now()` (i.e., current timestamp) instead of the current date at midnight.
- [x] Allow user to specify a timestamp in addition to a date. If user specifies an `as_of` date without a timestamp, default the time to midnight UTC. 
 
**2025-01-3 update:** If someone passes YYYY-MM-DD in string format (_i.e._, 2025-01-01), Cladetime should interpret that as `datetime(2024, 1, 1, 11, 59, 59)`. However, if someone passes a Python `datetime` object without a time (_i.e._, `datetime(2025, 1, 1, 0, 0, 0)`), it's hard to interpret intent--maybe they want to use midnight as a time stamp? Therefore, in the latter case, Cladetime should not override the timestamp to 11:59:59)

Discussion:

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..f390822
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,98 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+  workflow_dispatch:
+
+env:
+  FORCE_COLOR: "1"
+  PIP_DISABLE_PIP_VERSION_CHECK: "1"
+  PIP_NO_PYTHON_VERSION_WARNING: "1"
+
+jobs:
+  run-checks:
+    name: Run linter and tests
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout 🛎️
+        uses: actions/checkout@v4
+
+      - name: Set up Python 🐍
+        uses: actions/setup-python@v5
+        with:
+          cache: pip
+
+      - name: Install dependencies 📦️
+        run: |
+          pip install --upgrade pip
+          pip install -r requirements/requirements-dev.txt
+          pip install -e .
+
+      - name: Lint 🧹
+        run: ruff check
+
+      - name: Run tests 🧪
+        run: |
+          pip install --upgrade pip
+          pip install -r requirements/requirements-dev.txt
+          pip install -e .
+          coverage run -m pytest
+
+      - name: Upload coverage data 📤
+        uses: actions/upload-artifact@v4
+        with:
+          name: coverage
+          path: .coverage
+          include-hidden-files: true
+          if-no-files-found: ignore
+
+  coverage:
+    # https://hynek.me/articles/ditch-codecov-python/
+    name: Generate coverage report
+    runs-on: ubuntu-latest
+    needs: run-checks
+    if: always()
+
+    steps:
+      - name: Checkout 🛎️
+        uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+
+      - name: Set up Python 🐍
+        uses: actions/setup-python@v5
+        with:
+          cache: pip
+
+      - name: Install uv 🌟
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: ">=0.0.1"
+
+      - name: Download coverage data 📥
+        uses: actions/download-artifact@v4
+        with:
+          name: coverage
+          merge-multiple: true
+
+      - name: Generate coverage report 📊
+        run: |
+          uv tool install coverage
+          coverage html --skip-covered --skip-empty
+          coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
+
+          # Generate report again, this time with a fail-under threshold
+          coverage report --fail-under=80
+
+      - name: Upload HTML report if coverage check fails
+        uses: actions/upload-artifact@v4
+        with:
+            name: html-cov-report
+            path: htmlcov
+        if: ${{ failure() }}
+
+
diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml
new file mode 100644
index 0000000..7b71aa4
--- /dev/null
+++ b/.github/workflows/publish-pypi.yaml
@@ -0,0 +1,103 @@
+# .github/workflows/publish-pypi.yaml
+# uses trusted publishing to publish the package to PyPI and create a GitHub
+# release as described here:
+# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/
+name: Publish to PyPI and create GitHub release
+
+on:
+  push:
+    tags:
+      # only run workflow for tags in release format
+      - "v[0-9]+.[0-9]+.[0-9]+"
+
+jobs:
+  build:
+    name: Build distribution 📦
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout 🛎️
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Set up Python 🐍
+        uses: actions/setup-python@v5
+
+      - name: Install uv 🌟
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: ">=0.0.1"
+
+      - name: Build package for distribution 🛠️
+        run: |
+          uv build
+
+      - name: Upload distribution packages 📤
+        uses: actions/upload-artifact@v4
+        with:
+          name: cladetime-package-distribution
+          path: dist/
+
+  publish-to-pypi:
+    name: Publish Python distribution to PyPI
+    needs:
+    - build
+    runs-on: ubuntu-latest
+    environment:
+      name: pypi
+      url: https://pypi.org/p/cladetime
+    permissions:
+       id-token: write  # needed for trusted publishing (i.e., OIDC)
+
+    steps:
+    - name: Download distribution artifacts 📥
+      uses: actions/download-artifact@v4
+      with:
+        name: cladetime-package-distribution
+        path: dist/
+    - name: Publish distribution to PyPI 🚀
+      uses: pypa/gh-action-pypi-publish@release/v1
+
+  github-release:
+    name: >-
+      Sign the Python distribution with Sigstore
+      and upload them to GitHub Release
+    needs:
+    - publish-to-pypi
+    runs-on: ubuntu-latest
+
+    permissions:
+      contents: write  # required for creating GitHub Releases
+      id-token: write  # required for sigstore
+
+    steps:
+    - name: Download distribution artifacts 📥
+      uses: actions/download-artifact@v4
+      with:
+        name: cladetime-package-distribution
+        path: dist/
+    - name: Sign the dists with Sigstore 📝
+      uses: sigstore/gh-action-sigstore-python@v3.0.0
+      with:
+        inputs: >-
+          ./dist/*.tar.gz
+          ./dist/*.whl
+    - name: Create GitHub Release 🛠️
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      run: >-
+        gh release create
+        "$GITHUB_REF_NAME"
+        --repo "$GITHUB_REPOSITORY"
+    - name: Upload artifact signatures to GitHub Release 📤
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      # Upload to GitHub Release.
+      # `dist/` contains the built packages, and the
+      # sigstore-produced signatures and certificates.
+      run: >-
+        gh release upload
+        "$GITHUB_REF_NAME" dist/**
+        --repo "$GITHUB_REPOSITORY"
diff --git a/.github/workflows/run-code-checks.yaml b/.github/workflows/run-code-checks.yaml
deleted file mode 100644
index 5b38f17..0000000
--- a/.github/workflows/run-code-checks.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: run-code-checks
-
-on:
-  push:
-  workflow_dispatch:
-
-jobs:
-  run-checks:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout 🛎️
-        uses: actions/checkout@v4
-
-      - name: Set up Python 🐍
-        uses: actions/setup-python@v5
-        with:
-          cache: pip
-
-      - name: Install dependencies 📦️
-        run: |
-          pip install --upgrade pip
-          pip install -r requirements/requirements-dev.txt
-          pip install -e .
-
-      - name: Lint 🧹
-        run: ruff check
-
-      - name: Run unit tests 🧪
-        run: |
-          pytest -k unit
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 615f284..31cb549 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -46,7 +46,7 @@ will install the version of Python specified in
 [.python-version](.python-version).
 
     ```bash
-    uv venv
+    uv venv --seed
     ```
 
 5. Install the project's dependencies, including an editable copy of the
@@ -149,7 +149,7 @@ of waiting several minutes for the pull request to generate a preview.
 1. Install the documentation dependencies:
 
     ```bash
-    uv pip install -r requirements/requirements-doc.txt
+    uv pip install -r requirements/requirements-docs.txt
     ```
 
 2. Build the documentation:
diff --git a/README.md b/README.md
index bb61c51..8ace857 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+![cladetime CI status](https://github.com/reichlab/cladetime/actions/workflows/ci.yaml/badge.svg)
+
 # User Guide
 
 Cladetime is a wrapper around Nextstrain's GenBank-based SARS-CoV-2 genome
@@ -16,7 +18,7 @@ Cladetime is designed for use with US-based sequences from Homo sapiens.
 Cladetime is written in Python and can be installed using pip:
 
 ```bash
-pip install git+https://github.com/reichlab/cladetime.git
+pip install cladetime
 ```
 
 ## The CladeTime class
diff --git a/docs/index.rst b/docs/index.rst
index 5dd2433..d293ae6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -29,7 +29,7 @@ Cladetime can be installed with `pip <https://pip.pypa.io/>`_:
 
 .. code-block:: bash
 
-   $ pip install git+https://github.com/reichlab/cladetime.git
+   $ pip install cladetime
 
 Detailed documentation
 ----------------------
diff --git a/pyproject.toml b/pyproject.toml
index 65161ee..c605c8a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,21 +1,31 @@
 [project]
 name = "cladetime"
-version = "0.2.0"
-description = "Assign clades to viral genome sequences at a point in time."
+dynamic = ["version"]
+description = "Assign clades to SARS-CoV-2 genome sequences at a point in time."
 authors = [
+    {name = "Reich Lab @ University of Massachusetts", email = "nick@umass.edu"},
+]
+maintainers = [
+    {name = "Becky Sweger", email = "rsweger@umass.edu"},
     {name = "Evan Ray", email="elray@umass.edu"},
     {name = "Ben Rogers", email = "bwrogers@umass.edu"},
-    {name = "Becky Sweger", email = "rsweger@umass.edu"},
 ]
-
-requires-python = ">=3.11"
-readme = "README.md"
+license = {text = "MIT License"}
+keywords = ["biostatistics", "clade", "covid", "epidemiology", "genome", "sequence"]
 
 classifiers = [
-  "Development Status :: 3 - Alpha",
+  "Development Status :: 4 - Beta",
   "License :: OSI Approved :: MIT License",
+  "Natural Language :: English",
+  "Operating System :: OS Independent",
+  "Programming Language :: Python :: 3",
+  "Topic :: Scientific/Engineering",
+  "Topic :: Scientific/Engineering :: Bio-Informatics",
 ]
 
+requires-python = ">=3.11"
+readme = "README.md"
+
 dependencies = [
     "awscli>=1.32.92",
     "biopython",
@@ -59,7 +69,7 @@ docs = [
     "sphinx-github-style",
     "sphinxext-opengraph",
     "sphinx_toolbox",
-    ]
+]
 
 [project.urls]
 Repository = "https://github.com/reichlab/cladetime.git"
@@ -67,9 +77,16 @@ Documentation = "https://cladetime.readthedocs.io/"
 Issues = "https://github.com/reichlab/cladetime/issues"
 
 [build-system]
-requires = ["setuptools>=64", "wheel"]
+requires = ["setuptools>=64", "wheel", "setuptools-scm>=8"]
 build-backend = "setuptools.build_meta"
 
+[tool.setuptools.packages.find]
+namespaces = true
+where = ["src"]
+
+[tool.setuptools_scm]
+local_scheme = "no-local-version"
+
 [tool.pytest.ini_options]
 tmp_path_retention_policy = "none"
 filterwarnings = [
@@ -89,9 +106,11 @@ lint.extend-select = ["I"]
 # Ignore import formatting rules in `__init__.py`
 "__init__.py" = ["I001"]
 
-[tools.setuptools]
-packages = ["cladetime"]
-
 [tool.mypy]
-ignore_missing_imports = true
+ignore_missing_imports = false
+
+[tool.uv]
+# ensure setuptools_scm generates a version number that reflects latest tags
+# https://docs.astral.sh/uv/concepts/cache/#dynamic-metadata
+cache-keys = [{ git = { commit = true, tags = true } }]
 
diff --git a/src/cladetime/cladetime.py b/src/cladetime/cladetime.py
index 09efd3a..d38e6b1 100644
--- a/src/cladetime/cladetime.py
+++ b/src/cladetime/cladetime.py
@@ -32,15 +32,17 @@ class CladeTime:
         sequence metadata files that will be used by CladeTime
         properties and methods. Can be a datetime object or a
         string in YYYY-MM-DD format, both of which will be treated as
-        UTC. The default value is the current time.
+        UTC. The default value is the current UTC time. Dates passed
+        as YYYY-MM-DD strings will be set to 11:59:59 PM UTC.
     tree_as_of : datetime.datetime | str | None
         Sets the version of the Nextstrain reference tree that will be
         used by CladeTime. Can be a datetime object or a string in
         YYYY-MM-DD format, both of which will be treated as UTC.
         The default value is :any:`sequence_as_of<sequence_as_of>`,
         unless sequence_as_of is before reference tree availability
-        (2024-08-01), in which case tree_as_of will default to the
-        current time.
+        (2024-08-01), in which case tree_as_of will default to current
+        time UTC. Dates passed as YYYY-MM-DD strings will be
+        set to 11:59:59 PM UTC.
 
     Attributes
     ----------
diff --git a/src/cladetime/sequence.py b/src/cladetime/sequence.py
index e425b1a..534de4f 100644
--- a/src/cladetime/sequence.py
+++ b/src/cladetime/sequence.py
@@ -274,10 +274,10 @@ def filter_metadata(
 
     # Apply filters for min and max sequence collection date, if applicable
     if collection_min_date is not None:
-        collection_min_date = _get_date(collection_min_date)
+        collection_min_date = _get_date(collection_min_date).replace(hour=0, minute=0, second=0)
         filtered_metadata = filtered_metadata.filter(pl.col("date") >= collection_min_date)
     if collection_max_date is not None:
-        collection_max_date = _get_date(collection_max_date)
+        collection_max_date = _get_date(collection_max_date).replace(hour=0, minute=0, second=0)
         filtered_metadata = filtered_metadata.filter(pl.col("date") <= collection_max_date)
 
     # Create state mappings based on state_format parameter, including a DC alias, since
diff --git a/src/cladetime/util/reference.py b/src/cladetime/util/reference.py
index e1b6ea8..5369cbf 100644
--- a/src/cladetime/util/reference.py
+++ b/src/cladetime/util/reference.py
@@ -28,7 +28,11 @@ def _get_date(original_date: datetime | str | None) -> datetime:
         new_date = original_date.replace(tzinfo=timezone.utc)
     elif isinstance(original_date, str):
         try:
-            new_date = datetime.strptime(original_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
+            new_date = (
+                datetime.strptime(original_date, "%Y-%m-%d")
+                .replace(hour=11, minute=59, second=59)
+                .replace(tzinfo=timezone.utc)
+            )
         except ValueError as e:
             raise ValueError(f"Invalid date format: {original_date}") from e
 


Test patch:
diff --git a/.github/workflows/publish-pypi-test.yaml b/.github/workflows/publish-pypi-test.yaml
new file mode 100644
index 0000000..38b95ec
--- /dev/null
+++ b/.github/workflows/publish-pypi-test.yaml
@@ -0,0 +1,61 @@
+# .github/workflows/publish-pypi-test.yaml
+# uses trusted publishing to publish the package to TestPyPI as described here:
+# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/
+name: Publish to Test PyPI
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  build:
+    name: Build distribution 📦
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout 🛎️
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Set up Python 🐍
+        uses: actions/setup-python@v5
+
+      - name: Install uv 🌟
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: ">=0.0.1"
+
+      - name: Build package for distribution 🛠️
+        run: |
+          uv build
+
+      - name: Upload distribution packages 📤
+        uses: actions/upload-artifact@v4
+        with:
+          name: cladetime-package-distribution
+          path: dist/
+
+  publish-to-testpypi:
+    name: Publish Python distribution to TestPyPI
+    needs:
+    - build
+    runs-on: ubuntu-latest
+    environment:
+      name: pypi-test
+      url: https://test.pypi.org/p/cladetime
+    permissions:
+       id-token: write  # needed for trusted publishing (i.e., OIDC)
+
+    steps:
+    - name: Download distribution artifacts 📥
+      uses: actions/download-artifact@v4
+      with:
+        name: cladetime-package-distribution
+        path: dist/
+    - name: Publish distribution to TestPyPI 🚀
+      uses: pypa/gh-action-pypi-publish@release/v1
+      with:
+        repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/run-integration-tests.yaml b/.github/workflows/run-integration-tests.yaml
deleted file mode 100644
index e4c7ce5..0000000
--- a/.github/workflows/run-integration-tests.yaml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: run-integration-tests
-
-on:
-  push:
-    branches:
-      - main
-  pull_request:
-    types: [opened, ready_for_review, reopened]
-  workflow_dispatch:
-
-jobs:
-  run-checks:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout 🛎️
-        uses: actions/checkout@v4
-
-      - name: Set up Python 🐍
-        uses: actions/setup-python@v5
-        with:
-          cache: pip
-
-      - name: Install dependencies 📦️
-        run: |
-          pip install --upgrade pip
-          pip install -r requirements/requirements-dev.txt
-          pip install -e .
-
-      - name: Run integration tests 🧪
-        run: pytest -k integration
diff --git a/tests/integration/test_cladetime_integration.py b/tests/integration/test_cladetime_integration.py
index 4551a97..4262207 100644
--- a/tests/integration/test_cladetime_integration.py
+++ b/tests/integration/test_cladetime_integration.py
@@ -109,9 +109,9 @@ def test_assign_old_tree(test_file_path, tmp_path, test_sequences):
         expected_summary, old_assigned_clades.summary.collect(), check_column_order=False, check_row_order=False
     )
 
-    # metadata should reflect ncov metadata as of 2024-11-01
     assert old_assigned_clades.meta.get("sequence_as_of") == datetime(2024, 11, 1, tzinfo=timezone.utc)
-    assert old_assigned_clades.meta.get("tree_as_of") == datetime(2024, 8, 2, tzinfo=timezone.utc)
+    assert old_assigned_clades.meta.get("tree_as_of") == datetime(2024, 8, 2, 11, 59, 59, tzinfo=timezone.utc)
+    # nextclade metadata should reflect its state on tree_as_of (2024-08-02)
     assert old_assigned_clades.meta.get("nextclade_dataset_version") == "2024-07-17--12-57-03Z"
     assert old_assigned_clades.meta.get("nextclade_version_num") == "3.8.2"
     assert old_assigned_clades.meta.get("assignment_as_of") == "2024-11-01 00:00"
diff --git a/tests/unit/test_cladetime.py b/tests/unit/test_cladetime.py
index 08289c1..aba6e81 100644
--- a/tests/unit/test_cladetime.py
+++ b/tests/unit/test_cladetime.py
@@ -26,8 +26,8 @@ def test_cladetime_no_args():
             # (metadata for reference trees started publishing in Aug, 2024)
             "2024-09-01",
             "2024-01-01",
-            datetime(2024, 9, 1, tzinfo=timezone.utc),
-            datetime(2024, 9, 1, tzinfo=timezone.utc),
+            datetime(2024, 9, 1, 11, 59, 59, tzinfo=timezone.utc),
+            datetime(2024, 9, 1, 11, 59, 59, tzinfo=timezone.utc),
         ),
         (
             # sequence_as_of set to current date, tree_as_of defaults to sequence_as_of
@@ -41,7 +41,7 @@ def test_cladetime_no_args():
             None,
             "2024-09-01",
             datetime(2025, 7, 13, 16, 21, 34, tzinfo=timezone.utc),
-            datetime(2024, 9, 1, tzinfo=timezone.utc),
+            datetime(2024, 9, 1, 11, 59, 59, tzinfo=timezone.utc),
         ),
         (
             # tree_as_of set to sequence_as_of
@@ -62,7 +62,7 @@ def test_cladetime_no_args():
             # defaults to current date
             "2023-12-21",
             None,
-            datetime(2023, 12, 21, tzinfo=timezone.utc),
+            datetime(2023, 12, 21, 11, 59, 59, tzinfo=timezone.utc),
             datetime(2025, 7, 13, 16, 21, 34, tzinfo=timezone.utc),
         ),
         (
@@ -85,7 +85,7 @@ def test_cladetime_no_args():
             # 2024-08-01, so it should revert to current date
             "2023-07-13",
             "2074-07",
-            datetime(2023, 7, 13, tzinfo=timezone.utc),
+            datetime(2023, 7, 13, 11, 59, 59, tzinfo=timezone.utc),
             datetime(2025, 7, 13, 16, 21, 34, tzinfo=timezone.utc),
         ),
     ],