Skip to content

Commit 10c2032

Browse files
authored
v0.6.2 (#9)
1 parent 7f343ef commit 10c2032

43 files changed

Lines changed: 1636 additions & 1040 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ jobs:
3737
test:
3838
name: Test
3939
runs-on: ubuntu-latest
40-
needs: lint
4140
strategy:
4241
fail-fast: false
4342
matrix:
@@ -58,7 +57,7 @@ jobs:
5857
cache: 'pip'
5958

6059
- name: Install test dependencies
61-
run: python -m pip install -e ".[dev]"
60+
run: python -m pip install -e . -r requirements-dev.txt
6261

6362
- name: Audit dependencies
6463
run: python -m pip_audit
@@ -78,7 +77,9 @@ jobs:
7877
build:
7978
name: Build
8079
runs-on: ubuntu-latest
81-
needs: test
80+
needs:
81+
- lint
82+
- test
8283
steps:
8384
- name: Checkout code
8485
uses: actions/checkout@v6

.github/workflows/pages.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ jobs:
3030
with:
3131
python-version: '3.11'
3232

33-
- name: Create venv and install docs dependencies
33+
- name: Install docs dependencies
3434
run: |
3535
python -m venv .venv
36-
.venv/bin/pip install -e ".[docs]"
36+
.venv/bin/pip install zensical
3737
3838
- name: Build site
3939
run: .venv/bin/zensical build

.pre-commit-config.yaml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,26 @@ repos:
1515
- --fix=lf
1616
- id: check-yaml
1717
- id: check-toml
18+
- id: detect-private-key
19+
- id: requirements-txt-fixer
20+
files: ^requirements-dev\.txt$
1821

1922
- repo: https://github.com/adrienverge/yamllint
2023
rev: v1.38.0
2124
hooks:
2225
- id: yamllint
2326

2427
- repo: https://github.com/astral-sh/ruff-pre-commit
25-
rev: v0.15.5
28+
rev: v0.15.8
2629
hooks:
2730
- id: ruff-format
2831
- id: ruff-check
2932

33+
- repo: https://github.com/codespell-project/codespell
34+
rev: v2.4.2
35+
hooks:
36+
- id: codespell
37+
3038
- repo: https://github.com/pre-commit/mirrors-mypy
3139
rev: v1.19.1
3240
hooks:
@@ -49,7 +57,7 @@ repos:
4957
additional_dependencies: ["bandit[toml]"]
5058

5159
- repo: https://github.com/semgrep/pre-commit
52-
rev: v1.154.0
60+
rev: v1.156.0
5361
hooks:
5462
- id: semgrep
5563
args: ["--config", "p/python", "--error"]

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ init: ## Set up dev env. Prompts for Python, or: make init PYTHON=python3.10
7474
fi; \
7575
if [ ! -d "$(VENV_DIR)" ]; then $$py -m venv "$(VENV_DIR)" >/dev/null; fi
7676
@$(VENV_BIN)/pip install --upgrade pip >/dev/null
77-
@$(VENV_BIN)/pip install -e ".[dev,docs]" >/dev/null
77+
@$(VENV_BIN)/pip install -e . -r requirements-dev.txt zensical >/dev/null
7878
@$(VENV_BIN)/pip install pre-commit >/dev/null
7979
@$(VENV_BIN)/pre-commit install >/dev/null
8080

@@ -118,7 +118,7 @@ test-verbose: check-venv ## Run BDD tests with full scenario/step output (for de
118118
@$(VENV_BIN)/coverage report --show-missing
119119

120120
# ============================================================================
121-
# Docs Targets (Zensical; docs deps installed by make init)
121+
# Docs Targets
122122
# ============================================================================
123123

124124
##@ Docs

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
</a>
55
</p>
66

7-
<p align="center"><strong>TimeRun</strong> — <em>Python package for time measurement.</em></p>
7+
<p align="center"><strong>TimeRun</strong> — <em>Structured timing for Python.</em></p>
88

99
<p align="center">
1010
<a href="https://pypi.org/project/timerun/"><img alt="Version" src="https://img.shields.io/pypi/v/timerun.svg"></a>
@@ -14,9 +14,9 @@
1414
<a href="https://pepy.tech/project/timerun"><img alt="Total Downloads" src="https://static.pepy.tech/badge/timerun"></a>
1515
</p>
1616

17-
TimeRun is a **single-file** Python package with **no dependencies** beyond the standard library. It records **wall-clock time** and **CPU time** for code blocks or function calls and supports optional **metadata** (e.g. run id, tags) per measurement.
17+
TimeRun is a **single-file** Python package with **no dependencies** beyond the standard library. It records **wall-clock time** and **CPU time** when you measure **a block** or **function calls** (one `Measurement` per block or per call) and supports optional **metadata** (e.g. run id, tags) and **callbacks** (`on_start` / `on_end`) per measurement.
1818

19-
For the full value proposition and positioning, see [Why TimeRun](https://hh-mwb.github.io/timerun/about/) on the docs site.
19+
For positioning and the full value proposition, see [Overview](https://hh-mwb.github.io/timerun/overview/) on the docs site.
2020

2121
## Installation
2222

@@ -72,9 +72,11 @@ datetime.timedelta(microseconds=8)
7272

7373
*Note: Argument `maxlen` caps how many measurements are kept (e.g. `@Timer(maxlen=10)`). By default the deque is unbounded.*
7474

75-
### Callbacks on Start and End
75+
### Callbacks
7676

77-
Optional `on_start` and `on_end` callbacks run once per measurement. Both receive the measurement instance (`on_start` before timings are set, `on_end` after). Typical uses are logging, forwarding to OpenTelemetry, or enqueueing to a metrics pipeline.
77+
Optional `on_start` and `on_end` callbacks run once per measurement. Both receive the `Measurement` instance — `on_start` before timings are set, `on_end` after. For example:
78+
79+
Print elapsed time when a block finishes:
7880

7981
```python
8082
>>> from timerun import Timer
@@ -84,6 +86,20 @@ Optional `on_start` and `on_end` callbacks run once per measurement. Both receiv
8486
0:00:00.000008
8587
```
8688

89+
Attach a trace id before each call starts:
90+
91+
```python
92+
>>> from uuid import uuid4
93+
>>> from timerun import Timer
94+
>>> @Timer(on_start=lambda m: m.metadata.update(trace_id=uuid4().hex))
95+
... def func():
96+
... return
97+
...
98+
>>> func()
99+
>>> func.measurements[-1].metadata
100+
{'trace_id': '8aa2c000c98843738a2f0d5d3600d052'}
101+
```
102+
87103
## Contributing
88104

89105
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/HH-MWB/timerun/blob/main/CONTRIBUTING.md) for setup, testing, and pull request guidelines.
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
title: Analyze results
3+
---
4+
15
# Analyze results
26

37
**Problem:** You have many measurements (e.g. from repeated runs or a decorator's `measurements` deque) and want to summarize or compare — mean, variance, confidence intervals.
@@ -27,7 +31,7 @@ measurements = list(my_func.measurements)
2731

2832
## What to extract
2933

30-
Each measurement has **wall time** and **CPU time**; use the one that matches your question (e.g. wall for latency, CPU for compute-bound work). Use `wall_time.duration` (nanoseconds, int) or `wall_time.timedelta` for float seconds. You can also use **metadata** to group or filter before computing stats (e.g. by `run_id`, `stage`) so you get per-group summaries.
34+
Each measurement has **wall time** and **CPU time**; use the one that matches your question (e.g. wall for latency, CPU for compute-bound work). Use `wall_time.duration` (nanoseconds, int) or `wall_time.timedelta.total_seconds()` for float seconds. You can also use **metadata** to group or filter before computing stats (e.g. by `run_id`, `stage`) so you get per-group summaries.
3135

3236
```python
3337
durations_ns = [m.wall_time.duration for m in measurements]
@@ -107,6 +111,4 @@ plt.show()
107111

108112
This plots the mean as a point with an error bar spanning the confidence interval. For more on confidence intervals and benchmarking, see your preferred stats or benchmarking reference.
109113

110-
**Back to:** [Recipes](index.md)
111-
112-
**See also:** For the `measurements` deque and `maxlen`, see [Measure functions](../guide/measure-functions.md). For collecting in `on_end`, see [Callbacks](../guide/callbacks.md).
114+
**See also:** [Measure function calls](../guide/measure-functions.md) for the `measurements` deque and `maxlen`. [Callbacks](../guide/callbacks.md) for collecting measurements in `on_end`.

docs/cookbook/index.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: Cookbook
3+
---
4+
5+
# Cookbook
6+
7+
Real-world patterns for using TimeRun: use metadata effectively, share results with your stack, time web traffic, and analyze timing data.
8+
9+
You already know the API from the [Guide](../guide/index.md): timer overview, measure a block, measure function calls, metadata, and callbacks. Here we show how to apply it to concrete problems.
10+
11+
1. **[Use metadata effectively](metadata.md)** — Add context (e.g. request id, stage) to every measurement by mutating metadata in `on_start`.
12+
2. **[Share results](share-results.md)** — Send measurements to logs, files, OpenTelemetry, or Prometheus using `on_end`.
13+
3. **[Time web requests](web-framework.md)** — Wrap HTTP requests with `Timer` in FastAPI, Flask, or Django.
14+
4. **[Analyze results](analyze-results.md)** — Collect measurements and compute summaries or confidence intervals with standard tools.

docs/cookbook/metadata.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
title: Use metadata effectively
3+
---
4+
5+
# Use metadata effectively
6+
7+
**Problem:** You want context on every measurement (e.g. request id, stage, experiment id) without repeating it in every `Timer()` call.
8+
9+
**Idea:** Metadata is attached to each measurement. You can **mutate `measurement.metadata` in `on_start`** (or inside the block) to add or change keys for that run. Each measurement gets its own copy of the initial metadata when the run starts, so mutating it in `on_start` only affects that measurement.
10+
11+
## Why this works
12+
13+
Each measurement gets its own deep copy of the metadata dict, so mutations in `on_start` or the block affect only that run. See [Guide: Metadata](../guide/metadata.md) for copy and isolation rules.
14+
15+
## Example: add run context in `on_start`
16+
17+
Omit metadata (or pass a dict); an empty dict is the default when you pass `None`. Fill it per run in `on_start` from context vars or thread-local storage:
18+
19+
```python
20+
from contextvars import ContextVar
21+
from timerun import Timer
22+
23+
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
24+
25+
def add_request_id(m):
26+
m.metadata["request_id"] = request_id_ctx.get()
27+
28+
with Timer(on_start=add_request_id) as m:
29+
pass # your code
30+
31+
# m.metadata now includes "request_id" for this run
32+
print(m.metadata) # e.g. {"request_id": "req-abc"}
33+
```
34+
35+
## Example: set tags inside the block
36+
37+
When context is fixed at the start of the run (request id, stage), **`on_start` is often clearer** than mutating inside the block. Mutating `m.metadata` in the block is still valid when values depend on **work you do inside the timed region** (outcome, branch taken, or a value known only after some steps):
38+
39+
```python
40+
with Timer(metadata={"stage": "ingest"}) as m:
41+
do_work()
42+
if some_condition:
43+
m.metadata["tag"] = "slow_path"
44+
# m.metadata is {"stage": "ingest", "tag": "slow_path"} when relevant
45+
```
46+
47+
## Example: invocation count with a closure
48+
49+
Use a factory that returns an `on_start` callback with its own counter so each measurement gets a monotonic call number (e.g. for a decorated hot path):
50+
51+
```python
52+
from timerun import Timer
53+
54+
55+
def make_invocation_callback():
56+
count = 0 # (1)!
57+
58+
def set_invocation(m):
59+
nonlocal count # (2)!
60+
count += 1
61+
m.metadata["invocation"] = count
62+
63+
return set_invocation # (3)!
64+
65+
66+
on_start = make_invocation_callback()
67+
for _ in range(3):
68+
with Timer(on_start=on_start) as m:
69+
pass # your code
70+
71+
# After each block, invocation is 1, 2, 3, ...; last m.metadata["invocation"] is 3
72+
```
73+
74+
1. Counter lives in the closure; each factory call gets its own independent sequence.
75+
2. `nonlocal` updates the enclosing `count` so every invocation of `set_invocation` sees the same running total.
76+
3. Return the inner function so `Timer` receives a stable `on_start` callback with shared state.
77+
78+
**See also:** [Guide: Metadata](../guide/metadata.md) for passing `metadata={...}` and copy rules.
Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
---
2+
title: Share results
3+
---
4+
15
# Share results
26

3-
**Problem:** You need to get measurements out of the process — to a log, a file, OpenTelemetry, or a metrics backend.
7+
**Problem:** You need to get measurements out of the process — to a log, a file, OpenTelemetry, Prometheus, or another metrics backend.
48

59
**Idea:** Use **`on_end`** (and optionally `on_start`) to push each measurement out when the run finishes. The callback receives the `Measurement` with `wall_time`, `cpu_time`, and `metadata` set.
610

711
## Log
812

9-
```python
13+
```python hl_lines="16"
1014
import logging
1115
from timerun import Timer
1216

@@ -61,23 +65,50 @@ from timerun import Timer
6165
# tracer = get_tracer(__name__)
6266

6367
def on_start(m):
64-
m.metadata["span"] = tracer.start_span("timerun")
68+
m.metadata["span"] = tracer.start_span("timerun") # (1)!
6569

6670
def on_end(m):
67-
span = m.metadata.get("span")
71+
span = m.metadata.get("span") # (2)!
6872
if span is None:
6973
return # If on_start didn't set a span, skip.
7074
span.set_attribute("wall_time_ns", m.wall_time.duration)
7175
span.set_attribute("cpu_time_ns", m.cpu_time.duration)
7276
for k, v in m.metadata.items():
7377
if k != "span" and v is not None:
7478
span.set_attribute(k, str(v))
75-
span.end()
79+
span.end() # (3)!
7680

7781
with Timer(on_start=on_start, on_end=on_end):
7882
do_work()
7983
```
8084

81-
**Next:** [Analyze results](analyze-results.md)
85+
1. Start the span before the timed work runs so nested operations can attach to the same trace context if your tracer supports it.
86+
2. Retrieve the span object you stashed on the `Measurement`; guard in case `on_start` failed or was skipped.
87+
3. End the span after attributes are set so duration and metadata are recorded on the same span.
88+
89+
## Prometheus
90+
91+
Use the [Prometheus Python client](https://github.com/prometheus/client_python) (`pip install prometheus-client`). Register a histogram (or summary) and observe wall-clock seconds in `on_end`:
92+
93+
```python
94+
from prometheus_client import Histogram
95+
from timerun import Timer
96+
97+
OPERATION_SECONDS = Histogram(
98+
"timerun_operation_seconds",
99+
"Wall time for timed operations (seconds)",
100+
buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, float("inf")),
101+
)
102+
103+
104+
def observe_wall_time(m):
105+
OPERATION_SECONDS.observe(m.wall_time.timedelta.total_seconds())
106+
107+
108+
with Timer(on_end=observe_wall_time):
109+
do_work()
110+
```
111+
112+
Expose metrics from your process with `start_http_server` or your framework’s integration so Prometheus can scrape them.
82113

83-
For callback basics, see [Reference: Callbacks](../guide/callbacks.md). For the OpenTelemetry API, see the [OpenTelemetry Python docs](https://opentelemetry.io/docs/languages/python/).
114+
**See also:** [Guide: Callbacks](../guide/callbacks.md) for when callbacks run. For the OpenTelemetry API, see the [OpenTelemetry Python docs](https://opentelemetry.io/docs/languages/python/).

0 commit comments

Comments
 (0)