fairchild 0.0.3__tar.gz → 0.0.5__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.
Files changed (36) hide show
  1. {fairchild-0.0.3 → fairchild-0.0.5}/PKG-INFO +1 -1
  2. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/cli.py +4 -12
  3. fairchild-0.0.5/fairchild/db/migrations/001_initial.sql +59 -0
  4. fairchild-0.0.5/fairchild/db/migrations/002_add_parent_id.sql +8 -0
  5. fairchild-0.0.5/fairchild/db/migrations/003_remove_workflows.sql +9 -0
  6. fairchild-0.0.5/fairchild/db/migrations/004_add_workers.sql +16 -0
  7. fairchild-0.0.5/fairchild/db/migrations/005_add_worker_tasks.sql +3 -0
  8. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/task.py +46 -0
  9. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/templates/dashboard.html +5 -30
  10. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/ui.py +61 -57
  11. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/worker.py +7 -3
  12. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/PKG-INFO +1 -1
  13. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/SOURCES.txt +5 -0
  14. {fairchild-0.0.3 → fairchild-0.0.5}/pyproject.toml +2 -2
  15. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_web_ui.py +41 -15
  16. {fairchild-0.0.3 → fairchild-0.0.5}/LICENSE +0 -0
  17. {fairchild-0.0.3 → fairchild-0.0.5}/README.md +0 -0
  18. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/__init__.py +0 -0
  19. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/context.py +0 -0
  20. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/db/__init__.py +0 -0
  21. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/db/migrations.py +0 -0
  22. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/fairchild.py +0 -0
  23. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/future.py +0 -0
  24. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/job.py +0 -0
  25. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/record.py +0 -0
  26. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild/templates/job.html +0 -0
  27. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/dependency_links.txt +0 -0
  28. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/entry_points.txt +0 -0
  29. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/requires.txt +0 -0
  30. {fairchild-0.0.3 → fairchild-0.0.5}/fairchild.egg-info/top_level.txt +0 -0
  31. {fairchild-0.0.3 → fairchild-0.0.5}/setup.cfg +0 -0
  32. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_cli.py +0 -0
  33. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_integration.py +0 -0
  34. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_job.py +0 -0
  35. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_record.py +0 -0
  36. {fairchild-0.0.3 → fairchild-0.0.5}/tests/test_task.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Workflow scheduling with PostgreSQL
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -162,19 +162,11 @@ async def _run_workers(queue_config: dict[str, int]):
162
162
  @cli.command()
163
163
  @click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
164
164
  @click.option("--port", "-p", default=4000, help="Port to bind to")
165
- @click.option(
166
- "--import",
167
- "-i",
168
- "imports",
169
- multiple=True,
170
- help='Module to import for task registration, e.g., "myapp.tasks"',
171
- )
172
- def ui(host: str, port: int, imports: tuple[str, ...]):
173
- """Start the web UI dashboard."""
174
- # Import task modules
175
- for module_path in imports:
176
- import_module(module_path)
165
+ def ui(host: str, port: int):
166
+ """Start the web UI dashboard.
177
167
 
168
+ The UI reads task definitions from active workers, so no --import is needed.
169
+ """
178
170
  click.echo(f"Starting Fairchild UI at http://{host}:{port}")
179
171
  asyncio.run(_run_ui(host, port))
180
172
 
