Skip to content

Commit f523689

Browse files
committed
Extract palace.util into a uv workspace package
Convert the repo into a uv workspace and carve out a new namespace package at packages/palace-util/ (namespace palace.util) holding the small set of cross-cutting utilities that are genuinely reusable: - palace.util.exceptions: BasePalaceException, PalaceValueError, PalaceTypeError. - palace.util.datetime_helpers: the full timezone-aware datetime helpers module (utc_now, to_utc, datetime_utc, from_timestamp, minute_timestamp, previous_months, strptime_utc). - palace.util.log: LoggerMixin, LoggerType, LoggerAdapterType, logger_for_cls. palace.manager.{core.exceptions, util.datetime_helpers, util.log} are kept as thin re-export shims so the ~316 existing callers across the codebase keep working unchanged. palace.manager.util.log retains the palace-manager-specific helpers (log_elapsed_time, elapsed_time_logging, pluralize, ExtraDataLoggerAdapter) which depend on palace-manager internals like LogLevel. Tooling: - pyproject.toml: [tool.uv.workspace]/[tool.uv.sources], palace-util added to [project.dependencies], mypy files/mypy_path, coverage source, pytest testpaths extended. tests-mypy override now lists "tests.*" and "test_datetime_helpers". - tox.ini: pytest uses testpaths from pyproject.toml. - docker/Dockerfile, docker/Dockerfile.baseimage: copy workspace member pyproject.toml in the cache layer; switch cache-stage sync to --no-install-workspace; clean the packages/ dir at the end of the base-image RUN. - .pre-commit-config.yaml: namespace-package check now matches any package's src/palace/__init__.py. - README.md: new "Repository Layout" section linking to packages/palace-util/README.md. - CLAUDE.md: project-structure section reflects the workspace and documents the re-export shims.
1 parent 57475b4 commit f523689

20 files changed

Lines changed: 529 additions & 372 deletions

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ repos:
7676
- id: check-namespace-package
7777
name: Check that palace is a namespace package
7878
language: fail
79-
entry: Please remove src/palace/__init__.py
80-
files: ^src/palace/__init__.py$
79+
entry: Please remove src/palace/__init__.py - palace must remain a PEP 420 namespace.
80+
files: (^|/)src/palace/__init__\.py$
8181

8282
# Exclude test files, since they may be intentionally malformed
8383
exclude: ^tests/files/

CLAUDE.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ through mobile and web applications.
4949

5050
## Project Structure
5151

52-
- `/src/palace/manager` - Main application source code
52+
This repository is a [`uv` workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/). The main
53+
`palace-manager` application is at the repo root; reusable namespace packages live under `/packages`.
54+
55+
- `/src/palace/manager` - Main application source code (`palace-manager`)
5356
- `/api` - REST API endpoints for mobile applications
5457
- `/admin` - Administrative API endpoints for the web dashboard
5558
- `/celery` - Background worker processes and task definitions
@@ -64,12 +67,20 @@ through mobile and web applications.
6467
- `/service` - Dependency injection container and service layer
6568
- `/sqlalchemy` - Database models and schema definitions
6669
- `/util` - Reusable utility functions and helpers
67-
- `/tests` - Test files
70+
- `/packages` - Workspace member packages (each is independently buildable/publishable)
71+
- `/palace-util` - Shared utilities under the `palace.util` namespace: exceptions
72+
(`BasePalaceException`, `PalaceValueError`, `PalaceTypeError`), datetime helpers, `LoggerMixin`.
73+
`palace.manager.util.{datetime_helpers,log}` and `palace.manager.core.exceptions` re-export from
74+
here for backward compatibility — new code can import from either path.
75+
- `/tests` - Test files for `palace-manager`
6876
- `/files` - Test fixture files
6977
- `/fixtures` - pytest fixtures shared across test functions
7078
- `/manager` - pytest test files (should mirror `src/palace/manager` structure)
7179
- `/migration` - Database migration tests
7280
- `/mocks` - Test mocks (**being phased out - avoid in new code**)
81+
- Each workspace package owns its own `tests/` directory under `packages/<name>/tests/`. The full
82+
pytest suite (configured via `testpaths` in `pyproject.toml`) runs them all together so
83+
root-conftest plugins remain available to package tests.
7384

