fairchild 0.0.4__py3-none-any.whl → 0.0.5__py3-none-any.whl

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.
fairchild/cli.py CHANGED
@@ -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,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 '[]';
fairchild/task.py CHANGED
@@ -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() {
fairchild/ui.py CHANGED
@@ -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
 
fairchild/worker.py CHANGED
@@ -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.4
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
@@ -1,24 +1,25 @@
1
1
  fairchild/__init__.py,sha256=Q7FvQFBPDM12tf2hAg_nlROSdg7dYIXWnDwyqX-6mLU,209
2
- fairchild/cli.py,sha256=H0Y1Zr6POsbnRCJIoMVh65jLYiPt9_-kPNP4_5MGrjQ,10077
2
+ fairchild/cli.py,sha256=LOrF4e6LgMasWGmeRpciD6iDptC3IiiVF53Ll-TzN8M,9895
3
3
  fairchild/context.py,sha256=64yEo5Cj4LeBT6LQd2UdN00tM7SXSd2Vw_dM4RV1u68,1757
4
4
  fairchild/fairchild.py,sha256=QxGPvkhedyaEtjtdavIONgfX3O4H5BKo1hG1T2WFNJ4,4451
5
5
  fairchild/future.py,sha256=SeVm_6Ds4k3qdwpKnFl71Qknx9tRM9v3UWZ_tJCp7cw,2302
6
6
  fairchild/job.py,sha256=_ilXLHg1aQOM4xOUZArT-UDGzPQnKVDYKvru4Qz1MXs,3767
7
7
  fairchild/record.py,sha256=DjTQgeE2YjjsU2nB3pegB0Njq1_sNWgJialPLZQ1k7c,575
8
- fairchild/task.py,sha256=Nwt8FxDV7PK4y4nMXingFPPmPX4HLhamlJrSacyXgIg,6964
9
- fairchild/ui.py,sha256=q7Bj60PAulMtt5KM3UvkFqpGJuKvc4P8Vv3RN5tIirs,17806
10
- fairchild/worker.py,sha256=cZzHLuE7CF9bTLy_59zUeZHQs_p7z2VUMJfcMZTjWLs,17045
8
+ fairchild/task.py,sha256=RZJbpN49dJz4zaFo9b4PxBI6TpXA4RLzvRi95JtS8_k,8350
9
+ fairchild/ui.py,sha256=Ib4vXlPGZvTTlvIsYFoIEYPJATXrYEppCv8R-1gtHs4,18106
10
+ fairchild/worker.py,sha256=5jDJAaryv-uC1cVAks68G-Fz20qt7JmHavrLHFXGDwc,17250
11
11
  fairchild/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  fairchild/db/migrations.py,sha256=tL5UPqllCipYC-XcEH_4ZfaRZLtJKaPACp70ZusvEvg,2174
13
13
  fairchild/db/migrations/001_initial.sql,sha256=FFObm3bIHdASd3d7lXzuAzWsmkCm3OAO2F63pw6HcMs,1711
14
14
  fairchild/db/migrations/002_add_parent_id.sql,sha256=ftDT053BaMnXX42aDxTtHnRnAgNKzIaUd1Fz_sO6LyQ,295
15
15
  fairchild/db/migrations/003_remove_workflows.sql,sha256=6svXO2nM7JiO-QHXfzIJCd6DeOSZ0AFQDsuQgPPv4IY,368
16
16
  fairchild/db/migrations/004_add_workers.sql,sha256=SOvHykAinr_O9gDf7kllEUZ57pw-USoHUsYLCfQumMI,714
17
- fairchild/templates/dashboard.html,sha256=Ng0q9iXGdDSo74ltABUupzjbKhqpofC8S3Zw7wXUmLA,50041
17
+ fairchild/db/migrations/005_add_worker_tasks.sql,sha256=0VJA5HBE2Aidjmk72nqLzreWA8A2jRDZsg1-6rcqTzo,186
18
+ fairchild/templates/dashboard.html,sha256=3prb9Li1lDF1pRxPnLq6VQRvPQgx0RD8qraG-QzuIJk,49171
18
19
  fairchild/templates/job.html,sha256=Pg7hd5p8dU5XJeCW9SryvDbvcErY1AMrxYm0z8hldnA,38042
19
- fairchild-0.0.4.dist-info/licenses/LICENSE,sha256=ad6qehkQLI1ax2pV6ocs7YePX06CPLBs3SThRbNC0q0,1068
20
- fairchild-0.0.4.dist-info/METADATA,sha256=RKftba_0moODAJNOAQiwVeYtC6HmXJuKGerN4VyHZDE,11006
21
- fairchild-0.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
- fairchild-0.0.4.dist-info/entry_points.txt,sha256=urOgjfuYex5__jBX91srCX8T5GgHvYZDPpWYkuA7z90,49
23
- fairchild-0.0.4.dist-info/top_level.txt,sha256=_2zkPnqS4i3JjLMpxRPDrBS0a04KBXZiD1NHFv7CO4U,10
24
- fairchild-0.0.4.dist-info/RECORD,,
20
+ fairchild-0.0.5.dist-info/licenses/LICENSE,sha256=ad6qehkQLI1ax2pV6ocs7YePX06CPLBs3SThRbNC0q0,1068
21
+ fairchild-0.0.5.dist-info/METADATA,sha256=BKFhgEsktIplI1bjcvU7k8-TCP1b_7M02lo4W4_rDGE,11006
22
+ fairchild-0.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
23
+ fairchild-0.0.5.dist-info/entry_points.txt,sha256=urOgjfuYex5__jBX91srCX8T5GgHvYZDPpWYkuA7z90,49
24
+ fairchild-0.0.5.dist-info/top_level.txt,sha256=_2zkPnqS4i3JjLMpxRPDrBS0a04KBXZiD1NHFv7CO4U,10
25
+ fairchild-0.0.5.dist-info/RECORD,,