@@ -0,0 +1,59 @@
1
+ -- Initial Fairchild schema
2
+
3
+ CREATE TABLE IF NOT EXISTS fairchild_jobs (
4
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5
+
6
+ -- Task identification
7
+ task_name VARCHAR(255) NOT NULL,
8
+ queue VARCHAR(255) NOT NULL DEFAULT 'default',
9
+ args JSONB NOT NULL DEFAULT '{}',
10
+
11
+ -- Workflow membership
12
+ workflow_id UUID,
13
+ workflow_name VARCHAR(255),
14
+ job_key VARCHAR(255),
15
+ deps VARCHAR(255)[] DEFAULT '{}',
16
+
17
+ -- State
18
+ state VARCHAR(50) NOT NULL DEFAULT 'available',
19
+
20
+ -- Scheduling & priority
21
+ priority SMALLINT NOT NULL DEFAULT 5,
22
+ scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23
+
24
+ -- Execution
25
+ attempted_at TIMESTAMPTZ,
26
+ completed_at TIMESTAMPTZ,
27
+ attempt INTEGER NOT NULL DEFAULT 0,
28
+ max_attempts INTEGER NOT NULL DEFAULT 3,
29
+
30
+ -- Results & errors
31
+ recorded JSONB,
32
+ errors JSONB DEFAULT '[]',
33
+
34
+ -- Metadata
35
+ tags VARCHAR(255)[] DEFAULT '{}',
36
+ meta JSONB DEFAULT '{}',
37
+
38
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
39
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
40
+ );
41
+
42
+ -- Index for fetching available jobs from a queue
43
+ CREATE INDEX IF NOT EXISTS idx_fairchild_jobs_fetchable
44
+ ON fairchild_jobs (queue, state, priority, scheduled_at)
45
+ WHERE state = 'available';
46
+
47
+ -- Index for workflow lookups
48
+ CREATE INDEX IF NOT EXISTS idx_fairchild_jobs_workflow
49
+ ON fairchild_jobs (workflow_id, job_key)
50
+ WHERE workflow_id IS NOT NULL;
51
+
52
+ -- Index for finding jobs by state
53
+ CREATE INDEX IF NOT EXISTS idx_fairchild_jobs_state
54
+ ON fairchild_jobs (state);
55
+
56
+ -- Index for scheduled jobs that need to become available
57
+ CREATE INDEX IF NOT EXISTS idx_fairchild_jobs_scheduled
58
+ ON fairchild_jobs (scheduled_at)
59
+ WHERE state = 'scheduled';
@@ -0,0 +1,8 @@
1
+ -- Add parent_id column for spawned tasks (parent-child job relationships)
2
+
3
+ ALTER TABLE fairchild_jobs
4
+ ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES fairchild_jobs(id);
5
+
6
+ CREATE INDEX IF NOT EXISTS idx_fairchild_jobs_parent
7
+ ON fairchild_jobs (parent_id)
8
+ WHERE parent_id IS NOT NULL;
@@ -0,0 +1,9 @@
1
+ -- Remove workflow columns (replaced by parent-child job relationships)
2
+
3
+ -- Drop the workflow index first
4
+ DROP INDEX IF EXISTS idx_fairchild_jobs_workflow;
5
+
6
+ -- Remove workflow columns
7
+ ALTER TABLE fairchild_jobs DROP COLUMN IF EXISTS workflow_id;
8
+ ALTER TABLE fairchild_jobs DROP COLUMN IF EXISTS workflow_name;
9
+ ALTER TABLE fairchild_jobs DROP COLUMN IF EXISTS job_key;
@@ -0,0 +1,16 @@
1
+ -- Workers table to track active workers
2
+ CREATE TABLE IF NOT EXISTS fairchild_workers (
3
+ id UUID PRIMARY KEY,
4
+ hostname TEXT NOT NULL,
5
+ pid INTEGER NOT NULL,
6
+ queues JSONB NOT NULL DEFAULT '{}', -- {"queue_name": num_slots, ...}
7
+ active_jobs INTEGER NOT NULL DEFAULT 0,
8
+ state TEXT NOT NULL DEFAULT 'running', -- running, paused, stopped
9
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10
+ last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
11
+ paused_at TIMESTAMPTZ,
12
+ metadata JSONB DEFAULT '{}'
13
+ );
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_fairchild_workers_state ON fairchild_workers(state);
16
+ CREATE INDEX IF NOT EXISTS idx_fairchild_workers_heartbeat ON fairchild_workers(last_heartbeat_at);
@@ -0,0 +1,3 @@
1
+ -- Add tasks column to workers table
2
+ -- Workers publish their registered tasks (with schemas) on startup
3
+ ALTER TABLE fairchild_workers ADD COLUMN IF NOT EXISTS tasks JSONB DEFAULT '[]';
@@ -15,6 +15,52 @@ def get_task(name: str) -> "Task":
15
15
  return _task_registry[name]
16
16
 
17
17
 
