Skip to main content

Merge pull request #1 from alexwlchan/timestamps

ID
9d6790f
date
2025-11-28 21:20:55+00:00
author
Alex Chan <alex@alexwlchan.net>
parents
acfe798, 44fe747
message
Merge pull request #1 from alexwlchan/timestamps

Add all my code for dealing with timestamps
changed files
7 files, 204 additions, 10 deletions

Changed files

LICENSE (1053) → LICENSE (1053)

diff --git a/LICENSE b/LICENSE
index b3d4b83..ddc366e 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2024 Alex Chan
+Copyright (c) 2025 Alex Chan
 
 Permission is hereby granted, free of charge, to any person obtaining a
 copy of this software and associated documentation files (the "Software"),

README.md (9) → README.md (1550)

diff --git a/README.md b/README.md
index f2d2b91..5570e53 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,52 @@
 # chives
+
+chives is a collection of Python functions for working with my local
+media archives.
+
+I store a lot of media archives as [static websites][static-sites], and I use Python scripts to manage the sites.
+This includes:
+
+*   Verifying every file that's described in the metadata is stored correctly
+*   Downloading pages from sites I want to bookmark
+*   Checking the quality and consistency of my metadata
+
+This package has some functions I share across multiple archives/sites.
+
+[static-sites]: https://alexwlchan.net/2024/static-websites/
+
+## References
+
+I've written blog posts about some of the code in this repo:
+
+*   [Cleaning up messy dates in JSON](https://alexwlchan.net/2025/messy-dates-in-json/)
+
+## Versioning
+
+This library is monotically versioned.
+I'll try not to break anything between releases, but I make no guarantees of back-compatibility.
+
+I'm making this public because it's convenient for me, and you might find useful code here, but be aware this may not be entirely stable.
+
+## Usage
+
+All the functions are available in the `chives` namespace.
+
+See the docstrings on individual functions for usage descriptions.
+
+## Installation
+
+If you want to use this in your project, I recommend copying the relevant function and test into your codebase (with a link back to this repo).
+
+Alternatively, you can install the package from PyPI:
+
+```console
+$ pip install alexwlchan-chives
+```
+
+## Development
+
+If you want to make changes to the library, there are instructions in [CONTRIBUTING.md](./CONTRIBUTING.md).
+
+## License
+
+MIT.

pyproject.toml (1194) → pyproject.toml (1189)

diff --git a/pyproject.toml b/pyproject.toml
index 6204fa6..df0d438 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ maintainers = [
   {name = "Alex Chan", email="alex@alexwlchan.net"},
 ]
 classifiers = [
-  "Development Status :: 5 - Production/Stable",
+  "Development Status :: 4 - Beta",
   "Programming Language :: Python :: 3.13",
 ]
 requires-python = ">=3.13"
@@ -54,4 +54,4 @@ strict = true
 
 [tool.ruff.lint]
 select = ["D"]
-ignore = ["D200", "D203", "D204", "D205", "D212"]
+ignore = ["D200", "D203", "D204", "D205", "D212", "D401"]

src/chives/__init__.py (390) → src/chives/__init__.py (628)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index cec1174..3ac12bf 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,18 @@ I share across multiple sites.
 
 """
 
-__version__ = "0"
+from .timestamps import (
+    find_all_dates,
+    date_matches_format,
+    date_matches_any_format,
+    reformat_date,
+)
+
+__version__ = "1"
+
+__all__ = [
+    "date_matches_any_format",
+    "date_matches_format",
+    "find_all_dates",
+    "reformat_date",
+]

src/chives/timestamps.py (0) → src/chives/timestamps.py (2122)

diff --git a/src/chives/timestamps.py b/src/chives/timestamps.py
new file mode 100644
index 0000000..272d651
--- /dev/null
+++ b/src/chives/timestamps.py
@@ -0,0 +1,73 @@
+"""
+Functions for interacting with timestamps and date strings.
+
+References:
+* https://alexwlchan.net/2025/messy-dates-in-json/
+
+"""
+
+from collections.abc import Iterable, Iterator
+from datetime import datetime, timezone
+from typing import Any
+
+
+def find_all_dates(json_value: Any) -> Iterator[tuple[dict[str, Any], str, str]]:
+    """
+    Find all the timestamps in a heavily nested JSON object.
+
+    This function looks for any JSON objects with a key-value pair
+    where the key starts with `date_` and the value is a string, and
+    emits a 3-tuple:
+
+    *   the JSON object
+    *   the key
+    *   the value
+
+    """
+    if isinstance(json_value, dict):
+        for key, value in json_value.items():
+            if (
+                isinstance(key, str)
+                and key.startswith("date_")
+                and isinstance(value, str)
+            ):
+                yield json_value, key, value
+            else:
+                yield from find_all_dates(value)
+    elif isinstance(json_value, list):
+        for value in json_value:
+            yield from find_all_dates(value)
+
+
+def date_matches_format(date_string: str, format: str) -> bool:
+    """
+    Returns True if `date_string` can be parsed as a datetime
+    using `format`, False otherwise.
+    """
+    try:
+        datetime.strptime(date_string, format)
+        return True
+    except ValueError:
+        return False
+
+
+def date_matches_any_format(date_string: str, formats: Iterable[str]) -> bool:
+    """
+    Returns True if `date_string` can be parsed as a datetime
+    with any of the `formats`, False otherwise.
+    """
+    return any(date_matches_format(date_string, fmt) for fmt in formats)
+
+
+def reformat_date(s: str, /, orig_fmt: str) -> str:
+    """
+    Reformat a date to one of my desired formats.
+    """
+    if "%Z" in orig_fmt:
+        d = datetime.strptime(s, orig_fmt)
+    else:
+        d = datetime.strptime(s.replace("Z", "+0000"), orig_fmt.replace("Z", "%z"))
+    d = d.replace(microsecond=0)
+    if d.tzinfo is None:
+        d = d.replace(tzinfo=timezone.utc)
+    return d.strftime("%Y-%m-%dT%H:%M:%S%z").replace("+0000", "Z")

tests/test_timestamps.py (0) → tests/test_timestamps.py (2020)

diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py
new file mode 100644
index 0000000..8117acf
--- /dev/null
+++ b/tests/test_timestamps.py
@@ -0,0 +1,62 @@
+"""Tests for `chives.timestamps`."""
+
+import json
+
+import pytest
+
+from chives import date_matches_any_format, find_all_dates, reformat_date
+
+
+def test_find_all_dates() -> None:
+    """find_all_dates finds all the nested dates in a JSON object."""
+    json_value = json.loads("""{
+      "doc1": {"id": "1", "date_created": "2025-10-14T05:34:07+0000"},
+      "shapes": [
+      	{"color": "blue", "date_saved": "2015-03-01 23:34:39 +00:00"},
+      	{"color": "yellow", "date_saved": "2013-9-21 13:43:00Z", "is_square": true},
+      	{"color": "green", "date_saved": null}
+      ],
+      "date_verified": "2024-08-30"
+    }""")
+
+    assert list(find_all_dates(json_value)) == [
+        (
+            {"id": "1", "date_created": "2025-10-14T05:34:07+0000"},
+            "date_created",
+            "2025-10-14T05:34:07+0000",
+        ),
+        (
+            {"color": "blue", "date_saved": "2015-03-01 23:34:39 +00:00"},
+            "date_saved",
+            "2015-03-01 23:34:39 +00:00",
+        ),
+        (
+            {"color": "yellow", "date_saved": "2013-9-21 13:43:00Z", "is_square": True},
+            "date_saved",
+            "2013-9-21 13:43:00Z",
+        ),
+        (json_value, "date_verified", "2024-08-30"),
+    ]
+
+
+def test_date_matches_any_format() -> None:
+    """
+    Tests for `date_matches_any_format`.
+    """
+    assert date_matches_any_format(
+        "2001-01-01", formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S%z"]
+    )
+    assert not date_matches_any_format("2001-01-01", formats=["%Y-%m-%dT%H:%M:%S%z"])
+
+
+@pytest.mark.parametrize(
+    "s, orig_fmt, formatted_date",
+    [
+        ("2025-11-12T15:34:39.570Z", "%Y-%m-%dT%H:%M:%S.%fZ", "2025-11-12T15:34:39Z"),
+        ("2025-03-12 09:57:03", "%Y-%m-%d %H:%M:%S", "2025-03-12T09:57:03Z"),
+        ("2016-02-25 05:28:35 GMT", "%Y-%m-%d %H:%M:%S %Z", "2016-02-25T05:28:35Z"),
+    ],
+)
+def test_reformat_date(s: str, orig_fmt: str, formatted_date: str) -> None:
+    """Tests for `reformat_date`."""
+    assert reformat_date(s, orig_fmt) == formatted_date

tests/test_truth.py (107) → tests/test_truth.py (0)

diff --git a/tests/test_truth.py b/tests/test_truth.py
deleted file mode 100644
index 2c10529..0000000
--- a/tests/test_truth.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Tests for `chives`."""
-
-
-def test_truth() -> None:
-    """Basic test to exercise CI."""
-    assert True