7485
## Architecture Overview
7586

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ Palace Manager is a backend service for digital library systems, maintained by [
1515
- [Patron Blocking Rules — Allowed Functions](docs/FUNCTIONS.md)
1616
Reference for the functions available in patron blocking rule expressions.
1717

18+
## Repository Layout
19+
20+
This repository is a [`uv` workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/). The main
21+
`palace-manager` application lives at the repo root (`src/palace/manager/`). Reusable namespace packages
22+
live under `packages/`:
23+
24+
- [`palace-util`](packages/palace-util/README.md) — shared utilities (exceptions, datetime helpers,
25+
`LoggerMixin`) under the `palace.util` namespace.
26+
1827
## Installation
1928

2029
Docker images created from this code are available at:

docker/Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ COPY --chmod=644 docker/services/logrotate /etc/
1818
RUN mv /etc/cron.daily/logrotate /etc/cron.hourly/logrotate
1919

2020
# Copy our uv files into the image and install our dependencies.
21+
# We copy the workspace-member pyproject.toml files (but not their source) so that
22+
# uv can resolve workspace members without invalidating this layer on source changes.
2123
COPY --chown=palace:palace uv.lock pyproject.toml /var/www/circulation/
22-
RUN uv sync --frozen --no-dev --no-install-project
24+
COPY --chown=palace:palace packages/palace-util/pyproject.toml /var/www/circulation/packages/palace-util/
25+
RUN uv sync --frozen --no-dev --no-install-workspace
2326

2427
COPY --chown=palace:palace . /var/www/circulation
2528

docker/Dockerfile.baseimage

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,17 @@ RUN useradd -ms /bin/bash -U palace && \
6060

6161
WORKDIR /var/www/circulation
6262
COPY --chown=palace:palace uv.lock pyproject.toml /var/www/circulation/
63+
# Workspace-member pyproject.toml files are needed for uv sync to resolve the
64+
# workspace; we copy just the manifests (not the source) so this layer doesn't
65+
# get invalidated by source changes.
66+
COPY --chown=palace:palace packages/palace-util/pyproject.toml /var/www/circulation/packages/palace-util/
6367

6468
# Setup virtualenv and install our python dependencies.
6569
# What we install is based on the uv.lock file in the repo at the time this
6670
# image is built. These may get out of date, but we always rerun this step when
6771
# building the final image, so it will be up to date then. This gives is a base
6872
# to work from which speeds up the final image build.
69-
RUN uv sync --frozen --no-dev --no-install-project && \
73+
RUN uv sync --frozen --no-dev --no-install-workspace && \
7074
SIMPLIFIED_ENVIRONMENT=/var/www/circulation/environment.sh && \
7175
echo "if [ -f $SIMPLIFIED_ENVIRONMENT ]; then source $SIMPLIFIED_ENVIRONMENT; fi" >> env/bin/activate && \
7276
. env/bin/activate && \
@@ -76,5 +80,6 @@ RUN uv sync --frozen --no-dev --no-install-project && \
7680
rm -Rf /root/.cache && \
7781
rm pyproject.toml && \
7882
rm uv.lock && \
83+
rm -rf packages && \
7984
rm -rf /root/.cache && \
8085
/bd_build/cleanup.sh

packages/palace-util/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# palace-util
2+
3+
Shared utilities for Palace Project packages. Exposes the `palace.util` namespace module.
4+
5+
Contents:
6+
7+
- `palace.util.exceptions``BasePalaceException`, `PalaceValueError`, `PalaceTypeError`.
8+
- `palace.util.datetime_helpers` — timezone-aware datetime helpers.
9+
- `palace.util.log``LoggerMixin` and related logging helpers.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[build-system]
2+
build-backend = "hatchling.build"
3+
requires = ["hatchling"]
4+
5+
[project]
6+
authors = [{name = "The Palace Project", email = "info@thepalaceproject.org"}]
7+
dependencies = [
8+
"python-dateutil>=2.8,<3",
9+
]
10+
description = "Shared utilities for Palace Project packages."
11+
license = "Apache-2.0"
12+
name = "palace-util"
13+
readme = "README.md"
14+
requires-python = ">=3.12,<4"
15+
version = "0"
16+
17+
[project.urls]
18+
Homepage = "https://thepalaceproject.org"
19+
Repository = "https://github.com/ThePalaceProject/circulation"
20+
21+
[tool.hatch.build.targets.wheel]
22+
packages = ["src/palace"]

packages/palace-util/src/palace/util/__init__.py

Whitespace-only changes.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import datetime
2+
from collections.abc import Callable
3+
from functools import wraps
4+
from typing import overload
5+
6+
from dateutil.relativedelta import relativedelta
7+
8+
9+
def _wrapper[T, **P](func: Callable[P, T]) -> Callable[P, T]:
10+
@wraps(func)
11+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
12+
kwargs["tzinfo"] = datetime.UTC
13+
return func(*args, **kwargs)
14+
15+
return wrapper
16+
17+
18+
datetime_utc = _wrapper(datetime.datetime)
19+
"""
20+
Return a datetime object but with UTC information.
21+
"""
22+
23+
24+
def from_timestamp(ts: float) -> datetime.datetime:
25+
"""Return a UTC datetime object from a timestamp.
26+
27+
:return: datetime object
28+
"""
29+
return datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
30+
31+
32+
def utc_now() -> datetime.datetime:
33+
"""Get the current time in UTC.
34+
35+
:return: datetime object
36+
"""
37+
return datetime.datetime.now(tz=datetime.UTC)
38+
39+
40+
@overload
41+
def to_utc(dt: datetime.datetime) -> datetime.datetime: ...
42+
43+
44+
@overload
45+
def to_utc(dt: datetime.datetime | None) -> datetime.datetime | None: ...
46+
47+
48+
def to_utc(dt: datetime.datetime | None) -> datetime.datetime | None:
49+
"""This converts a naive datetime object that represents UTC into
50+
an aware datetime object.
51+
52+
:return: datetime object, or None if `dt` was None.
53+
"""
54+
if dt is None:
55+
return None
56+
if dt.tzinfo is None:
57+
return dt.replace(tzinfo=datetime.UTC)
58+
if dt.tzinfo == datetime.UTC:
59+
# Already UTC.
60+
return dt
61+
return dt.astimezone(datetime.UTC)
62+
63+
64+
def strptime_utc(date_string: str, format: str) -> datetime.datetime:
65+
"""Parse a string that describes a time but includes no timezone,
66+
into a timezone-aware datetime object set to UTC.
67+
68+
:raise ValueError: If `format` expects timezone information to be
69+
present in `date_string`.
70+
"""
71+
if "%Z" in format or "%z" in format:
72+
raise ValueError(f"Cannot use strptime_utc with timezone-aware format {format}")
73+
return to_utc(datetime.datetime.strptime(date_string, format))
74+
75+
76+
def previous_months(number_of_months: int) -> tuple[datetime.date, datetime.date]:
77+
"""Calculate date boundaries for matching the specified previous number of months.
78+
79+
:param number_of_months: The number of months in the interval.
80+
:returns: Date interval boundaries, consisting of a 2-tuple of
81+
`start` and `until` dates.
82+
83+
These boundaries should be used such that matching dates are on the
84+
half-closed/half-open interval `[start, until)` (i.e., start <= match < until).
85+
Only dates/datetimes greater than or equal to `start` and less than
86+
(NOT less than or equal to) `until` should be considered as matching.
87+
88+
`start` will be the first day of the designated month.
89+
`until` will be the first day of the current month.
90+
"""
91+
now = utc_now()
92+
start = now - relativedelta(months=number_of_months)
93+
start = start.replace(day=1)
94+
until = now.replace(day=1)
95+
return start.date(), until.date()
96+
97+
98+
def minute_timestamp(dt: datetime.datetime) -> datetime.datetime:
99+
"""Minute resolution timestamp by truncating the seconds from a datetime object.
100+
101+
:param dt: datetime object with seconds resolution
102+
:return: datetime object with minute resolution
103+
"""
104+
return datetime.datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Any
2+
3+
4+
class BasePalaceException(Exception):
5+
"""Base class for all Exceptions in Palace packages."""
6+
7+
def __init__(self, message: str | None = None):
8+
"""Initializes a new instance of BasePalaceException class
9+
10+
:param message: String containing description of the exception that occurred
11+
"""
12+
super().__init__(message)
13+
self.message = message
14+
15+
def __getstate__(self) -> dict[str, Any]:
16+
return {"dict": self.__dict__, "args": self.args}
17+
18+
def __setstate__(self, state: dict[str, Any] | None) -> None:
19+
# The signature must accept None to match BaseException.__setstate__ for mypy.
20+
# In practice, state is always a dict from our __getstate__ implementation.
21+
assert (
22+
state is not None
23+
), "__setstate__ received None; expected dict from __getstate__"
24+
self.__dict__.update(state["dict"])
25+
self.args = state["args"]
26+
27+
def __reduce__(self) -> tuple[Any, ...]:
28+
state = self.__getstate__()
29+
return self.__class__.__new__, (self.__class__,), state
30+
31+
32+
class PalaceValueError(BasePalaceException, ValueError): ...
33+
34+
35+
class PalaceTypeError(BasePalaceException, TypeError): ...

0 commit comments

Comments
 (0)