18
+ def get_task_schemas() -> list[dict]:
19
+ """Get schemas for all registered tasks.
20
+
21
+ Returns a list of task info dicts suitable for storing in the database.
22
+ """
23
+ tasks = []
24
+ for name, task in sorted(_task_registry.items()):
25
+ sig = inspect.signature(task.fn)
26
+ params = []
27
+ for param_name, param in sig.parameters.items():
28
+ if param_name in ("job", "workflow"):
29
+ continue
30
+
31
+ param_info = {"name": param_name}
32
+
33
+ if param.annotation != inspect.Parameter.empty:
34
+ try:
35
+ param_info["type"] = param.annotation.__name__
36
+ except AttributeError:
37
+ param_info["type"] = str(param.annotation)
38
+
39
+ if param.default != inspect.Parameter.empty:
40
+ param_info["default"] = param.default
41
+ param_info["required"] = False
42
+ else:
43
+ param_info["required"] = True
44
+
45
+ params.append(param_info)
46
+
47
+ docstring = inspect.getdoc(task.fn)
48
+
49
+ tasks.append(
50
+ {
51
+ "name": name,
52
+ "queue": task.queue,
53
+ "priority": task.priority,
54
+ "max_attempts": task.max_attempts,
55
+ "tags": task.tags,
56
+ "params": params,
57
+ "docstring": docstring,
58
+ }
59
+ )
60
+
61
+ return tasks
62
+
63
+
18
64
  def task(
19
65
  queue: str = "default",
20
66
  max_attempts: int = 3,
@@ -354,13 +354,6 @@
354
354
  font-weight: 500;
355
355
  }
356
356
 
357
- #workflows-table tbody tr {
358
- cursor: pointer;
359
- }
360
- #workflows-table tbody tr:hover {
361
- background: var(--bg-hover);
362
- }
363
-
364
357
  /* Enqueue Modal */
365
358
  .modal-overlay {
366
359
  display: none;
@@ -1214,25 +1207,6 @@
1214
1207
  }
1215
1208
  }
1216
1209
 
1217
- async function fetchWorkflows() {
1218
- const res = await fetch("/api/workflows");
1219
- const workflows = await res.json();
1220
-
1221
- const tbody = document.querySelector("#workflows-table tbody");
1222
- tbody.innerHTML = workflows
1223
- .map(
1224
- (wf) => `
1225
- <tr onclick="window.location='/workflows/${wf.workflow_id}'">
1226
- <td>${wf.workflow_name || wf.workflow_id.slice(0, 8)}</td>
1227
- <td>${wf.completed}/${wf.total_jobs} completed</td>
1228
- <td class="mono">${formatTime(wf.started_at)}</td>
1229
- <td class="mono">${wf.finished_at ? formatTime(wf.finished_at) : "-"}</td>
1230
- </tr>
1231
- `,
1232
- )
1233
- .join("");
1234
- }
1235
-
1236
1210
  async function fetchJobs(state = "") {
1237
1211
  const url = state ? `/api/jobs?state=${state}` : "/api/jobs";
1238
1212
  const res = await fetch(url);
@@ -1462,7 +1436,6 @@
1462
1436
  fetchStats(),
1463
1437
  fetchQueues(),
1464
1438
  fetchWorkers(currentWorkerState),
1465
- fetchWorkflows(),
1466
1439
  fetchJobs(currentState),
1467
1440
  fetchTimeseries(),
1468
1441
  ]);
@@ -1484,14 +1457,17 @@
1484
1457
  }, 1000);
1485
1458
 
1486
1459
  // Enqueue modal
1487
- let tasksLoaded = false;
1488
1460
  let taskRegistry = {};
1489
1461
 
1490
1462
  async function loadTasks() {
1491
- if (tasksLoaded) return;
1492
1463
  const res = await fetch("/api/tasks");
1493
1464
  const tasks = await res.json();
1494
1465
  const select = document.getElementById("enqueue-task");
1466
+
1467
+ // Clear existing options except the placeholder
1468
+ select.innerHTML = '<option value="">Select a task...</option>';
1469
+ taskRegistry = {};
1470
+
1495
1471
  tasks.forEach((task) => {
1496
1472
  taskRegistry[task.name] = task;
1497
1473
  const option = document.createElement("option");
@@ -1499,7 +1475,6 @@
1499
1475
  option.textContent = `${task.name} (queue: ${task.queue})`;
1500
1476
  select.appendChild(option);
1501
1477
  });
1502
- tasksLoaded = true;
1503
1478
  }
1504
1479
 
