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.
- pg_task_tracker-0.1.0/PKG-INFO +23 -0
- pg_task_tracker-0.1.0/README.md +0 -0
- pg_task_tracker-0.1.0/pyproject.toml +47 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/__init__.py +15 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/_types.py +4 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/migrations/001_initial.sql +35 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/models.py +44 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/py.typed +0 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/schema.py +22 -0
- pg_task_tracker-0.1.0/src/pg_task_tracker/tracker.py +135 -0
|
@@ -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,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)
|