diff --git a/simple_repository_browser/fetch_projects.py b/simple_repository_browser/fetch_projects.py index 8e2ebb8..cfbde7b 100644 --- a/simple_repository_browser/fetch_projects.py +++ b/simple_repository_browser/fetch_projects.py @@ -32,6 +32,11 @@ def remove_if_found(connection, canonical_name): def update_summary( conn, name: str, summary: str, release_date: datetime.datetime, release_version: str ): + # Strip timezone info before storing in SQLite to avoid converter issues. + # We always store naive datetimes which represent UTC. + if release_date.tzinfo is not None: + release_date = release_date.replace(tzinfo=None) + with conn as cursor: cursor.execute( """ diff --git a/simple_repository_browser/model.py b/simple_repository_browser/model.py index 9c56deb..1192707 100644 --- a/simple_repository_browser/model.py +++ b/simple_repository_browser/model.py @@ -24,6 +24,25 @@ class SearchResultItem: release_version: str | None = None release_date: datetime.datetime | None = None + @classmethod + def from_db_row(cls, row: sqlite3.Row) -> "SearchResultItem": + """ + Convert a database row to SearchResultItem with timezone handling. + + Naive datetimes from SQLite are interpreted as UTC. We store naive datetimes + to avoid SQLite converter issues (issue #27), but always work with UTC-aware + datetimes in the application. + """ + row_dict = dict(row) + if ( + row_dict["release_date"] is not None + and row_dict["release_date"].tzinfo is None + ): + row_dict["release_date"] = row_dict["release_date"].replace( + tzinfo=datetime.timezone.utc + ) + return cls(**row_dict) + class RepositoryStatsModel(typing.TypedDict): n_packages: int @@ -154,7 +173,7 @@ async def project_query( results = cursor.execute(sql_query, params).fetchall() # Convert results to SearchResultItem objects - results = [SearchResultItem(*result) for result in results] + results = [SearchResultItem.from_db_row(row) for row in results] # If the search was for a specific name, then make sure we return it if # it is in the package repository. diff --git a/simple_repository_browser/tests/test_model.py b/simple_repository_browser/tests/test_model.py new file mode 100644 index 0000000..27d2301 --- /dev/null +++ b/simple_repository_browser/tests/test_model.py @@ -0,0 +1,43 @@ +"""Tests for model timezone handling.""" + +from datetime import datetime, timezone +from pathlib import Path +import sqlite3 +import tempfile + +from simple_repository_browser import fetch_projects, model + + +def test_SearchResultItem__from_db_row__converts_naive_to_utc(): + """Verify SearchResultItem.from_db_row() converts naive datetimes to UTC.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.db" + con = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + con.row_factory = sqlite3.Row + fetch_projects.create_table(con) + + # Insert with naive datetime (simulating what update_summary stores) + naive_dt = datetime(2023, 1, 15, 12, 30, 0) + con.execute( + "INSERT INTO projects(canonical_name, preferred_name, summary, release_version, release_date) VALUES (?, ?, ?, ?, ?)", + ("test-pkg", "Test-Pkg", "A test package", "1.0.0", naive_dt), + ) + + # Read back using from_db_row + cursor = con.execute( + "SELECT canonical_name, summary, release_version, release_date FROM projects WHERE canonical_name = ?", + ("test-pkg",), + ) + row = cursor.fetchone() + result = model.SearchResultItem.from_db_row(row) + + # Should have UTC timezone + assert result.canonical_name == "test-pkg" + assert result.summary == "A test package" + assert result.release_version == "1.0.0" + assert result.release_date == datetime( + 2023, 1, 15, 12, 30, 0, tzinfo=timezone.utc + ) + assert result.release_date.tzinfo is not None + + con.close() diff --git a/simple_repository_browser/tests/test_search.py b/simple_repository_browser/tests/test_search.py index 81954ac..b74b4fa 100644 --- a/simple_repository_browser/tests/test_search.py +++ b/simple_repository_browser/tests/test_search.py @@ -1,3 +1,4 @@ +from datetime import datetime from pathlib import Path import sqlite3 import tempfile @@ -198,7 +199,7 @@ async def get_project_page(self, name: str): def test_database(tmp_path: Path): """Create a temporary SQLite database with test data for search ordering.""" db_path = tmp_path / "test.db" - con = sqlite3.connect(db_path) + con = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) con.row_factory = sqlite3.Row # Create projects table matching the real schema @@ -207,7 +208,7 @@ def test_database(tmp_path: Path): canonical_name TEXT PRIMARY KEY, summary TEXT, release_version TEXT, - release_date TEXT + release_date timestamp ) """) @@ -236,10 +237,12 @@ def test_database(tmp_path: Path): ("requests", "HTTP library", "2.28.0", "2022-12-01"), ] - for name, summary, version, date in test_projects: + for name, summary, version, date_str in test_projects: + # Convert date strings to naive datetime objects (representing UTC) + release_date = datetime.strptime(date_str, "%Y-%m-%d") con.execute( "INSERT INTO projects (canonical_name, summary, release_version, release_date) VALUES (?, ?, ?, ?)", - (name, summary, version, date), + (name, summary, version, release_date), ) con.commit()