1505
1480
  function onTaskSelected() {
@@ -96,52 +96,32 @@ async def api_queues(request: web.Request) -> web.Response:
96
96
 
97
97
 
98
98
  async def api_tasks(request: web.Request) -> web.Response:
99
- """Get list of registered tasks with parameter info."""
100
- import inspect
101
- from fairchild.task import _task_registry
99
+ """Get list of registered tasks from active workers."""
100
+ fairchild: Fairchild = request.app[_fairchild_key]
101
+
102
+ # Get tasks from workers that have heartbeated recently (alive)
103
+ query = """
104
+ SELECT tasks FROM fairchild_workers
105
+ WHERE last_heartbeat_at > now() - interval '30 seconds'
106
+ AND state != 'stopped'
107
+ """
108
+ rows = await fairchild._pool.fetch(query)
102
109
 
110
+ # Aggregate unique tasks from all workers
111
+ seen = set()
103
112
  tasks = []
104
- for name, task in sorted(_task_registry.items()):
105
- # Extract parameter info from function signature
106
- sig = inspect.signature(task.fn)
107
- params = []
108
- for param_name, param in sig.parameters.items():
109
- # Skip special injected parameters
110
- if param_name in ("job", "workflow"):
111
- continue
112
-
113
- param_info = {"name": param_name}
114
-
115
- # Add type annotation if present
116
- if param.annotation != inspect.Parameter.empty:
117
- try:
118
- param_info["type"] = param.annotation.__name__
119
- except AttributeError:
120
- param_info["type"] = str(param.annotation)
121
-
122
- # Add default value if present
123
- if param.default != inspect.Parameter.empty:
124
- param_info["default"] = param.default
125
- param_info["required"] = False
126
- else:
127
- param_info["required"] = True
128
-
129
- params.append(param_info)
130
-
131
- # Get docstring
132
- docstring = inspect.getdoc(task.fn)
133
-
134
- tasks.append(
135
- {
136
- "name": name,
137
- "queue": task.queue,
138
- "priority": task.priority,
139
- "max_attempts": task.max_attempts,
140
- "tags": task.tags,
141
- "params": params,
142
- "docstring": docstring,
143
- }
144
- )
113
+ for row in rows:
114
+ worker_tasks = row["tasks"] or []
115
+ # Handle case where tasks is a JSON string (asyncpg returns jsonb as parsed, but just in case)
116
+ if isinstance(worker_tasks, str):
117
+ worker_tasks = json.loads(worker_tasks)
118
+ for task in worker_tasks:
119
+ if task["name"] not in seen:
120
+ seen.add(task["name"])
121
+ tasks.append(task)
122
+
123
+ # Sort by name
124
+ tasks.sort(key=lambda t: t["name"])
145
125
 
146
126
  return web.json_response(tasks)
147
127
 
@@ -234,8 +214,8 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
234
214
  "scheduled_at": "ISO8601"
235
215
  }
