python-pq 0.2.3__tar.gz → 0.3.1__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.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-pq
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Postgres-backed job queue for Python
5
5
  Author: ricwo
6
6
  Author-email: ricwo <r@cogram.com>
7
+ Requires-Dist: alembic>=1.17.2
7
8
  Requires-Dist: click>=8.3.1
8
9
  Requires-Dist: croniter>=6.0.0
9
10
  Requires-Dist: dill>=0.4.0
@@ -45,7 +46,7 @@ Requires PostgreSQL and Python 3.13+.
45
46
  from pq import PQ
46
47
 
47
48
  pq = PQ("postgresql://localhost/mydb")
48
- pq.create_tables()
49
+ pq.run_db_migrations() # Creates/updates tables
49
50
 
50
51
  def send_email(to: str, subject: str) -> None:
51
52
  print(f"Sending to {to}: {subject}")
@@ -25,7 +25,7 @@ Requires PostgreSQL and Python 3.13+.
25
25
  from pq import PQ
26
26
 
27
27
  pq = PQ("postgresql://localhost/mydb")
28
- pq.create_tables()
28
+ pq.run_db_migrations() # Creates/updates tables
29
29
 
30
30
  def send_email(to: str, subject: str) -> None:
31
31
  print(f"Sending to {to}: {subject}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.2.3"
3
+ version = "0.3.1"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,6 +8,7 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.13,<3.15"
10
10
  dependencies = [
11
+ "alembic>=1.17.2",
11
12
  "click>=8.3.1",
12
13
  "croniter>=6.0.0",
13
14
  "dill>=0.4.0",
@@ -38,8 +39,8 @@ module-name = "pq"
38
39
  dev = [
39
40
  "pre-commit>=4.5.1",
40
41
  "pytest>=9.0.2",
41
- "ruff>=0.14.10",
42
- "ty>=0.0.8",
42
+ "ruff>=0.14.11",
43
+ "ty>=0.0.10",
43
44
  ]
44
45
 
45
46
  [tool.pytest.ini_options]
@@ -1,5 +1,6 @@
1
1
  """PQ client - main interface for task queue."""
2
2
 
3
+ import importlib.resources
3
4
  from collections.abc import Callable, Set
4
5
  from contextlib import contextmanager
5
6
  from datetime import UTC, datetime, timedelta
@@ -65,12 +66,41 @@ class PQ:
65
66
  """Exit context manager and close connections."""
66
67
  self.close()
67
68
 
69
+ def run_db_migrations(self) -> None:
70
+ """Run database migrations to latest version.
71
+
72
+ Call this once at application startup before using the queue.
73
+ Uses Alembic to apply any pending migrations. Safe to call
74
+ multiple times - only pending migrations are applied.
75
+
76
+ Example:
77
+ pq = PQ("postgresql://localhost/mydb")
78
+ pq.run_db_migrations()
79
+ """
80
+ # Lazy import to avoid fork issues on macOS
81
+ from alembic import command
82
+ from alembic.config import Config
83
+
84
+ # Get migrations directory from within the installed package
85
+ migrations_pkg = importlib.resources.files("pq.migrations")
86
+ migrations_dir = str(migrations_pkg)
87
+
88
+ alembic_cfg = Config()
89
+ alembic_cfg.set_main_option("script_location", migrations_dir)
90
+ alembic_cfg.set_main_option("sqlalchemy.url", str(self._engine.url))
91
+ command.upgrade(alembic_cfg, "head")
92
+
68
93
  def create_tables(self) -> None:
69
- """Create all tables (for testing)."""
94
+ """Create all tables directly (for testing only).
95
+
96
+ For production, use run_db_migrations() instead. This method
97
+ bypasses Alembic and creates tables directly via SQLAlchemy,
98
+ which doesn't track schema versions.
99
+ """
70
100
  Base.metadata.create_all(self._engine)
71
101
 
72
102
  def drop_tables(self) -> None:
73
- """Drop all tables (for testing)."""
103
+ """Drop all tables (for testing only)."""
74
104
  Base.metadata.drop_all(self._engine)
75
105
 
76
106
  def clear_all(self) -> None:
@@ -85,6 +115,7 @@ class PQ:
85
115
  *args: Any,
86
116
  run_at: datetime | None = None,
87
117
  priority: Priority = Priority.NORMAL,
118
+ client_id: str | None = None,
88
119
  **kwargs: Any,
89
120
  ) -> int:
90
121
  """Enqueue a one-off task.
@@ -94,6 +125,7 @@ class PQ:
94
125
  *args: Positional arguments to pass to the handler.
95
126
  run_at: When to run the task. Defaults to now.
96
127
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
128
+ client_id: Optional client-provided identifier. Must be unique if provided.
97
129
  **kwargs: Keyword arguments to pass to the handler.
98
130
 
99
131
  Returns:
@@ -101,6 +133,7 @@ class PQ:
101
133
 
102
134
  Raises:
103
135
  ValueError: If task is a lambda, closure, or cannot be imported.
136
+ IntegrityError: If client_id already exists.
104
137
  """
105
138
  name = get_function_path(task)
106
139
  payload = serialize(args, kwargs)
@@ -108,7 +141,13 @@ class PQ:
108
141
  if run_at is None:
109
142
  run_at = datetime.now(UTC)
110
143
 
111
- task_obj = Task(name=name, payload=payload, run_at=run_at, priority=priority)
144
+ task_obj = Task(
145
+ name=name,
146
+ payload=payload,
147
+ run_at=run_at,
148
+ priority=priority,
149
+ client_id=client_id,
150
+ )
112
151
 
113
152
  with self.session() as session:
114
153
  session.add(task_obj)
@@ -122,6 +161,7 @@ class PQ:
122
161
  run_every: timedelta | None = None,
123
162
  cron: str | croniter | None = None,
124
163
  priority: Priority = Priority.NORMAL,
164
+ client_id: str | None = None,
125
165
  **kwargs: Any,
126
166
  ) -> int:
