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