236
216
  """
237
- from datetime import datetime
238
- from fairchild.task import get_task
217
+ from datetime import datetime, timezone
218
+ from fairchild.job import Job, JobState
239
219
 
240
220
  fairchild: Fairchild = request.app[_fairchild_key]
241
221
 
@@ -249,10 +229,27 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
249
229
  if not task_name:
250
230
  return web.json_response({"error": "Missing required field: task"}, status=400)
251
231
 
252
- # Look up the task
253
- try:
254
- task = get_task(task_name)
255
- except ValueError:
232
+ # Look up the task from active workers
233
+ query = """
234
+ SELECT tasks FROM fairchild_workers
235
+ WHERE last_heartbeat_at > now() - interval '30 seconds'
236
+ AND state != 'stopped'
237
+ """
238
+ rows = await fairchild._pool.fetch(query)
239
+
240
+ task_info = None
241
+ for row in rows:
242
+ worker_tasks = row["tasks"] or []
243
+ if isinstance(worker_tasks, str):
244
+ worker_tasks = json.loads(worker_tasks)
245
+ for t in worker_tasks:
246
+ if t["name"] == task_name:
247
+ task_info = t
248
+ break
249
+ if task_info:
250
+ break
251
+
252
+ if not task_info:
256
253
  return web.json_response({"error": f"Unknown task: {task_name}"}, status=404)
257
254
 
258
255
  # Parse optional fields
@@ -278,14 +275,21 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
278
275
  {"error": "scheduled_at must be a valid ISO8601 datetime"}, status=400
279
276
  )
280
277
 
281
- # Enqueue the job
278
+ # Create and insert the job directly using task info from workers
279
+ now = datetime.now(timezone.utc)
280
+ job = Job(
281
+ task_name=task_name,
282
+ queue=task_info["queue"],
283
+ args=args,
284
+ priority=priority if priority is not None else task_info["priority"],
285
+ max_attempts=task_info["max_attempts"],
286
+ tags=task_info.get("tags", []),
287
+ scheduled_at=scheduled_at or now,
288
+ state=JobState.AVAILABLE if scheduled_at is None else JobState.SCHEDULED,
289
+ )
290
+
282
291
  try:
283
- job = await fairchild.enqueue(
284
- task=task,
285
- args=args,
286
- priority=priority,
287
- scheduled_at=scheduled_at,
288
- )
292
+ await fairchild._insert_job(job)
289
293
  except Exception as e:
290
294
  return web.json_response({"error": f"Failed to enqueue job: {e}"}, status=500)
291
295
 
@@ -11,7 +11,7 @@ from fairchild.context import set_current_job, get_pending_children
11
11
  from fairchild.fairchild import Fairchild
12
12
  from fairchild.job import Job, JobState
13
13
  from fairchild.record import Record
14
- from fairchild.task import get_task
14
+ from fairchild.task import get_task, get_task_schemas
15
15
 
16
16
 
17
17
  class Worker:
@@ -398,26 +398,30 @@ class WorkerPool:
398
398
  async def _register(self):
399
399
  """Register this worker pool in the database."""
400
400
  query = """
401
- INSERT INTO fairchild_workers (id, hostname, pid, queues, state)
402
- VALUES ($1, $2, $3, $4::jsonb, 'running')
401
+ INSERT INTO fairchild_workers (id, hostname, pid, queues, tasks, state)
402
+ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, 'running')
403
403
  ON CONFLICT (id) DO UPDATE SET
404
404
  hostname = EXCLUDED.hostname,
405
405
  pid = EXCLUDED.pid,
406
406
  queues = EXCLUDED.queues,
407
+ tasks = EXCLUDED.tasks,
407
408
  state = 'running',
408
409
  last_heartbeat_at = now()
409
410
  """
410
411
  # asyncpg requires JSON as a string for jsonb columns
411
412
  import json as json_module
412
413
 
414
+ tasks = get_task_schemas()
413
415
  await self.fairchild._pool.execute(
414
416
  query,
415
417
  self.id,
416
418
  self.hostname,
417
419
  self.pid,
418
420
  json_module.dumps(self.queue_config),
421
+ json_module.dumps(tasks),
419
422
  )
420
423
  print(f"Registered worker pool {self.id} ({self.hostname}:{self.pid})")
424
+ print(f" Tasks: {[t['name'] for t in tasks]}")
421
425
 
422
426
  async def _heartbeat_loop(self):
423
427
  """Periodically send heartbeats and check for pause state."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Workflow scheduling with PostgreSQL
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -19,6 +19,11 @@ fairchild.egg-info/requires.txt
19
19
  fairchild.egg-info/top_level.txt
20
20
  fairchild/db/__init__.py
21
21
  fairchild/db/migrations.py
22
+ fairchild/db/migrations/001_initial.sql
23
+ fairchild/db/migrations/002_add_parent_id.sql
24
+ fairchild/db/migrations/003_remove_workflows.sql
25
+ fairchild/db/migrations/004_add_workers.sql
26
+ fairchild/db/migrations/005_add_worker_tasks.sql
22
27
  fairchild/templates/dashboard.html
23
28
  fairchild/templates/job.html
24
29
  tests/test_cli.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fairchild"
3
- version = "0.0.3"
3
+ version = "0.0.5"
4
4
  description = "Workflow scheduling with PostgreSQL"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  fairchild = "fairchild.cli:main"
15
15
 
16
16
  [tool.setuptools.package-data]
17
- fairchild = ["templates/*.html"]
17
+ fairchild = ["templates/*.html", "db/migrations/*.sql"]
18
18
 
19
19
  [project.optional-dependencies]
