feat(api): add Snapshot.created and type NodeFile timestamps as date-time

This commit is contained in:
Mark Paronyan 2026-10-07 10:38:07 +00:00
parent 2fb7056cb9
commit ead44787f3
No known key found for this signature in database
GPG Key ID: 8DC7338A87FE22F8
4 changed files with 38 additions and 8 deletions

View File

@ -480,11 +480,13 @@ class Project:
stat_info = await wait_run_in_executor(lambda e=entry: e.stat()) stat_info = await wait_run_in_executor(lambda e=entry: e.stat())
is_dir = await wait_run_in_executor(lambda e=entry: e.is_dir()) is_dir = await wait_run_in_executor(lambda e=entry: e.is_dir())
if is_dir: if is_dir:
created_at: str | None = None
modified_at: str | None = None
try: try:
created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat()
modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat()
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
created_at = modified_at = "" created_at = modified_at = None
files.append( files.append(
{ {
"path": rel_path, "path": rel_path,
@ -506,7 +508,7 @@ class Project:
created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat()
modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat()
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
created_at = modified_at = "" created_at = modified_at = None
files.append( files.append(
{ {
"path": rel_path, "path": rel_path,
@ -537,7 +539,7 @@ class Project:
created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat()
modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat()
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
created_at = modified_at = "" created_at = modified_at = None
files.append( files.append(
{ {
"path": os.path.relpath(dir_full_path, base_path), "path": os.path.relpath(dir_full_path, base_path),
@ -567,7 +569,7 @@ class Project:
modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat()
except (OSError, OverflowError, ValueError) as e: except (OSError, OverflowError, ValueError) as e:
log.warning(f"Invalid timestamp for '{rel_path}': {e}") log.warning(f"Invalid timestamp for '{rel_path}': {e}")
created_at = modified_at = "" created_at = modified_at = None
files.append( files.append(
{ {
"path": rel_path, "path": rel_path,

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
@ -111,8 +112,8 @@ class NodeFile(BaseModel):
path: str = Field(..., description="File name") path: str = Field(..., description="File name")
size: int = Field(..., description="File size in bytes") size: int = Field(..., description="File size in bytes")
created_at: str = Field(..., description="File creation time (ISO 8601)") created_at: Optional[datetime] = Field(None, description="File creation time (ISO 8601)")
modified_at: str = Field(..., description="File modification time (ISO 8601)") modified_at: Optional[datetime] = Field(None, description="File modification time (ISO 8601)")
file_type: str = Field(..., description="File type determined by the file command") file_type: str = Field(..., description="File type determined by the file command")

View File

@ -15,10 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class SnapshotBase(BaseModel): class SnapshotBase(BaseModel):
@ -44,4 +45,14 @@ class Snapshot(SnapshotBase):
name: str = Field(..., description="Name of the snapshot") name: str = Field(..., description="Name of the snapshot")
filename: str = Field(..., description="Filename of the snapshot") filename: str = Field(..., description="Filename of the snapshot")
description: str = Field(..., description="Description of the snapshot") description: str = Field(..., description="Description of the snapshot")
created_at: int = Field(..., description="Date of the snapshot (UTC timestamp)") created_at: int = Field(
..., description="Date of the snapshot (UTC timestamp). Deprecated: use created", deprecated=True
)
created: datetime = Field(..., description="Date of the snapshot (ISO 8601, UTC)")
@model_validator(mode="before")
@classmethod
def _derive_created(cls, data):
if isinstance(data, dict) and "created" not in data and data.get("created_at") is not None:
data = {**data, "created": datetime.fromtimestamp(data["created_at"], tz=timezone.utc)}
return data

View File

@ -17,6 +17,7 @@
import os import os
import uuid import uuid
from datetime import datetime, timezone
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -54,6 +55,9 @@ class TestSnapshotRoutes:
response = await client.get(app.url_path_for("get_snapshots", project_id=project.id)) response = await client.get(app.url_path_for("get_snapshots", project_id=project.id))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1 assert len(response.json()) == 1
data = response.json()[0]
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_delete_snapshot( async def test_delete_snapshot(
self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot
@ -80,3 +84,15 @@ class TestSnapshotRoutes:
response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"}) response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"})
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2 assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2
data = response.json()
assert isinstance(data["created_at"], int)
assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc)
async def test_snapshot_schema_timestamps(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
assert schemas["Snapshot"]["properties"]["created"]["format"] == "date-time"
assert schemas["Snapshot"]["properties"]["created_at"]["type"] == "integer"
assert schemas["Snapshot"]["properties"]["created_at"]["deprecated"] is True
for field in ("created_at", "modified_at"):
assert {"type": "string", "format": "date-time"} in schemas["NodeFile"]["properties"][field]["anyOf"]