127
167
  """Schedule a periodic task.
@@ -135,6 +175,7 @@ class PQ:
135
175
  run_every: Interval between executions (e.g., timedelta(hours=1)).
136
176
  cron: Cron expression string (e.g., "0 9 * * 1") or croniter object.
137
177
  priority: Task priority. Higher = higher priority. Defaults to NORMAL.
178
+ client_id: Optional client-provided identifier. Must be unique if provided.
138
179
  **kwargs: Keyword arguments to pass to the handler.
139
180
 
140
181
  Returns:
@@ -144,6 +185,7 @@ class PQ:
144
185
  ValueError: If neither run_every nor cron is provided, or if both are.
145
186
  ValueError: If cron expression is invalid.
146
187
  ValueError: If task is a lambda, closure, or cannot be imported.
188
+ IntegrityError: If client_id already exists.
147
189
  """
148
190
  if run_every is None and cron is None:
149
191
  raise ValueError("Either run_every or cron must be provided")
@@ -185,6 +227,7 @@ class PQ:
185
227
  run_every=run_every,
186
228
  cron=cron_expr,
187
229
  next_run=next_run,
230
+ client_id=client_id,
188
231
  )
189
232
  .on_conflict_do_update(
190
233
  index_elements=["name"],
@@ -261,6 +304,38 @@ class PQ:
261
304
  session.expunge(task)
262
305
  return task
263
306
 
307
+ def get_task_by_client_id(self, client_id: str) -> Task | None:
308
+ """Get a task by client_id.
309
+
310
+ Args:
311
+ client_id: Client-provided identifier.
312
+
313
+ Returns:
314
+ Task object or None if not found.
315
+ """
316
+ with self.session() as session:
317
+ stmt = select(Task).where(Task.client_id == client_id)
318
+ task = session.execute(stmt).scalar_one_or_none()
319
+ if task:
320
+ session.expunge(task)
321
+ return task
322
+
323
+ def get_periodic_by_client_id(self, client_id: str) -> Periodic | None:
324
+ """Get a periodic task by client_id.
325
+
326
+ Args:
327
+ client_id: Client-provided identifier.
328
+
329
+ Returns:
330
+ Periodic object or None if not found.
331
+ """
332
+ with self.session() as session:
333
+ stmt = select(Periodic).where(Periodic.client_id == client_id)
334
+ periodic = session.execute(stmt).scalar_one_or_none()
335
+ if periodic:
336
+ session.expunge(periodic)
337
+ return periodic
338
+
264
339
  def list_failed(self, limit: int = 100) -> list[Task]:
265
340
  """List failed tasks.
266
341
 
@@ -0,0 +1 @@
1
+ Generic single-database configuration.
@@ -0,0 +1 @@
1
+ """Alembic migrations for pq database schema."""
@@ -0,0 +1,85 @@
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config, pool
4
+
5
+ from alembic import context
6
+
7
+ from pq.models import Base
8
+
9
+ # this is the Alembic Config object, which provides
10
+ # access to the values within the .ini file in use.
11
+ config = context.config
12
+
13
+ # Interpret the config file for Python logging.
14
+ # This line sets up loggers basically.
15
+ if config.config_file_name is not None:
16
+ fileConfig(config.config_file_name)
17
+
18
+ # pq models metadata for autogenerate support
19
+ target_metadata = Base.metadata
20
+
21
+ # Custom version table name to match pq_ naming convention
22
+ VERSION_TABLE = "pq_schema_version"
23
+
24
+
25
+ def run_migrations_offline() -> None:
26
+ """Run migrations in 'offline' mode.
27
+
28
+ This configures the context with just a URL
29
+ and not an Engine, though an Engine is acceptable
30
+ here as well. By skipping the Engine creation
31
+ we don't even need a DBAPI to be available.
32
+
33
+ Calls to context.execute() here emit the given string to the
34
+ script output.
35
+
36
+ """
37
+ url = config.get_main_option("sqlalchemy.url")
38
+ context.configure(
39
+ url=url,
40
+ target_metadata=target_metadata,
41
+ literal_binds=True,
42
+ dialect_opts={"paramstyle": "named"},
43
+ version_table=VERSION_TABLE,
44
+ )
45
+
46
+ with context.begin_transaction():
47
+ context.run_migrations()
48
+
49
+
50
+ def run_migrations_online() -> None:
51
+ """Run migrations in 'online' mode.
52
+
53
+ In this scenario we need to create an Engine
54
+ and associate a connection with the context.
55
+
56
+ """
57
+ # Support both ini file config and programmatic config
58
+ config_section = config.get_section(config.config_ini_section, {})
59
+ url = config.get_main_option("sqlalchemy.url")
60
+
61
+ if url and not config_section.get("sqlalchemy.url"):
62
+ # URL was set programmatically, add it to config section
63
+ config_section["sqlalchemy.url"] = url
64
+
65
+ connectable = engine_from_config(
66
+ config_section,
67
+ prefix="sqlalchemy.",
68
+ poolclass=pool.NullPool,
69
+ )
70
+
71
+ with connectable.connect() as connection:
72
+ context.configure(
73
+ connection=connection,
74
+ target_metadata=target_metadata,
75
+ version_table=VERSION_TABLE,
76
+ )
77
+
78
+ with context.begin_transaction():
79
+ context.run_migrations()
80
+
81
+
82
+ if context.is_offline_mode():
83
+ run_migrations_offline()
84
+ else:
85
+ run_migrations_online()
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,97 @@
1
+ """initial schema
2
+
3
+ Revision ID: 476683af098d
4
+ Revises:
5
+ Create Date: 2026-01-09 05:58:39.589866 Z
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+ from sqlalchemy.dialects import postgresql
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "476683af098d"
17
+ down_revision: Union[str, Sequence[str], None] = None
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.create_table(
26
+ "pq_periodic",
27
+ sa.Column("id", sa.BigInteger(), sa.Identity(always=False), nullable=False),
28
+ sa.Column("name", sa.String(length=255), nullable=False),
29
+ sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
30
+ sa.Column("priority", sa.SmallInteger(), nullable=False),
31
+ sa.Column("run_every", sa.Interval(), nullable=True),
32
+ sa.Column("cron", sa.String(length=100), nullable=True),
33
+ sa.Column("next_run", sa.DateTime(timezone=True), nullable=False),
34
+ sa.Column("last_run", sa.DateTime(timezone=True), nullable=True),
35
+ sa.Column(
36
+ "created_at",
37
+ sa.DateTime(timezone=True),
38
+ server_default=sa.text("now()"),
39
+ nullable=False,
40
+ ),
41
+ sa.PrimaryKeyConstraint("id"),
42
+ sa.UniqueConstraint("name"),
43
+ )
44
+ op.create_index(
45
+ "ix_pq_periodic_priority_next_run",
46
+ "pq_periodic",
47
+ ["priority", "next_run"],
48
+ unique=False,
49
+ )
50
+ op.create_table(
51
+ "pq_tasks",
52
+ sa.Column("id", sa.BigInteger(), sa.Identity(always=False), nullable=False),
53
+ sa.Column("name", sa.String(length=255), nullable=False),
54
+ sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
55
+ sa.Column("priority", sa.SmallInteger(), nullable=False),
56
+ sa.Column(
57
+ "status",
58
+ sa.Enum(
59
+ "PENDING",
60
+ "RUNNING",
61
+ "COMPLETED",
62
+ "FAILED",
63
+ name="task_status",
64
+ create_constraint=True,
65
+ ),
66
+ nullable=False,
67
+ ),
68
+ sa.Column("run_at", sa.DateTime(timezone=True), nullable=False),
69
+ sa.Column(
70
+ "created_at",
71
+ sa.DateTime(timezone=True),
72
+ server_default=sa.text("now()"),
73
+ nullable=False,
74
+ ),
75
+ sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
76
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
77
+ sa.Column("error", sa.Text(), nullable=True),
78
+ sa.Column("attempts", sa.Integer(), nullable=False),
79
+ sa.PrimaryKeyConstraint("id"),
80
+ )
81
+ op.create_index(
82
+ "ix_pq_tasks_status_priority_run_at",
83
+ "pq_tasks",
84
+ ["status", "priority", "run_at"],
85
+ unique=False,
86
+ )
87
+ # ### end Alembic commands ###
88
+
89
+
90
+ def downgrade() -> None:
91
+ """Downgrade schema."""
92
+ # ### commands auto generated by Alembic - please adjust! ###
93
+ op.drop_index("ix_pq_tasks_status_priority_run_at", table_name="pq_tasks")
94
+ op.drop_table("pq_tasks")
95
+ op.drop_index("ix_pq_periodic_priority_next_run", table_name="pq_periodic")
96
+ op.drop_table("pq_periodic")
97
+ # ### end Alembic commands ###
@@ -0,0 +1,54 @@
1
+ """add client_id
2
+
3
+ Revision ID: 2483bec70083
4
+ Revises: 476683af098d
5
+ Create Date: 2026-01-09 06:37:47 Z
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "2483bec70083"
17
+ down_revision: Union[str, Sequence[str], None] = "476683af098d"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Add client_id column to pq_tasks and pq_periodic tables."""
24
+ # Add client_id column to pq_tasks
25
+ op.add_column(
26
+ "pq_tasks",
27
+ sa.Column("client_id", sa.String(length=255), nullable=True),
28
+ )
29
+ op.create_index(
30
+ "ix_pq_tasks_client_id",
31
+ "pq_tasks",
32
+ ["client_id"],
33
+ unique=True,
34
+ )
35
+
36
+ # Add client_id column to pq_periodic
37
+ op.add_column(
38
+ "pq_periodic",
39
+ sa.Column("client_id", sa.String(length=255), nullable=True),
40
+ )
41
+ op.create_index(
42
+ "ix_pq_periodic_client_id",
43
+ "pq_periodic",
44
+ ["client_id"],
45
+ unique=True,
46
+ )
47
+
48
+
49
+ def downgrade() -> None:
50
+ """Remove client_id column from pq_tasks and pq_periodic tables."""
51
+ op.drop_index("ix_pq_periodic_client_id", table_name="pq_periodic")
52
+ op.drop_column("pq_periodic", "client_id")
53
+ op.drop_index("ix_pq_tasks_client_id", table_name="pq_tasks")
54
+ op.drop_column("pq_tasks", "client_id")
@@ -0,0 +1 @@
1
+ """Migration versions."""
@@ -45,6 +45,9 @@ class Task(Base):
45
45
  )
46
46
 
47
47
  id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)
48
+ client_id: Mapped[str | None] = mapped_column(
49
+ String(255), nullable=True, unique=True, index=True
50
+ )
48
51
  name: Mapped[str] = mapped_column(String(255), nullable=False)
49
52
  payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
50
53
  priority: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
@@ -76,6 +79,9 @@ class Periodic(Base):
76
79
  )
77
80
 
78
81
  id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)
82
+ client_id: Mapped[str | None] = mapped_column(
83
+ String(255), nullable=True, unique=True, index=True
84
+ )
79
85
  name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
80
86
  payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
81
87
  priority: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes