expr-tracker 0.2.5__tar.gz → 0.2.6__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 (113) hide show
  1. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/PKG-INFO +2 -6
  2. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/README.md +1 -2
  3. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/design.md +6 -3
  4. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/getting-started.md +4 -1
  5. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/alerts.md +2 -2
  6. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/index.md +1 -1
  7. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/pyproject.toml +0 -3
  8. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/backends/__init__.py +34 -38
  9. expr_tracker-0.2.6/src/expr_tracker/alerts/backends/cards.py +102 -0
  10. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_backends.py +24 -21
  11. expr_tracker-0.2.6/tests/test_lark.py +380 -0
  12. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/uv.lock +1 -61
  13. expr_tracker-0.2.5/tests/test_lark_live.py +0 -269
  14. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/.github/workflows/docs.yaml +0 -0
  15. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/.github/workflows/release.yaml +0 -0
  16. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/.gitignore +0 -0
  17. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/LICENSE +0 -0
  18. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/architecture.md +0 -0
  19. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/examples.md +0 -0
  20. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/artifacts.md +0 -0
  21. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/backends.md +0 -0
  22. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/cli.md +0 -0
  23. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/distributed.md +0 -0
  24. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/history.md +0 -0
  25. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/logging.md +0 -0
  26. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/spans.md +0 -0
  27. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/guide/streams.md +0 -0
  28. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/reference/api.md +0 -0
  29. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/reference/configuration.md +0 -0
  30. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/docs/reference/expressions.md +0 -0
  31. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/README.md +0 -0
  32. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/alert_rules.py +0 -0
  33. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/checkpoints.py +0 -0
  34. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/early_stopping.py +0 -0
  35. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/multiprocess_pipeline.py +0 -0
  36. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/profile_step.py +0 -0
  37. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/examples/quickstart.py +0 -0
  38. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/mkdocs.yml +0 -0
  39. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/__init__.py +0 -0
  40. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/_compat.py +0 -0
  41. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/__init__.py +0 -0
  42. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/backends/base.py +0 -0
  43. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/dispatch.py +0 -0
  44. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/engine.py +0 -0
  45. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  46. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/eval.py +0 -0
  47. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/functions.py +0 -0
  48. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  49. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  50. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/parser.py +0 -0
  51. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/expr/rule.py +0 -0
  52. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/alerts/models.py +0 -0
  53. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/artifacts.py +0 -0
  54. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/cli.py +0 -0
  55. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/encoders.py +0 -0
  56. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/__init__.py +0 -0
  57. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/codec.py +0 -0
  58. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/frame.py +0 -0
  59. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/naming.py +0 -0
  60. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/reader.py +0 -0
  61. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/series.py +0 -0
  62. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/store.py +0 -0
  63. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/history/writer.py +0 -0
  64. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/plugins.py +0 -0
  65. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/py.typed +0 -0
  66. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/run.py +0 -0
  67. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/spans.py +0 -0
  68. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/summary.py +0 -0
  69. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/trace.py +0 -0
  70. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/tracker.py +0 -0
  71. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/src/expr_tracker/types.py +0 -0
  72. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/conftest.py +0 -0
  73. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_delivery.py +0 -0
  74. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_dispatch.py +0 -0
  75. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_engine.py +0 -0
  76. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_models.py +0 -0
  77. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_alert_routing.py +0 -0
  78. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_artifacts.py +0 -0
  79. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_benchmark.py +0 -0
  80. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_cache.py +0 -0
  81. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_cli.py +0 -0
  82. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_correctness.py +0 -0
  83. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_distributed.py +0 -0
  84. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_e2e.py +0 -0
  85. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_examples.py +0 -0
  86. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_expr_builder.py +0 -0
  87. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_expr_eval.py +0 -0
  88. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_expr_functions.py +0 -0
  89. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_expr_parser.py +0 -0
  90. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_expr_properties.py +0 -0
  91. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_failure_modes.py +0 -0
  92. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_features.py +0 -0
  93. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_frame_codec_summary.py +0 -0
  94. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_history.py +0 -0
  95. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_hot_paths.py +0 -0
  96. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_integration.py +0 -0
  97. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_perf.py +0 -0
  98. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_public_surfaces.py +0 -0
  99. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_review_regressions.py +0 -0
  100. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_rule_lifecycle.py +0 -0
  101. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_run_backends.py +0 -0
  102. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_scenarios.py +0 -0
  103. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_span_plugins.py +0 -0
  104. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_spans.py +0 -0
  105. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_step_commit.py +0 -0
  106. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_streams.py +0 -0
  107. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_stress.py +0 -0
  108. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_trace.py +0 -0
  109. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_trackio.py +0 -0
  110. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_value_encoding.py +0 -0
  111. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_wandb.py +0 -0
  112. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_writer_buffer.py +0 -0
  113. {expr_tracker-0.2.5 → expr_tracker-0.2.6}/tests/test_writer_durability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
5
5
  Project-URL: Homepage, https://hspk.github.io/expr_tracker/
6
6
  Project-URL: Documentation, https://hspk.github.io/expr_tracker/
@@ -31,13 +31,10 @@ Provides-Extra: all
31
31
  Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
32
32
  Requires-Dist: pandas>=1.5; extra == 'all'
33
33
  Requires-Dist: polars>=0.20; extra == 'all'
34
- Requires-Dist: slark>=0.1.28; extra == 'all'
35
34
  Requires-Dist: trackio>=0.4.0; extra == 'all'
36
35
  Requires-Dist: wandb>=0.21.0; extra == 'all'
37
36
  Provides-Extra: gpu
38
37
  Requires-Dist: nvidia-ml-py>=12.0; extra == 'gpu'
39
- Provides-Extra: lark
40
- Requires-Dist: slark>=0.1.28; extra == 'lark'
41
38
  Provides-Extra: pandas
42
39
  Requires-Dist: pandas>=1.5; extra == 'pandas'
43
40
  Provides-Extra: polars
@@ -92,7 +89,6 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
92
89
  uv add expr_tracker # local-first: click, loguru, pydantic only
93
90
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
94
91
  uv add "expr_tracker[trackio]" # mirror to trackio
95
- uv add "expr_tracker[lark]" # Feishu/Lark alert channel
96
92
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
97
93
  uv add "expr_tracker[all]" # everything
98
94
  ```
@@ -180,7 +176,7 @@ uv run ruff format src tests
180
176
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
181
177
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
182
178
  | `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
183
- | `test_lark_live.py` | Lark channel; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
179
+ | `test_lark.py` | Lark card construction; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
184
180
  | `test_stress.py` (`slow`) | 100k-row writes, concurrency, cache thrash, write-failure recovery |
185
181
  | `test_benchmark.py` (`benchmark`) | throughput, tail latency, query cost, memory stability |
186
182
 
@@ -42,7 +42,6 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
42
42
  uv add expr_tracker # local-first: click, loguru, pydantic only
43
43
  uv add "expr_tracker[wandb]" # mirror to Weights & Biases
44
44
  uv add "expr_tracker[trackio]" # mirror to trackio
45
- uv add "expr_tracker[lark]" # Feishu/Lark alert channel
46
45
  uv add "expr_tracker[pandas]" # history(output_type="pandas")
47
46
  uv add "expr_tracker[all]" # everything
48
47
  ```
@@ -130,7 +129,7 @@ uv run ruff format src tests
130
129
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
131
130
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
132
131
  | `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
133
- | `test_lark_live.py` | Lark channel; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
132
+ | `test_lark.py` | Lark card construction; real delivery when `ET_LARK_TEST_WEBHOOK` is set |
134
133
  | `test_stress.py` (`slow`) | 100k-row writes, concurrency, cache thrash, write-failure recovery |
135
134
  | `test_benchmark.py` (`benchmark`) | throughput, tail latency, query cost, memory stability |
136
135
 
@@ -375,9 +375,12 @@ AlertRule(name, condition, level="warning", title=None,
375
375
  timeout.
376
376
  - **Only send failures are swallowed**; configuration errors raise at configuration
377
377
  time.
378
- - **Backends**: `lark` (via slark, client reused), `slack`, `dingtalk`, `wecom` and
379
- `webhook` (a generic template) use stdlib `urllib`; `email` uses stdlib `smtplib` —
380
- **no new hard dependencies**. `register_backend()` extends the set.
378
+ - **Backends**: `lark`, `slack`, `dingtalk`, `wecom` and `webhook` (a generic
379
+ template) use stdlib `urllib`; `email` uses stdlib `smtplib` — **every channel is
380
+ dependency-free**. `register_backend()` extends the set. The Lark card layout is
381
+ built as plain dicts in `backends/cards.py` rather than pulled in with a client
382
+ library: it is one JSON body, and a whole HTTP stack to shape it was not a trade
383
+ worth making.
381
384
  - **Configuration precedence**: `init(alert=)` > `configure_alert()` >
382
385
  `ET_ALERT_CONFIG` file > environment (`ET_LARK_WEBHOOK_URL`, legacy `WEBHOOK_URL`)
383
386
  > defaults.
@@ -12,10 +12,13 @@ Only `click`, `loguru` and `pydantic` are required. Everything else is an extra:
12
12
  | --- | --- |
13
13
  | `wandb` | mirror metrics to Weights & Biases |
14
14
  | `trackio` | mirror metrics to trackio |
15
- | `lark` | Feishu/Lark alert channel |
16
15
  | `pandas` / `polars` | `history(output_type=...)` frames |
16
+ | `gpu` | `GpuStats` span plugin, via NVML |
17
17
  | `all` | all of the above |
18
18
 
19
+ Alert channels need nothing: every one of them, Lark included, is built on the
20
+ standard library.
21
+
19
22
  A missing extra is reported with the exact install command; it never crashes a run.
20
23
 
21
24
  ## A complete run
@@ -90,8 +90,8 @@ et.alert(title="done", text="training finished", level="info", channels=["oncall
90
90
  ```
91
91
 
92
92
  Built-in types: `lark`, `slack`, `dingtalk`, `wecom`, `webhook` (a generic JSON
93
- template), `email`, `callable`. All but `lark` use only the standard library. Add
94
- your own with `register_backend()`.
93
+ template), `email`, `callable`. All of them use only the standard library, so no
94
+ channel needs an extra. Add your own with `register_backend()`.
95
95
 
96
96
  ### Email
97
97
 
@@ -36,7 +36,7 @@ or an unserialisable value degrades with a warning; none of them can stop traini
36
36
 
37
37
  ```bash
38
38
  uv add expr_tracker # local-first, three small dependencies
39
- uv add "expr_tracker[all]" # + wandb, trackio, lark, pandas, polars
39
+ uv add "expr_tracker[all]" # + wandb, trackio, pandas, polars, gpu
40
40
  ```
41
41
 
42
42
  ## Next
@@ -40,14 +40,12 @@ dynamic = ["version"]
40
40
 
41
41
  [project.optional-dependencies]
42
42
  # Local-first by default: remote backends and rich frames are opt-in.
43
- lark = ["slark>=0.1.28"]
44
43
  wandb = ["wandb>=0.21.0"]
45
44
  trackio = ["trackio>=0.4.0"]
46
45
  pandas = ["pandas>=1.5"]
47
46
  polars = ["polars>=0.20"]
48
47
  gpu = ["nvidia-ml-py>=12.0"]
49
48
  all = [
50
- "slark>=0.1.28",
51
49
  "wandb>=0.21.0",
52
50
  "trackio>=0.4.0",
53
51
  "pandas>=1.5",
@@ -72,7 +70,6 @@ docs = [
72
70
  ]
73
71
  dev = [
74
72
  "ipykernel>=6.30.1",
75
- "slark>=0.1.28",
76
73
  "wandb>=0.21.0",
77
74
  "trackio>=0.4.0",
78
75
  "numpy>=1.24",
@@ -6,7 +6,7 @@ import json
6
6
  import smtplib
7
7
  from email.message import EmailMessage
8
8
 
9
- from ..models import AlertLevel, AlertMessage, ChannelConfig
9
+ from ..models import AlertLevel, AlertMessage
10
10
  from .base import (
11
11
  AlertBackend,
12
12
  SendError,
@@ -16,6 +16,7 @@ from .base import (
16
16
  render_html,
17
17
  render_text,
18
18
  )
19
+ from .cards import build_card, card_payload
19
20
 
20
21
  LEVEL_EMOJI = {
21
22
  AlertLevel.DEBUG: "🔍",
@@ -56,13 +57,22 @@ class UrlBackend(AlertBackend):
56
57
  self.url, payload, self.timeout, self.config.options.get("headers")
57
58
  )
58
59
 
59
- def post_checked(self, payload: dict):
60
- """DingTalk and WeCom report failures via errcode inside an HTTP 200 body."""
61
- body = self.post(payload)
60
+ def post_reply(self, payload: dict) -> dict:
61
+ """Post, and decode the reply these APIs put their verdict in.
62
+
63
+ An unreadable body is not a failure: the POST itself succeeded, and some
64
+ proxies answer 200 with nothing at all.
65
+ """
66
+ body = self.post(payload) # outside the try: a failed POST must raise
62
67
  try:
63
68
  result = json.loads(body)
64
69
  except Exception:
65
- return
70
+ return {}
71
+ return result if isinstance(result, dict) else {}
72
+
73
+ def post_checked(self, payload: dict):
74
+ """DingTalk and WeCom report failures via errcode inside an HTTP 200 body."""
75
+ result = self.post_reply(payload)
66
76
  code = result.get("errcode")
67
77
  if code:
68
78
  raise SendError(
@@ -73,45 +83,31 @@ class UrlBackend(AlertBackend):
73
83
 
74
84
 
75
85
  class LarkBackend(UrlBackend):
76
- type = "lark"
77
-
78
- def __init__(self, config: ChannelConfig):
79
- super().__init__(config)
80
- self._client = None
86
+ """Feishu/Lark bot webhook, as an interactive card."""
81
87
 
82
- def _lark(self):
83
- if self._client is None:
84
- from slark import Lark
88
+ type = "lark"
85
89
 
86
- options = {k: v for k, v in self.config.options.items() if k != "headers"}
87
- self._client = Lark(webhook=self.url, **options)
88
- return self._client
90
+ # Lark answers HTTP 200 and puts the verdict in the body
91
+ RETRYABLE_CODES = frozenset({9499, 11232, 19024})
89
92
 
90
93
  def send(self, message: AlertMessage):
91
94
  title = f"{LEVEL_EMOJI.get(message.level, '')} {message.title}".strip()
92
- text = render_text(message)
93
- subtitle = message.subtitle
94
- try:
95
- client = self._lark()
96
- except ImportError as e: # pragma: no cover - optional extra
95
+ card = build_card(
96
+ render_text(message),
97
+ title,
98
+ message.subtitle,
99
+ message.traceback,
100
+ failed=message.level >= AlertLevel.ERROR,
101
+ )
102
+ result = self.post_reply(card_payload(card))
103
+ # Success is code 0, or StatusCode 0 from the older bot endpoint
104
+ code = result.get("code") or result.get("StatusCode") or 0
105
+ if code:
106
+ detail = result.get("msg") or result.get("StatusMessage")
97
107
  raise SendError(
98
- f'The lark channel needs slark: pip install "expr_tracker[lark]" ({e})',
99
- retryable=False,
100
- ) from e
101
- try:
102
- if message.level >= AlertLevel.ERROR:
103
- client.webhook.post_error_card(
104
- msg=text,
105
- traceback=message.traceback or "",
106
- title=title,
107
- subtitle=subtitle,
108
- )
109
- else:
110
- client.webhook.post_success_card(
111
- msg=text, title=title, subtitle=subtitle
112
- )
113
- except Exception as e:
114
- raise SendError(f"Lark webhook failed: {e}") from e
108
+ f"Lark rejected the message: code={code} msg={detail!r}",
109
+ retryable=code in self.RETRYABLE_CODES,
110
+ )
115
111
 
116
112
 
117
113
  class SlackBackend(UrlBackend):
@@ -0,0 +1,102 @@
1
+ """Lark (Feishu) interactive message cards.
2
+
3
+ The card layout is the one ``slark`` builds, reproduced here as plain dicts so
4
+ the Lark channel needs no third-party client: a webhook post is one JSON body
5
+ over stdlib HTTP, which is what every other channel already does.
6
+
7
+ Reference: https://open.feishu.cn/document/server-docs/im-v1/message-card
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Any
14
+
15
+ GREEN = "green"
16
+ RED = "red"
17
+ YES_ICON = "yes_filled"
18
+ ERROR_ICON = "error_filled"
19
+
20
+
21
+ def _heading(content: str) -> dict[str, Any]:
22
+ """A section heading. Lark has no heading element, hence the wrapping."""
23
+ return {
24
+ "tag": "column_set",
25
+ "flex_mode": "none",
26
+ "horizontal_spacing": "default",
27
+ "horizontal_align": "left",
28
+ "background_style": "default",
29
+ "columns": [
30
+ {
31
+ "tag": "column",
32
+ "background_style": "default",
33
+ "elements": [
34
+ {
35
+ "tag": "div",
36
+ "text": {
37
+ "tag": "plain_text",
38
+ "content": content,
39
+ "text_size": "heading",
40
+ "text_align": "left",
41
+ "text_color": "default",
42
+ },
43
+ }
44
+ ],
45
+ "width": "auto",
46
+ "weight": 1,
47
+ "vertical_align": "top",
48
+ "vertical_spacing": "default",
49
+ }
50
+ ],
51
+ }
52
+
53
+
54
+ def _code_block(content: str, language: str = "txt") -> dict[str, Any]:
55
+ return {
56
+ "tag": "markdown",
57
+ "content": f"```{language}\n{content}\n```",
58
+ "text_align": "left",
59
+ "text_size": "normal",
60
+ }
61
+
62
+
63
+ def _header(title: str, subtitle: str, template: str, icon: str) -> dict[str, Any]:
64
+ return {
65
+ "title": {"tag": "plain_text", "content": title},
66
+ "subtitle": {"tag": "plain_text", "content": subtitle},
67
+ "template": template,
68
+ "ud_icon": {"token": icon},
69
+ }
70
+
71
+
72
+ def build_card(
73
+ message: str,
74
+ title: str,
75
+ subtitle: str | None = None,
76
+ traceback: str | None = None,
77
+ *,
78
+ failed: bool = False,
79
+ ) -> dict[str, Any]:
80
+ """One card: a coloured header, the message, and a traceback if there is one."""
81
+ elements: list[dict[str, Any]] = [
82
+ _heading("Message"),
83
+ _code_block(message),
84
+ ]
85
+ if traceback and traceback.strip():
86
+ # Skipped when absent: most alerts are metric conditions, not crashes,
87
+ # and an empty "Traceback" code block on every one of them is noise
88
+ elements += [_heading("Traceback"), _code_block(traceback.strip(), "")]
89
+ return {
90
+ "header": _header(
91
+ title,
92
+ subtitle if subtitle is not None else time.strftime("%Y-%m-%d %H:%M:%S"),
93
+ RED if failed else GREEN,
94
+ ERROR_ICON if failed else YES_ICON,
95
+ ),
96
+ "elements": elements,
97
+ }
98
+
99
+
100
+ def card_payload(card: dict[str, Any]) -> dict[str, Any]:
101
+ """The webhook envelope a card has to travel in."""
102
+ return {"msg_type": "interactive", "card": card}
@@ -75,27 +75,30 @@ def test_custom_headers_are_forwarded(captured):
75
75
  assert captured[0]["headers"] == {"X-Token": "t"}
76
76
 
77
77
 
78
- def test_lark_uses_error_card_for_errors(monkeypatch):
79
- calls: list = []
80
-
81
- class FakeWebhook:
82
- def post_success_card(self, **kwargs):
83
- calls.append(("success", kwargs))
84
-
85
- def post_error_card(self, **kwargs):
86
- calls.append(("error", kwargs))
87
-
88
- class FakeLark:
89
- def __init__(self, **kwargs):
90
- self.webhook = FakeWebhook()
91
-
92
- backend = build("lark")
93
- monkeypatch.setattr(backend, "_lark", lambda: FakeLark())
94
- backend.send(message(level="info"))
95
- backend.send(message(level="error", traceback="tb"))
96
- assert [kind for kind, _ in calls] == ["success", "error"]
97
- assert calls[1][1]["traceback"] == "tb"
98
- assert calls[0][1]["title"].endswith("Title") # level emoji prefix
78
+ def test_lark_sends_an_interactive_card(captured):
79
+ build("lark").send(message(level="info"))
80
+ payload = captured[0]["payload"]
81
+ assert payload["msg_type"] == "interactive"
82
+ assert payload["card"]["header"]["title"]["content"].endswith("Title")
83
+
84
+
85
+ @pytest.mark.parametrize(
86
+ ("level", "template"),
87
+ [("info", "green"), ("warning", "green"), ("error", "red"), ("critical", "red")],
88
+ )
89
+ def test_the_lark_card_colour_follows_the_level(captured, level, template):
90
+ build("lark").send(message(level=level))
91
+ assert captured[0]["payload"]["card"]["header"]["template"] == template
92
+
93
+
94
+ def test_a_lark_card_carries_the_traceback(captured):
95
+ build("lark").send(message(level="error", traceback="tb here"))
96
+ blocks = [
97
+ e["content"]
98
+ for e in captured[0]["payload"]["card"]["elements"]
99
+ if e["tag"] == "markdown"
100
+ ]
101
+ assert any("tb here" in block for block in blocks)
99
102
 
100
103
 
101
104
  def test_callable_backend_requires_handler():