pg-task-tracker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: pg-task-tracker
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Keywords:
6
+ Author: craig stevenson
7
+ Author-email: craig stevenson <craig.stevenson@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: sqlmodel>=0.0.22
17
+ Requires-Dist: psycopg2-binary>=2.9
18
+ Requires-Python: >=3.12
19
+ Project-URL: Homepage, https://github.com/craig-stevenson/pg-task-tracker
20
+ Project-URL: Issues, https://github.com/craig-stevenson/pg-task-tracker/issues
21
+ Project-URL: Repository, https://github.com/craig-stevenson/pg-task-tracker
22
+ Description-Content-Type: text/markdown
23
+
File without changes
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "pg-task-tracker"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "craig stevenson", email = "craig.stevenson@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ keywords = []
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "sqlmodel>=0.0.22",
23
+ "psycopg2-binary>=2.9",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/craig-stevenson/pg-task-tracker"
28
+ Repository = "https://github.com/craig-stevenson/pg-task-tracker"
29
+ Issues = "https://github.com/craig-stevenson/pg-task-tracker/issues"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.8.13,<0.9.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "ty",
38
+ "pytest",
39
+ ]
40
+
41
+ [tool.ty.environment]
42
+ python-version = "3.12"
43
+
44
+ [tool.ty.rules]
45
+ possibly-unresolved-reference = "error"
46
+ unresolved-reference = "error"
47
+ unresolved-import = "error"
@@ -0,0 +1,15 @@
1
+ from pg_task_tracker._types import StepStatus, TaskStatus
2
+ from pg_task_tracker.models import Task, TaskStep
3
+ from pg_task_tracker.schema import ensure_schema, get_migration_sql
4
+ from pg_task_tracker.tracker import TaskHandle, TaskTracker
5
+
6
+ __all__ = [
7
+ "TaskTracker",
8
+ "TaskHandle",
9
+ "ensure_schema",
10
+ "get_migration_sql",
11
+ "Task",
12
+ "TaskStep",
13
+ "StepStatus",
14
+ "TaskStatus",
15
+ ]
@@ -0,0 +1,4 @@
1
+ from typing import Literal
2
+
3
+ StepStatus = Literal["pending", "running", "completed", "failed"]
4
+ TaskStatus = Literal["pending", "running", "completed", "failed"]
@@ -0,0 +1,35 @@
1
+ -- pg_task_tracker: initial schema
2
+ -- Apply with: psql -f 001_initial.sql your_database
3
+
4
+ BEGIN;
5
+
6
+ CREATE TABLE IF NOT EXISTS st_task (
7
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ name VARCHAR(255) NOT NULL,
9
+ status VARCHAR(20) NOT NULL DEFAULT 'pending'
10
+ CHECK (status IN ('pending', 'running', 'completed', 'failed')),
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
13
+ metadata JSONB
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS ix_st_task_name ON st_task (name);
17
+ CREATE INDEX IF NOT EXISTS ix_st_task_status ON st_task (status);
18
+
19
+ CREATE TABLE IF NOT EXISTS st_task_step (
20
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ task_id UUID NOT NULL REFERENCES st_task(id) ON DELETE CASCADE,
22
+ name VARCHAR(255) NOT NULL,
23
+ status VARCHAR(20) NOT NULL DEFAULT 'pending'
24
+ CHECK (status IN ('pending', 'running', 'completed', 'failed')),
25
+ step_order INTEGER NOT NULL,
26
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ started_at TIMESTAMPTZ,
28
+ completed_at TIMESTAMPTZ,
29
+ metadata JSONB,
30
+ UNIQUE (task_id, name)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS ix_st_task_step_task_id ON st_task_step (task_id);
34
+
35
+ COMMIT;
@@ -0,0 +1,44 @@
1
+ import uuid as _uuid
2
+ from datetime import datetime, timezone
3
+ from typing import Any
4
+
5
+ from sqlalchemy import Column, JSON, String, UniqueConstraint
6
+ from sqlmodel import Field, Relationship, SQLModel
7
+
8
+
9
+ def _utcnow() -> datetime:
10
+ return datetime.now(timezone.utc)
11
+
12
+
13
+ class TaskStep(SQLModel, table=True):
14
+ __tablename__ = "st_task_step"
15
+ __table_args__ = (UniqueConstraint("task_id", "name"),)
16
+
17
+ id: _uuid.UUID = Field(default_factory=_uuid.uuid4, primary_key=True)
18
+ task_id: _uuid.UUID = Field(foreign_key="st_task.id", index=True)
19
+ name: str = Field(max_length=255)
20
+ status: str = Field(default="pending", sa_column=Column(String(20), nullable=False, server_default="pending"))
21
+ step_order: int
22
+ created_at: datetime = Field(default_factory=_utcnow)
23
+ started_at: datetime | None = Field(default=None)
24
+ completed_at: datetime | None = Field(default=None)
25
+ step_metadata: dict[str, Any] | None = Field(
26
+ default=None, sa_column=Column("metadata", JSON, nullable=True)
27
+ )
28
+
29
+ task: "Task" = Relationship(back_populates="steps")
30
+
31
+
32
+ class Task(SQLModel, table=True):
33
+ __tablename__ = "st_task"
34
+
35
+ id: _uuid.UUID = Field(default_factory=_uuid.uuid4, primary_key=True)
36
+ name: str = Field(max_length=255, index=True)
37
+ status: str = Field(default="pending", sa_column=Column(String(20), nullable=False, server_default="pending"))
38
+ created_at: datetime = Field(default_factory=_utcnow)
39
+ updated_at: datetime = Field(default_factory=_utcnow)
40
+ task_metadata: dict[str, Any] | None = Field(
41
+ default=None, sa_column=Column("metadata", JSON, nullable=True)
42
+ )
43
+
44
+ steps: list[TaskStep] = Relationship(back_populates="task")
File without changes
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+
5
+ from sqlalchemy import Engine
6
+ from sqlmodel import SQLModel
7
+
8
+ from pg_task_tracker import models as _models # noqa: F401 — ensure tables are registered
9
+
10
+
11
+ def ensure_schema(engine: Engine) -> None:
12
+ """Create all tables if they don't already exist."""
13
+ SQLModel.metadata.create_all(engine)
14
+
15
+
16
+ def get_migration_sql() -> str:
17
+ """Return the contents of the bundled SQL migration file."""
18
+ return (
19
+ importlib.resources.files("pg_task_tracker")
20
+ .joinpath("migrations/001_initial.sql")
21
+ .read_text(encoding="utf-8")
22
+ )
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import Engine, func
7
+ from sqlmodel import Session, select
8
+
9
+ from pg_task_tracker._types import StepStatus, TaskStatus
10
+ from pg_task_tracker.models import Task, TaskStep, _utcnow
11
+
12
+
13
+ class TaskHandle:
14
+ """Handle to a single task. Provides methods to add and update steps."""
15
+
16
+ def __init__(self, task_id: UUID, session: Session) -> None:
17
+ self._task_id = task_id
18
+ self._session = session
19
+
20
+ @property
21
+ def task_id(self) -> UUID:
22
+ return self._task_id
23
+
24
+ def add_step(
25
+ self,
26
+ name: str,
27
+ *,
28
+ status: StepStatus = "pending",
29
+ metadata: dict[str, Any] | None = None,
30
+ ) -> TaskStep:
31
+ """Append a new step to this task."""
32
+ current_max = self._session.exec(
33
+ select(func.max(TaskStep.step_order)).where(
34
+ TaskStep.task_id == self._task_id
35
+ )
36
+ ).one()
37
+ next_order = (current_max or 0) + 1
38
+
39
+ now = _utcnow()
40
+ step = TaskStep(
41
+ task_id=self._task_id,
42
+ name=name,
43
+ status=status,
44
+ step_order=next_order,
45
+ started_at=now if status == "running" else None,
46
+ step_metadata=metadata,
47
+ )
48
+ self._session.add(step)
49
+ self._session.commit()
50
+ self._session.refresh(step)
51
+ return step
52
+
53
+ def update_step(
54
+ self,
55
+ name: str,
56
+ *,
57
+ status: StepStatus | None = None,
58
+ metadata: dict[str, Any] | None = None,
59
+ ) -> TaskStep:
60
+ """Update an existing step by name."""
61
+ step = self._session.exec(
62
+ select(TaskStep).where(
63
+ TaskStep.task_id == self._task_id,
64
+ TaskStep.name == name,
65
+ )
66
+ ).one()
67
+
68
+ if status is not None:
69
+ step.status = status
70
+ now = _utcnow()
71
+ if status == "running":
72
+ step.started_at = now
73
+ elif status in ("completed", "failed"):
74
+ step.completed_at = now
75
+
76
+ if metadata is not None:
77
+ step.step_metadata = metadata
78
+
79
+ self._session.commit()
80
+ self._session.refresh(step)
81
+ return step
82
+
83
+ def get_steps(self) -> list[TaskStep]:
84
+ """Return all steps for this task, ordered by step_order."""
85
+ return list(
86
+ self._session.exec(
87
+ select(TaskStep)
88
+ .where(TaskStep.task_id == self._task_id)
89
+ .order_by(TaskStep.step_order) # ty: ignore[invalid-argument-type]
90
+ ).all()
91
+ )
92
+
93
+ def update_status(self, status: TaskStatus) -> None:
94
+ """Update the task's own status."""
95
+ task = self._session.get_one(Task, self._task_id)
96
+ task.status = status
97
+ task.updated_at = _utcnow()
98
+ self._session.commit()
99
+
100
+
101
+ class TaskTracker:
102
+ """Entry point for tracking multi-step tasks in PostgreSQL."""
103
+
104
+ def __init__(self, engine: Engine) -> None:
105
+ self._engine = engine
106
+
107
+ def _get_session(self) -> Session:
108
+ return Session(self._engine)
109
+
110
+ def create_task(
111
+ self,
112
+ name: str,
113
+ *,
114
+ status: TaskStatus = "pending",
115
+ metadata: dict[str, Any] | None = None,
116
+ ) -> TaskHandle:
117
+ """Create a new task and return a handle to it."""
118
+ session = self._get_session()
119
+ task = Task(name=name, status=status, task_metadata=metadata)
120
+ session.add(task)
121
+ session.commit()
122
+ session.refresh(task)
123
+ return TaskHandle(task.id, session)
124
+
125
+ def get_task(self, task_id: UUID) -> TaskHandle:
126
+ """Resume working with an existing task by its UUID."""
127
+ session = self._get_session()
128
+ session.get_one(Task, task_id)
129
+ return TaskHandle(task_id, session)
130
+
131
+ def get_task_by_name(self, name: str) -> TaskHandle:
132
+ """Look up a task by name. Raises if not found or if multiple match."""
133
+ session = self._get_session()
134
+ task = session.exec(select(Task).where(Task.name == name)).one()
135
+ return TaskHandle(task.id, session)