20
20
  dev = [
@@ -50,6 +50,39 @@ async def client(fairchild_app):
50
50
  yield client
51
51
 
52
52
 
53
+ @pytest_asyncio.fixture
54
+ async def client_with_worker(fairchild_app):
55
+ """Create a test client with a simulated worker registration."""
56
+ import json
57
+ from uuid import uuid4
58
+ from fairchild.task import get_task_schemas
59
+ from fairchild.ui import _fairchild_key
60
+
61
+ # Import tasks to register them in _task_registry
62
+ import examples.tasks # noqa: F401
63
+
64
+ fairchild = fairchild_app[_fairchild_key]
65
+
66
+ # Register a fake worker with task schemas (simulates what real workers do)
67
+ worker_id = uuid4()
68
+ tasks = get_task_schemas()
69
+ query = """
70
+ INSERT INTO fairchild_workers (id, hostname, pid, queues, tasks, state)
71
+ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, 'running')
72
+ """
73
+ await fairchild._pool.execute(
74
+ query,
75
+ worker_id,
76
+ "test-host",
77
+ 12345,
78
+ json.dumps({"default": 1}),
79
+ json.dumps(tasks),
80
+ )
81
+
82
+ async with TestClient(TestServer(fairchild_app)) as client:
83
+ yield client
84
+
85
+
53
86
  @pytest.mark.asyncio
54
87
  async def test_dashboard_returns_html(client):
55
88
  """Test that the dashboard endpoint returns HTML."""
@@ -121,12 +154,9 @@ async def test_api_timeseries_returns_data(client):
121
154
 
122
155
 
123
156
  @pytest.mark.asyncio
124
- async def test_enqueue_job_via_api(client):
157
+ async def test_enqueue_job_via_api(client_with_worker):
125
158
  """Test enqueuing a job through the JSON API."""
126
- # Import tasks to register them
127
- import examples.tasks # noqa: F401
128
-
129
- resp = await client.post(
159
+ resp = await client_with_worker.post(
130
160
  "/api/jobs", json={"task": "examples.tasks.add", "args": {"a": 10, "b": 20}}
131
161
  )
132
162
  assert resp.status == 201
@@ -137,12 +167,10 @@ async def test_enqueue_job_via_api(client):
137
167
 
138
168
 
139
169
  @pytest.mark.asyncio
140
- async def test_enqueue_and_fetch_job(client):
170
+ async def test_enqueue_and_fetch_job(client_with_worker):
141
171
  """Test enqueuing a job and fetching its details."""
142
- import examples.tasks # noqa: F401
143
-
144
172
  # Enqueue a job
145
- resp = await client.post(
173
+ resp = await client_with_worker.post(
146
174
  "/api/jobs", json={"task": "examples.tasks.hello", "args": {"name": "TestUser"}}
147
175
  )
148
176
  assert resp.status == 201
@@ -150,7 +178,7 @@ async def test_enqueue_and_fetch_job(client):
150
178
  job_id = enqueue_data["id"]
151
179
 
152
180
  # Fetch the job details
153
- resp = await client.get(f"/api/jobs/{job_id}")
181
+ resp = await client_with_worker.get(f"/api/jobs/{job_id}")
154
182
  assert resp.status == 200
155
183
  job_data = await resp.json()
156
184
  assert job_data["id"] == job_id
@@ -165,19 +193,17 @@ async def test_enqueue_and_fetch_job(client):
165
193
 
166
194
 
167
195
  @pytest.mark.asyncio
168
- async def test_job_page_returns_html(client):
196
+ async def test_job_page_returns_html(client_with_worker):
169
197
  """Test that the job detail page returns HTML."""
170
- import examples.tasks # noqa: F401
171
-
172
198
  # First enqueue a job to get a valid ID
173
- resp = await client.post(
199
+ resp = await client_with_worker.post(
174
200
  "/api/jobs", json={"task": "examples.tasks.add", "args": {"a": 1, "b": 2}}
175
201
  )
176
202
  data = await resp.json()
177
203
  job_id = data["id"]
178
204
 
179
205
  # Fetch the job page
180
- resp = await client.get(f"/jobs/{job_id}")
206
+ resp = await client_with_worker.get(f"/jobs/{job_id}")
181
207
  assert resp.status == 200
182
208
  assert resp.content_type == "text/html"
183
209
  text = await resp.text()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes