stemtrace 0.2.0__tar.gz → 0.2.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.
Files changed (127) hide show
  1. {stemtrace-0.2.0 → stemtrace-0.2.1}/.bumpversion.toml +1 -1
  2. {stemtrace-0.2.0 → stemtrace-0.2.1}/.github/workflows/ci.yml +39 -0
  3. {stemtrace-0.2.0 → stemtrace-0.2.1}/CHANGELOG.md +11 -1
  4. {stemtrace-0.2.0 → stemtrace-0.2.1}/PKG-INFO +2 -2
  5. {stemtrace-0.2.0 → stemtrace-0.2.1}/README.md +1 -1
  6. {stemtrace-0.2.0 → stemtrace-0.2.1}/pyproject.toml +1 -1
  7. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/__init__.py +1 -1
  8. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/core/events.py +28 -2
  9. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/signals.py +75 -1
  10. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/api/routes.py +13 -4
  11. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/consumer.py +1 -0
  12. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/store.py +22 -1
  13. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/package.json +1 -1
  14. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/test_fastapi_integration.py +48 -16
  15. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_routes.py +40 -10
  16. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_signals.py +57 -0
  17. {stemtrace-0.2.0 → stemtrace-0.2.1}/uv.lock +1 -1
  18. {stemtrace-0.2.0 → stemtrace-0.2.1}/.dockerignore +0 -0
  19. {stemtrace-0.2.0 → stemtrace-0.2.1}/.github/dependabot.yml +0 -0
  20. {stemtrace-0.2.0 → stemtrace-0.2.1}/.github/workflows/e2e.yml +0 -0
  21. {stemtrace-0.2.0 → stemtrace-0.2.1}/.github/workflows/release.yml +0 -0
  22. {stemtrace-0.2.0 → stemtrace-0.2.1}/.gitignore +0 -0
  23. {stemtrace-0.2.0 → stemtrace-0.2.1}/.pre-commit-config.yaml +0 -0
  24. {stemtrace-0.2.0 → stemtrace-0.2.1}/.python-version +0 -0
  25. {stemtrace-0.2.0 → stemtrace-0.2.1}/CONTRIBUTING.md +0 -0
  26. {stemtrace-0.2.0 → stemtrace-0.2.1}/Dockerfile +0 -0
  27. {stemtrace-0.2.0 → stemtrace-0.2.1}/Dockerfile.e2e +0 -0
  28. {stemtrace-0.2.0 → stemtrace-0.2.1}/LICENSE +0 -0
  29. {stemtrace-0.2.0 → stemtrace-0.2.1}/Makefile +0 -0
  30. {stemtrace-0.2.0 → stemtrace-0.2.1}/build_ui.py +0 -0
  31. {stemtrace-0.2.0 → stemtrace-0.2.1}/docker-compose.e2e.yml +0 -0
  32. {stemtrace-0.2.0 → stemtrace-0.2.1}/docker-compose.yml +0 -0
  33. {stemtrace-0.2.0 → stemtrace-0.2.1}/docs/screenshots/task_details.png +0 -0
  34. {stemtrace-0.2.0 → stemtrace-0.2.1}/docs/screenshots/unregistered.png +0 -0
  35. {stemtrace-0.2.0 → stemtrace-0.2.1}/docs/screenshots/workflow.png +0 -0
  36. {stemtrace-0.2.0 → stemtrace-0.2.1}/examples/celery_app.py +0 -0
  37. {stemtrace-0.2.0 → stemtrace-0.2.1}/examples/fastapi_integration.py +0 -0
  38. {stemtrace-0.2.0 → stemtrace-0.2.1}/examples/with_auth.py +0 -0
  39. {stemtrace-0.2.0 → stemtrace-0.2.1}/progress.md +0 -0
  40. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/core/__init__.py +0 -0
  41. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/core/exceptions.py +0 -0
  42. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/core/graph.py +0 -0
  43. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/core/ports.py +0 -0
  44. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/__init__.py +0 -0
  45. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/bootsteps.py +0 -0
  46. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/config.py +0 -0
  47. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/scrubbing.py +0 -0
  48. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/transports/__init__.py +0 -0
  49. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/transports/memory.py +0 -0
  50. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/library/transports/redis.py +0 -0
  51. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/py.typed +0 -0
  52. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/__init__.py +0 -0
  53. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/__main__.py +0 -0
  54. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/api/__init__.py +0 -0
  55. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/api/schemas.py +0 -0
  56. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/api/websocket.py +0 -0
  57. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/fastapi/__init__.py +0 -0
  58. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/fastapi/auth.py +0 -0
  59. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/fastapi/extension.py +0 -0
  60. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/fastapi/router.py +0 -0
  61. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/__init__.py +0 -0
  62. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/biome.json +0 -0
  63. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/index.html +0 -0
  64. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/package-lock.json +0 -0
  65. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/playwright.config.ts +0 -0
  66. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/postcss.config.js +0 -0
  67. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/api/client.ts +0 -0
  68. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/api/queries.ts +0 -0
  69. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +0 -0
  70. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/Filters.tsx +0 -0
  71. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +0 -0
  72. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/TaskList.tsx +0 -0
  73. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +0 -0
  74. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +0 -0
  75. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +0 -0
  76. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/components/Tooltip.tsx +0 -0
  77. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +0 -0
  78. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +0 -0
  79. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/index.css +0 -0
  80. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/main.tsx +0 -0
  81. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/__root.tsx +0 -0
  82. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +0 -0
  83. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/graphs.tsx +0 -0
  84. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/index.tsx +0 -0
  85. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/registry.tsx +0 -0
  86. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +0 -0
  87. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/routes/workers.tsx +0 -0
  88. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/utils/format.ts +0 -0
  89. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/src/vite-env.d.ts +0 -0
  90. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-api.ts +0 -0
  91. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-data.ts +0 -0
  92. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/graphs.spec.ts +0 -0
  93. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/registry.spec.ts +0 -0
  94. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/task-detail.spec.ts +0 -0
  95. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/tasks.spec.ts +0 -0
  96. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tests/workers.spec.ts +0 -0
  97. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tsconfig.json +0 -0
  98. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +0 -0
  99. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/frontend/vite.config.ts +0 -0
  100. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/ui/static.py +0 -0
  101. {stemtrace-0.2.0 → stemtrace-0.2.1}/src/stemtrace/server/websocket.py +0 -0
  102. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/__init__.py +0 -0
  103. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/conftest.py +0 -0
  104. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/e2e/__init__.py +0 -0
  105. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/e2e/conftest.py +0 -0
  106. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/e2e/tasks.py +0 -0
  107. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/e2e/test_api_e2e.py +0 -0
  108. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/e2e/test_workers_e2e.py +0 -0
  109. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/__init__.py +0 -0
  110. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/test_embedded_static.py +0 -0
  111. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/test_redis_transport.py +0 -0
  112. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/test_static_ui.py +0 -0
  113. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/integration/test_workers_refresh.py +0 -0
  114. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/__init__.py +0 -0
  115. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_auth.py +0 -0
  116. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_bootsteps.py +0 -0
  117. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_config.py +0 -0
  118. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_consumer.py +0 -0
  119. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_events.py +0 -0
  120. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_examples.py +0 -0
  121. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_exceptions.py +0 -0
  122. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_graph.py +0 -0
  123. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_init.py +0 -0
  124. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_scrubbing.py +0 -0
  125. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_store.py +0 -0
  126. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_transports.py +0 -0
  127. {stemtrace-0.2.0 → stemtrace-0.2.1}/tests/unit/test_websocket.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "0.2.0"
2
+ current_version = "0.2.1"
3
3
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
4
4
  serialize = ["{major}.{minor}.{patch}"]
5
5
  commit = true
@@ -3,6 +3,9 @@ name: CI
3
3
  on:
4
4
  pull_request:
5
5
  branches: [main]
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
6
9
 
7
10
  concurrency:
8
11
  group: ${{ github.workflow }}-${{ github.ref }}
@@ -10,6 +13,7 @@ concurrency:
10
13
 
11
14
  jobs:
12
15
  lint:
16
+ if: github.event_name == 'pull_request'
13
17
  runs-on: ubuntu-latest
14
18
  steps:
15
19
  - uses: actions/checkout@v6
@@ -34,6 +38,7 @@ jobs:
34
38
  run: uv run ruff format --check src/ tests/
35
39
 
36
40
  type-check:
41
+ if: github.event_name == 'pull_request'
37
42
  runs-on: ubuntu-latest
38
43
  steps:
39
44
  - uses: actions/checkout@v6
@@ -55,6 +60,7 @@ jobs:
55
60
  run: uv run mypy src/ --strict
56
61
 
57
62
  test:
63
+ if: github.event_name == 'pull_request'
58
64
  runs-on: ubuntu-latest
59
65
  strategy:
60
66
  matrix:
@@ -87,6 +93,7 @@ jobs:
87
93
  fail_ci_if_error: false
88
94
 
89
95
  frontend:
96
+ if: github.event_name == 'pull_request'
90
97
  runs-on: ubuntu-latest
91
98
  defaults:
92
99
  run:
@@ -111,6 +118,7 @@ jobs:
111
118
  run: npm run typecheck
112
119
 
113
120
  build:
121
+ if: github.event_name == 'pull_request'
114
122
  runs-on: ubuntu-latest
115
123
  needs: [lint, type-check, test, frontend]
116
124
  steps:
@@ -148,3 +156,34 @@ jobs:
148
156
  with:
149
157
  name: dist
150
158
  path: dist/
159
+
160
+ coverage-main:
161
+ # Keep PR checks as the main gate, but ensure Codecov gets updated for the
162
+ # actual default-branch commit after merges (or direct pushes / releases).
163
+ if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
164
+ runs-on: ubuntu-latest
165
+ steps:
166
+ - uses: actions/checkout@v6
167
+
168
+ - name: Set up Python
169
+ uses: actions/setup-python@v6
170
+ with:
171
+ python-version: "3.12"
172
+
173
+ - name: Install uv
174
+ uses: astral-sh/setup-uv@v7
175
+ with:
176
+ enable-cache: true
177
+
178
+ - name: Install dependencies
179
+ run: uv sync --all-extras
180
+
181
+ - name: Run tests (coverage only)
182
+ run: uv run pytest --cov=stemtrace --cov-branch --cov-report=xml --cov-fail-under=95
183
+
184
+ - name: Upload coverage (main)
185
+ uses: codecov/codecov-action@v5
186
+ with:
187
+ token: ${{ secrets.CODECOV_TOKEN }}
188
+ files: ./coverage.xml
189
+ fail_ci_if_error: false
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-01-03
11
+
12
+ ### Added
13
+ - Task Registry now includes task definition metadata from workers (module, signature, docstring, bound)
14
+
15
+ ### Changed
16
+ - README: updated PyPI badge styling
17
+ - CI: upload Codecov coverage on `main`
18
+
10
19
  ## [0.2.0] - 2026-01-03
11
20
 
12
21
  ### Added
@@ -50,7 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
59
  - **E2E test suite**: Docker API tests + Playwright browser tests
51
60
  - **Comprehensive test suite**: 350+ Python tests, 90%+ coverage
52
61
 
53
- [unreleased]: https://github.com/iansokolskyi/stemtrace/compare/v0.2.0...HEAD
62
+ [unreleased]: https://github.com/iansokolskyi/stemtrace/compare/v0.2.1...HEAD
63
+ [0.2.1]: https://github.com/iansokolskyi/stemtrace/compare/v0.2.0...v0.2.1
54
64
  [0.2.0]: https://github.com/iansokolskyi/stemtrace/compare/v0.1.1...v0.2.0
55
65
  [0.1.1]: https://github.com/iansokolskyi/stemtrace/compare/v0.1.0...v0.1.1
56
66
  [0.1.0]: https://github.com/iansokolskyi/stemtrace/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stemtrace
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A lightweight Celery task flow visualizer
5
5
  Project-URL: Homepage, https://github.com/iansokolskyi/stemtrace
6
6
  Project-URL: Documentation, https://github.com/iansokolskyi/stemtrace#readme
@@ -55,7 +55,7 @@ Description-Content-Type: text/markdown
55
55
 
56
56
  **Zero-infrastructure Celery task flow visualizer**
57
57
 
58
- [![PyPI version](https://img.shields.io/pypi/v/stemtrace)](https://pypi.org/project/stemtrace/)
58
+ [![PyPI version](https://img.shields.io/pypi/v/stemtrace?color=darklime)](https://pypi.org/project/stemtrace)
59
59
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
60
60
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
61
61
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Zero-infrastructure Celery task flow visualizer**
4
4
 
5
- [![PyPI version](https://img.shields.io/pypi/v/stemtrace)](https://pypi.org/project/stemtrace/)
5
+ [![PyPI version](https://img.shields.io/pypi/v/stemtrace?color=darklime)](https://pypi.org/project/stemtrace)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
7
7
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
8
8
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stemtrace"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "A lightweight Celery task flow visualizer"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
38
38
  from celery import Celery
39
39
  from fastapi import FastAPI
40
40
 
41
- __version__ = "0.2.0"
41
+ __version__ = "0.2.1"
42
42
  __all__ = [
43
43
  "ConfigurationError",
44
44
  "StemtraceConfig",
@@ -4,7 +4,7 @@ from datetime import datetime
4
4
  from enum import Enum
5
5
  from typing import Any
6
6
 
7
- from pydantic import BaseModel, ConfigDict
7
+ from pydantic import BaseModel, ConfigDict, Field
8
8
 
9
9
 
10
10
  class TaskState(str, Enum):
@@ -27,6 +27,30 @@ class WorkerEventType(str, Enum):
27
27
  WORKER_SHUTDOWN = "worker_shutdown"
28
28
 
29
29
 
30
+ class RegisteredTaskDefinition(BaseModel):
31
+ """Celery task definition metadata captured from workers.
32
+
33
+ This data is collected from a running worker's task registry and is used
34
+ to enrich the task registry API (`/api/tasks/registry`) with human-friendly
35
+ information like docstrings and signatures.
36
+
37
+ Attributes:
38
+ name: Fully qualified task name (e.g., "myapp.tasks.add").
39
+ module: Python module where the task implementation lives.
40
+ signature: Human-readable call signature (best-effort).
41
+ docstring: Task documentation (best-effort).
42
+ bound: Whether the task is bound (`bind=True`) and receives `self`.
43
+ """
44
+
45
+ model_config = ConfigDict(frozen=True)
46
+
47
+ name: str
48
+ module: str | None = None
49
+ signature: str | None = None
50
+ docstring: str | None = None
51
+ bound: bool = False
52
+
53
+
30
54
  class TaskEvent(BaseModel):
31
55
  """Immutable task lifecycle event.
32
56
 
@@ -95,11 +119,13 @@ class WorkerEvent(BaseModel):
95
119
  hostname: str
96
120
  pid: int
97
121
  timestamp: datetime
98
- registered_tasks: list[str] = []
122
+ registered_tasks: list[str] = Field(default_factory=list)
123
+ task_definitions: dict[str, RegisteredTaskDefinition] = Field(default_factory=dict)
99
124
  shutdown_time: datetime | None = None
100
125
 
101
126
 
102
127
  __all__ = [
128
+ "RegisteredTaskDefinition",
103
129
  "TaskEvent",
104
130
  "TaskState",
105
131
  "WorkerEvent",
@@ -1,5 +1,6 @@
1
1
  """Celery signal handlers for task lifecycle events."""
2
2
 
3
+ import inspect
3
4
  import logging
4
5
  import threading
5
6
  import traceback as tb_module
@@ -17,7 +18,7 @@ from celery.signals import (
17
18
  worker_shutdown,
18
19
  )
19
20
 
20
- from stemtrace.core.events import TaskEvent, TaskState
21
+ from stemtrace.core.events import RegisteredTaskDefinition, TaskEvent, TaskState
21
22
  from stemtrace.core.ports import EventTransport
22
23
  from stemtrace.library.config import get_config
23
24
  from stemtrace.library.scrubbing import (
@@ -37,6 +38,9 @@ _transport: EventTransport | None = None
37
38
  _pending_emitted: set[str] = set()
38
39
  _pending_emitted_lock = threading.RLock()
39
40
 
41
+ # Guard against overly-large docstrings bloating broker events.
42
+ _MAX_DOCSTRING_CHARS = 4000
43
+
40
44
 
41
45
  def _extract_chord_info(chord_attr: Any) -> tuple[str | None, str | None]:
42
46
  """Extract chord group_id and callback task_id from chord attribute.
@@ -495,6 +499,74 @@ def _extract_registered_tasks(sender: Any) -> list[str]:
495
499
  return task_names
496
500
 
497
501
 
502
+ def _extract_task_definitions(sender: Any) -> dict[str, RegisteredTaskDefinition]:
503
+ """Extract task metadata (docstring/signature/module/bound) from Celery app.
504
+
505
+ This runs on the worker in response to `worker_ready`, so we have access to
506
+ the actual Task objects. The server generally does not import user task code,
507
+ so this is the authoritative source for registry metadata.
508
+
509
+ Args:
510
+ sender: Worker instance (WorkController) from worker_ready signal.
511
+
512
+ Returns:
513
+ Mapping of task name to RegisteredTaskDefinition.
514
+ """
515
+ definitions: dict[str, RegisteredTaskDefinition] = {}
516
+
517
+ app = getattr(sender, "app", None)
518
+ if app is None:
519
+ return definitions
520
+
521
+ tasks_registry = getattr(app, "tasks", None)
522
+ if tasks_registry is None:
523
+ return definitions
524
+
525
+ # tasks_registry is dict-like: {name: Task}
526
+ for name, task_obj in getattr(tasks_registry, "items", lambda: [])():
527
+ if not isinstance(name, str) or name.startswith("celery."):
528
+ continue
529
+ if task_obj is None:
530
+ continue
531
+
532
+ run_callable = getattr(task_obj, "run", None)
533
+ module: str | None = None
534
+ docstring: str | None = None
535
+ signature: str | None = None
536
+
537
+ # Best-effort: prefer module/docstring from the task implementation (run).
538
+ if run_callable is not None:
539
+ module = getattr(run_callable, "__module__", None)
540
+ docstring = inspect.getdoc(run_callable)
541
+ try:
542
+ signature = str(inspect.signature(run_callable))
543
+ except (TypeError, ValueError):
544
+ signature = None
545
+
546
+ # Fall back to task object/module if needed.
547
+ if module is None:
548
+ module = getattr(task_obj, "__module__", None)
549
+
550
+ # Bound tasks are declared via @app.task(bind=True). Celery exposes this
551
+ # on the Task instance as `bind`. Only treat an actual bool as authoritative
552
+ # to avoid MagicMock truthiness in tests.
553
+ bind_attr = getattr(task_obj, "bind", None)
554
+ bound = bind_attr if isinstance(bind_attr, bool) else False
555
+
556
+ if docstring is not None and len(docstring) > _MAX_DOCSTRING_CHARS:
557
+ docstring = docstring[:_MAX_DOCSTRING_CHARS] + "\n\n[truncated]"
558
+
559
+ definitions[name] = RegisteredTaskDefinition(
560
+ name=name,
561
+ module=module,
562
+ signature=signature,
563
+ docstring=docstring,
564
+ bound=bound,
565
+ )
566
+
567
+ return definitions
568
+
569
+
498
570
  def on_worker_ready(sender: Any, **_: Any) -> None:
499
571
  """Handle worker_ready signal - publish worker registration event.
500
572
 
@@ -509,6 +581,7 @@ def on_worker_ready(sender: Any, **_: Any) -> None:
509
581
  try:
510
582
  hostname, pid = _get_hostname_and_pid(sender)
511
583
  registered_tasks = _extract_registered_tasks(sender)
584
+ task_definitions = _extract_task_definitions(sender)
512
585
 
513
586
  # Publish worker lifecycle event using the global transport
514
587
  # This goes to the same stream as task events for unified consumption
@@ -520,6 +593,7 @@ def on_worker_ready(sender: Any, **_: Any) -> None:
520
593
  pid=pid,
521
594
  timestamp=datetime.now(timezone.utc),
522
595
  registered_tasks=registered_tasks,
596
+ task_definitions=task_definitions,
523
597
  )
524
598
 
525
599
  if _transport is not None:
@@ -310,16 +310,25 @@ def create_api_router(
310
310
  if status and task_status.value != status:
311
311
  continue
312
312
 
313
+ definition = (
314
+ worker_registry.get_task_definition(name) if worker_registry else None
315
+ )
316
+
313
317
  parts = name.rsplit(".", 1)
314
- module = parts[0] if len(parts) > 1 else None
318
+ fallback_module = parts[0] if len(parts) > 1 else None
319
+ module = (
320
+ definition.module
321
+ if definition and definition.module
322
+ else fallback_module
323
+ )
315
324
 
316
325
  tasks.append(
317
326
  RegisteredTaskResponse(
318
327
  name=name,
319
328
  module=module,
320
- signature=None,
321
- docstring=None,
322
- bound=False,
329
+ signature=definition.signature if definition else None,
330
+ docstring=definition.docstring if definition else None,
331
+ bound=definition.bound if definition else False,
323
332
  execution_count=execution_count,
324
333
  registered_by=registered_by,
325
334
  last_run=last_run,
@@ -141,6 +141,7 @@ class EventConsumer:
141
141
  hostname=event.hostname,
142
142
  pid=event.pid,
143
143
  tasks=event.registered_tasks,
144
+ task_definitions=event.task_definitions,
144
145
  event_timestamp=event.timestamp,
145
146
  )
146
147
  logger.info(
@@ -13,7 +13,7 @@ from stemtrace.core.graph import NodeType, TaskGraph, TaskNode
13
13
  from stemtrace.server.api.schemas import WorkerStatus
14
14
 
15
15
  if TYPE_CHECKING:
16
- from stemtrace.core.events import TaskEvent
16
+ from stemtrace.core.events import RegisteredTaskDefinition, TaskEvent
17
17
 
18
18
 
19
19
  class WorkerInfo(BaseModel):
@@ -104,6 +104,7 @@ class WorkerRegistry:
104
104
  def __init__(self) -> None:
105
105
  """Initialize empty worker registry."""
106
106
  self._workers: dict[str, WorkerInfo] = {}
107
+ self._task_definitions_by_name: dict[str, RegisteredTaskDefinition] = {}
107
108
  self._lock = threading.RLock()
108
109
 
109
110
  def register_worker(
@@ -111,6 +112,7 @@ class WorkerRegistry:
111
112
  hostname: str,
112
113
  pid: int,
113
114
  tasks: list[str],
115
+ task_definitions: dict[str, RegisteredTaskDefinition] | None = None,
114
116
  event_timestamp: datetime | None = None,
115
117
  ) -> None:
116
118
  """Register a worker with its task list.
@@ -121,6 +123,9 @@ class WorkerRegistry:
121
123
  hostname: Worker hostname.
122
124
  pid: Worker process ID.
123
125
  tasks: List of registered task names.
126
+ task_definitions: Optional mapping of task name to task definition
127
+ metadata (docstring/signature/module/bound). When provided, this
128
+ is used to enrich `/api/tasks/registry`.
124
129
  event_timestamp: When the worker event occurred. Used for last_seen
125
130
  to properly handle historical events from Redis replay.
126
131
  """
@@ -134,6 +139,10 @@ class WorkerRegistry:
134
139
  # Fall back to now() for direct calls (e.g., tests)
135
140
  timestamp = event_timestamp or datetime.now(timezone.utc)
136
141
 
142
+ # Update global task definitions map (best-effort, last-writer-wins).
143
+ if task_definitions:
144
+ self._task_definitions_by_name.update(task_definitions)
145
+
137
146
  # If worker already exists, update it (restart scenario)
138
147
  if worker_id in self._workers:
139
148
  self._workers[worker_id].registered_tasks = tasks
@@ -151,6 +160,18 @@ class WorkerRegistry:
151
160
  status=WorkerStatus.ONLINE,
152
161
  )
153
162
 
163
+ def get_task_definition(self, name: str) -> RegisteredTaskDefinition | None:
164
+ """Get task definition metadata by task name.
165
+
166
+ Args:
167
+ name: Fully qualified task name.
168
+
169
+ Returns:
170
+ RegisteredTaskDefinition if known, otherwise None.
171
+ """
172
+ with self._lock:
173
+ return self._task_definitions_by_name.get(name)
174
+
154
175
  def mark_shutdown(self, hostname: str, pid: int) -> None:
155
176
  """Mark a worker as offline (shutdown).
156
177
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stemtrace-ui",
3
3
  "private": true,
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -8,12 +8,12 @@ import pytest
8
8
  from fastapi import FastAPI
9
9
  from fastapi.testclient import TestClient
10
10
 
11
- from stemtrace.core.events import TaskEvent, TaskState
11
+ from stemtrace.core.events import RegisteredTaskDefinition, TaskEvent, TaskState
12
12
  from stemtrace.server.api.schemas import WorkerStatus
13
13
  from stemtrace.server.fastapi.auth import require_api_key, require_basic_auth
14
14
  from stemtrace.server.fastapi.extension import StemtraceExtension
15
15
  from stemtrace.server.fastapi.router import create_router
16
- from stemtrace.server.store import GraphStore
16
+ from stemtrace.server.store import GraphStore, WorkerRegistry
17
17
  from stemtrace.server.websocket import WebSocketManager
18
18
 
19
19
 
@@ -621,8 +621,6 @@ class TestWorkersEndpoint:
621
621
 
622
622
  def test_workers_empty_registry(self) -> None:
623
623
  """Empty registry returns empty list."""
624
- from stemtrace.server.store import WorkerRegistry
625
-
626
624
  store = GraphStore()
627
625
  worker_registry = WorkerRegistry()
628
626
  router = create_router(store=store, worker_registry=worker_registry)
@@ -639,8 +637,6 @@ class TestWorkersEndpoint:
639
637
 
640
638
  def test_workers_returns_registered_worker(self) -> None:
641
639
  """Registered worker appears in response."""
642
- from stemtrace.server.store import WorkerRegistry
643
-
644
640
  store = GraphStore()
645
641
  worker_registry = WorkerRegistry()
646
642
  worker_registry.register_worker(
@@ -665,8 +661,6 @@ class TestWorkersEndpoint:
665
661
 
666
662
  def test_workers_by_hostname(self) -> None:
667
663
  """Filter workers by hostname."""
668
- from stemtrace.server.store import WorkerRegistry
669
-
670
664
  store = GraphStore()
671
665
  worker_registry = WorkerRegistry()
672
666
  worker_registry.register_worker("worker-1", 12345, ["tasks.add"])
@@ -686,8 +680,6 @@ class TestWorkersEndpoint:
686
680
 
687
681
  def test_workers_shutdown_status(self) -> None:
688
682
  """Shutdown worker has offline status."""
689
- from stemtrace.server.store import WorkerRegistry
690
-
691
683
  store = GraphStore()
692
684
  worker_registry = WorkerRegistry()
693
685
  worker_registry.register_worker("worker-1", 12345, ["tasks.add"])
@@ -712,8 +704,6 @@ class TestRegistryWithWorkers:
712
704
 
713
705
  def test_registry_shows_registered_by(self) -> None:
714
706
  """Tasks show which workers registered them."""
715
- from stemtrace.server.store import WorkerRegistry
716
-
717
707
  store = GraphStore()
718
708
  worker_registry = WorkerRegistry()
719
709
  worker_registry.register_worker(
@@ -735,10 +725,54 @@ class TestRegistryWithWorkers:
735
725
  # Both workers should be listed (deduplicated and sorted)
736
726
  assert sorted(add_task["registered_by"]) == ["worker-1", "worker-2"]
737
727
 
728
+ def test_registry_includes_task_metadata(self) -> None:
729
+ """Registry includes docstring/signature/bound when provided by workers."""
730
+ store = GraphStore()
731
+ worker_registry = WorkerRegistry()
732
+
733
+ task_name = "tasks.add"
734
+ worker_registry.register_worker(
735
+ "worker-1",
736
+ 12345,
737
+ [task_name],
738
+ task_definitions={
739
+ task_name: RegisteredTaskDefinition(
740
+ name=task_name,
741
+ module="tasks",
742
+ signature="(x, y)",
743
+ docstring="Add two numbers.",
744
+ bound=True,
745
+ )
746
+ },
747
+ )
748
+
749
+ # Ensure the task is also observed (so it appears in the union set even if workers differ).
750
+ store.add_event(
751
+ TaskEvent(
752
+ task_id="task-1",
753
+ name=task_name,
754
+ state=TaskState.SUCCESS,
755
+ timestamp=datetime(2024, 1, 1, tzinfo=UTC),
756
+ )
757
+ )
758
+
759
+ router = create_router(store=store, worker_registry=worker_registry)
760
+ app = FastAPI()
761
+ app.include_router(router)
762
+ client = TestClient(app)
763
+
764
+ response = client.get("/api/tasks/registry")
765
+ assert response.status_code == 200
766
+ data = response.json()
767
+
768
+ add_task = next(t for t in data["tasks"] if t["name"] == task_name)
769
+ assert add_task["module"] == "tasks"
770
+ assert add_task["signature"] == "(x, y)"
771
+ assert add_task["docstring"] == "Add two numbers."
772
+ assert add_task["bound"] is True
773
+
738
774
  def test_registry_no_duplicate_hostnames(self) -> None:
739
775
  """Same hostname from multiple restarts appears only once."""
740
- from stemtrace.server.store import WorkerRegistry
741
-
742
776
  store = GraphStore()
743
777
  worker_registry = WorkerRegistry()
744
778
  # Same hostname, different PIDs (simulating restarts)
@@ -760,8 +794,6 @@ class TestRegistryWithWorkers:
760
794
 
761
795
  def test_registry_never_run_status_filter(self) -> None:
762
796
  """status=never_run shows only registered but never executed tasks."""
763
- from stemtrace.server.store import WorkerRegistry
764
-
765
797
  store = GraphStore()
766
798
  # Add an executed task (not registered) - status: not_registered
767
799
  store.add_event(
@@ -7,9 +7,9 @@ import pytest
7
7
  from fastapi import FastAPI
8
8
  from fastapi.testclient import TestClient
9
9
 
10
- from stemtrace.core.events import TaskEvent, TaskState
10
+ from stemtrace.core.events import RegisteredTaskDefinition, TaskEvent, TaskState
11
11
  from stemtrace.server.api.routes import create_api_router
12
- from stemtrace.server.store import GraphStore
12
+ from stemtrace.server.store import GraphStore, WorkerRegistry
13
13
 
14
14
 
15
15
  @pytest.fixture
@@ -431,8 +431,6 @@ class TestTaskRegistryWithWorkers:
431
431
  self, store: GraphStore, make_event: type
432
432
  ) -> None:
433
433
  """Task with executions AND registered by worker has 'active' status."""
434
- from stemtrace.server.store import WorkerRegistry
435
-
436
434
  worker_registry = WorkerRegistry()
437
435
  worker_registry.register_worker(
438
436
  hostname="worker-1",
@@ -457,10 +455,46 @@ class TestTaskRegistryWithWorkers:
457
455
  assert task["status"] == "active"
458
456
  assert "worker-1" in task["registered_by"]
459
457
 
458
+ def test_registry_includes_task_metadata_from_worker_registry(
459
+ self, store: GraphStore, make_event: type
460
+ ) -> None:
461
+ """Registry includes docstring/signature/bound when provided by workers."""
462
+ worker_registry = WorkerRegistry()
463
+ task_name = "myapp.tasks.process"
464
+ worker_registry.register_worker(
465
+ hostname="worker-1",
466
+ pid=12345,
467
+ tasks=[task_name],
468
+ task_definitions={
469
+ task_name: RegisteredTaskDefinition(
470
+ name=task_name,
471
+ module="myapp.tasks",
472
+ signature="(x, y)",
473
+ docstring="Process data.",
474
+ bound=True,
475
+ )
476
+ },
477
+ )
478
+
479
+ # Add execution so task is visible in registry
480
+ store.add_event(make_event.create("task-1", name=task_name))
481
+
482
+ app = FastAPI()
483
+ router = create_api_router(store, worker_registry=worker_registry)
484
+ app.include_router(router)
485
+ client = TestClient(app)
486
+
487
+ response = client.get("/api/tasks/registry")
488
+ data = response.json()
489
+
490
+ task = next(t for t in data["tasks"] if t["name"] == task_name)
491
+ assert task["module"] == "myapp.tasks"
492
+ assert task["signature"] == "(x, y)"
493
+ assert task["docstring"] == "Process data."
494
+ assert task["bound"] is True
495
+
460
496
  def test_registry_never_run_status_with_worker(self, store: GraphStore) -> None:
461
497
  """Task registered by worker but never executed has 'never_run' status."""
462
- from stemtrace.server.store import WorkerRegistry
463
-
464
498
  worker_registry = WorkerRegistry()
465
499
  worker_registry.register_worker(
466
500
  hostname="worker-1",
@@ -488,8 +522,6 @@ class TestTaskRegistryWithWorkers:
488
522
  self, store: GraphStore, make_event: type
489
523
  ) -> None:
490
524
  """Filter to show only never_run tasks."""
491
- from stemtrace.server.store import WorkerRegistry
492
-
493
525
  worker_registry = WorkerRegistry()
494
526
  worker_registry.register_worker(
495
527
  hostname="worker-1",
@@ -514,8 +546,6 @@ class TestTaskRegistryWithWorkers:
514
546
 
515
547
  def test_registry_mixed_statuses(self, store: GraphStore, make_event: type) -> None:
516
548
  """Registry shows all three status types correctly."""
517
- from stemtrace.server.store import WorkerRegistry
518
-
519
549
  worker_registry = WorkerRegistry()
520
550
  worker_registry.register_worker(
521
551
  hostname="worker-1",
@@ -10,8 +10,10 @@ import pytest
10
10
  from stemtrace.core.events import TaskState, WorkerEvent, WorkerEventType
11
11
  from stemtrace.library.config import StemtraceConfig, set_config
12
12
  from stemtrace.library.signals import (
13
+ _MAX_DOCSTRING_CHARS,
13
14
  _extract_chord_info,
14
15
  _extract_registered_tasks,
16
+ _extract_task_definitions,
15
17
  _format_exception,
16
18
  _format_traceback,
17
19
  _get_hostname_and_pid,
@@ -211,6 +213,61 @@ class TestWorkerLifecycleSignals:
211
213
 
212
214
  assert len(MemoryTransport.events) == 0
213
215
 
216
+ def test_extract_task_definitions_empty_without_app(self) -> None:
217
+ """No sender.app returns empty task definitions mapping."""
218
+ sender = MagicMock()
219
+ sender.app = None
220
+ assert _extract_task_definitions(sender) == {}
221
+
222
+ def test_extract_task_definitions_empty_without_tasks_registry(self) -> None:
223
+ """No app.tasks returns empty task definitions mapping."""
224
+ sender = SimpleNamespace(app=SimpleNamespace(tasks=None))
225
+ assert _extract_task_definitions(sender) == {}
226
+
227
+ def test_extract_task_definitions_truncates_docstring(self) -> None:
228
+ """Docstrings longer than the limit are truncated with a marker."""
229
+
230
+ def run_impl() -> None:
231
+ pass
232
+
233
+ run_impl.__doc__ = "x" * (_MAX_DOCSTRING_CHARS + 50)
234
+
235
+ class TaskObj:
236
+ run = staticmethod(run_impl)
237
+ bind = True
238
+
239
+ sender = SimpleNamespace(app=SimpleNamespace(tasks={"my.task": TaskObj()}))
240
+ defs = _extract_task_definitions(sender)
241
+ assert "my.task" in defs
242
+ assert defs["my.task"].bound is True
243
+ assert defs["my.task"].docstring is not None
244
+ assert defs["my.task"].docstring.endswith("[truncated]")
245
+ assert len(defs["my.task"].docstring) > _MAX_DOCSTRING_CHARS
246
+
247
+ def test_extract_task_definitions_signature_best_effort(self) -> None:
248
+ """Non-callable run attribute yields signature=None without raising."""
249
+
250
+ class TaskObj:
251
+ run = 123 # inspect.signature will raise TypeError
252
+ bind = False
253
+
254
+ sender = SimpleNamespace(app=SimpleNamespace(tasks={"my.bad": TaskObj()}))
255
+ defs = _extract_task_definitions(sender)
256
+ assert defs["my.bad"].signature is None
257
+
258
+ def test_extract_task_definitions_module_fallback_and_bind_guard(self) -> None:
259
+ """Fallback module uses task_obj.__module__ and bind guard ignores non-bool."""
260
+
261
+ class TaskObj:
262
+ __module__ = "my.module"
263
+ run = None
264
+ bind = MagicMock()
265
+
266
+ sender = SimpleNamespace(app=SimpleNamespace(tasks={"my.task": TaskObj()}))
267
+ defs = _extract_task_definitions(sender)
268
+ assert defs["my.task"].module == "my.module"
269
+ assert defs["my.task"].bound is False
270
+
214
271
 
215
272
  class TestTaskPrerun:
216
273
  """Tests for task_prerun signal handler."""
@@ -1595,7 +1595,7 @@ wheels = [
1595
1595
 
1596
1596
  [[package]]
1597
1597
  name = "stemtrace"
1598
- version = "0.2.0"
1598
+ version = "0.2.1"
1599
1599
  source = { editable = "." }
1600
1600
  dependencies = [
1601
1601
  { name = "celery" },
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
File without changes
File without changes
File without changes
File without changes
File without changes