Add Doris Barbell live workout logger
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-28 21:32:09 +00:00
parent ba958f7ef6
commit 1227814277
7 changed files with 507 additions and 3 deletions

View File

@@ -48,8 +48,9 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
- Homepage now renders John's imported five-day split as the default routine board. - Homepage now renders John's imported five-day split as the default routine board.
- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging. - Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging.
- Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days. - Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days.
- Live workout pages now let John start a session and save one exercise at a time mid-workout before finishing the routine into history.
- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist. - Homepage now includes John-focused recent history snapshots so the app is useful before charts exist.
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, progression summaries, homepage render, routine log flows, and form-post redirects. - Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, live workout session flows, progression summaries, homepage render, routine log flows, and form-post redirects.
## Next build slices ## Next build slices

View File

@@ -54,6 +54,7 @@ The current scaffold already supports:
- routine creation/listing on top of John's imported base split - routine creation/listing on top of John's imported base split
- structured workout logging with nested exercises and sets - structured workout logging with nested exercises and sets
- dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry - dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry
- live workout pages that let John start a session and save one exercise at a time mid-workout
- history endpoints for weight, measurements, and workouts - history endpoints for weight, measurements, and workouts
- exercise progression summaries with max weight and estimated 1RM per exercise - exercise progression summaries with max weight and estimated 1RM per exercise
- mobile-friendly homepage with HTML forms for: - mobile-friendly homepage with HTML forms for:

View File

@@ -140,6 +140,86 @@ def routine_log_sheet(request: Request, routine_id: str):
) )
@router.get('/routines/{routine_id}/live')
def live_workout_logger(request: Request, routine_id: str):
routine = _get_routine_or_404(request, routine_id)
return templates.TemplateResponse(
request=request,
name='live_workout.html',
context=_live_workout_context(request, routine),
)
@router.post('/routines/{routine_id}/live/start', status_code=303)
def start_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/add-exercise', status_code=303)
async def add_live_workout_exercise(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
exercise_name: str = Form(...),
set_count: int = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
exercise = await _parse_live_workout_exercise_form(request, routine, exercise_name, set_count)
request.app.state.store.add_active_workout_exercise(routine_id, user_id, exercise)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/finish', status_code=303)
def finish_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
notes: str = Form(''),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
raise HTTPException(status_code=400, detail='No live workout session in progress.')
if active_session['performed_at'] != performed_at:
raise HTTPException(status_code=400, detail='Performed-at timestamp does not match the active session.')
if not active_session.get('exercises'):
raise HTTPException(status_code=400, detail='Log at least one exercise before finishing the session.')
request.app.state.store.finish_active_workout_session(routine_id, user_id, notes.strip() or None)
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
@router.post('/workouts/routine-log', status_code=303) @router.post('/workouts/routine-log', status_code=303)
async def log_seeded_routine_workout( async def log_seeded_routine_workout(
request: Request, request: Request,
@@ -168,6 +248,11 @@ async def log_seeded_routine_workout(
def _dashboard_context(request: Request) -> dict: def _dashboard_context(request: Request) -> dict:
store = request.app.state.store store = request.app.state.store
summary = store.dashboard_summary() summary = store.dashboard_summary()
routines_with_live_state = []
for routine in summary['routines']:
active_session = store.get_active_workout_session(routine['id'], routine['user_id'])
routines_with_live_state.append({**routine, 'live_session': active_session})
summary['routines'] = routines_with_live_state
return { return {
'title': 'Doris Barbell', 'title': 'Doris Barbell',
'summary': summary, 'summary': summary,
@@ -178,6 +263,49 @@ def _dashboard_context(request: Request) -> dict:
} }
def _live_workout_context(request: Request, routine: dict) -> dict:
active_session = request.app.state.store.get_active_workout_session(routine['id'], routine['user_id'])
routine_sheet = _build_routine_log_sheet(request, routine)
remaining_exercises = _remaining_live_exercises(routine_sheet, active_session)
logged_exercises = []
if active_session is not None:
for exercise in active_session.get('exercises', []):
logged_exercises.append(
{
'exercise_name': exercise['exercise_name'],
'set_count': len(exercise.get('sets', [])),
'notes': exercise.get('notes'),
}
)
return {
'title': f"Live log · {routine['name']}",
'routine': routine,
'routine_sheet': routine_sheet,
'active_session': active_session,
'logged_exercises': logged_exercises,
'remaining_exercises': remaining_exercises,
'default_performed_at': active_session['performed_at'] if active_session else _default_performed_at_value(),
'routine_action_label': _routine_action_label(routine['name']),
}
def _remaining_live_exercises(routine_sheet: list[dict], active_session: dict | None) -> list[dict]:
completed_counts: dict[str, int] = {}
if active_session is not None:
for exercise in active_session.get('exercises', []):
normalized_name = exercise['exercise_name'].strip().lower()
completed_counts[normalized_name] = completed_counts.get(normalized_name, 0) + 1
remaining = []
for exercise in routine_sheet:
normalized_name = exercise['exercise_name'].strip().lower()
completed_count = completed_counts.get(normalized_name, 0)
if completed_count > 0:
completed_counts[normalized_name] = completed_count - 1
continue
remaining.append(exercise)
return remaining
def _optional_float(value: str) -> float | None: def _optional_float(value: str) -> float | None:
cleaned = value.strip() cleaned = value.strip()
if not cleaned: if not cleaned:
@@ -345,6 +473,58 @@ def _routine_action_label(routine_name: str) -> str:
return day_aliases.get(label, label) return day_aliases.get(label, label)
async def _parse_live_workout_exercise_form(
request: Request,
routine: dict,
exercise_name: str,
set_count: int,
) -> dict:
form = await request.form()
sheet = _build_routine_log_sheet(request, routine)
matching_exercise = next(
(exercise for exercise in sheet if exercise['exercise_name'].strip().lower() == exercise_name.strip().lower()),
None,
)
if matching_exercise is None:
raise HTTPException(status_code=400, detail=f'Unknown routine exercise: {exercise_name}')
default_reps_raw = matching_exercise.get('default_reps', '')
is_band_exercise = bool(matching_exercise.get('is_band'))
sets = []
for set_index in range(set_count):
reps_raw = str(form.get(f'set_{set_index}_reps', '')).strip()
weight_raw = str(form.get(f'set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw:
if is_band_exercise and default_reps_raw.isdigit():
reps_raw = default_reps_raw
else:
continue
if not reps_raw and default_reps_raw.isdigit():
reps_raw = default_reps_raw
if not reps_raw:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
)
if not weight_raw and not is_band_exercise:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
)
try:
reps = int(reps_raw)
weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
) from exc
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
if not sets:
raise HTTPException(status_code=400, detail=f'Log at least one set for {exercise_name}.')
exercise_notes = str(form.get('exercise_notes', '')).strip() or None
return {'exercise_name': matching_exercise['exercise_name'], 'notes': exercise_notes, 'sets': sets}
async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]: async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
form = await request.form() form = await request.form()
routine_exercises = routine.get('exercises', []) routine_exercises = routine.get('exercises', [])

View File

@@ -155,6 +155,7 @@ DEFAULT_STATE = {
'weight_entries': [], 'weight_entries': [],
'measurement_entries': [], 'measurement_entries': [],
'workout_sessions': [], 'workout_sessions': [],
'active_workout_sessions': [],
} }
LEGACY_TEMPLATE_ALIASES = { LEGACY_TEMPLATE_ALIASES = {
@@ -176,7 +177,11 @@ class JSONStore:
self._write(seeded_state) self._write(seeded_state)
def _read(self) -> dict[str, Any]: def _read(self) -> dict[str, Any]:
return json.loads(self.state_path.read_text()) payload = json.loads(self.state_path.read_text())
seeded_payload = _ensure_seeded_library(payload)
if seeded_payload != payload:
self._write(seeded_payload)
return seeded_payload
def _write(self, payload: dict[str, Any]) -> None: def _write(self, payload: dict[str, Any]) -> None:
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True)) self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
@@ -278,6 +283,84 @@ class JSONStore:
return routine return routine
return None return None
def get_active_workout_session(self, routine_id: str, user_id: str) -> dict[str, Any] | None:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
return session
return None
def start_active_workout_session(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
state['active_workout_sessions'] = [
session
for session in state['active_workout_sessions']
if not (
session['routine_id'] == payload['routine_id'] and session['user_id'] == payload['user_id']
)
]
saved = {
'id': uuid4().hex,
'routine_id': payload['routine_id'],
'routine_name': payload['routine_name'],
'user_id': payload['user_id'],
'performed_at': payload['performed_at'],
'notes': payload.get('notes'),
'exercises': [],
}
state['active_workout_sessions'].append(saved)
self._write(state)
return saved
def add_active_workout_exercise(self, routine_id: str, user_id: str, exercise: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
session['exercises'].append(
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
)
self._write(state)
return session
raise KeyError((routine_id, user_id))
def finish_active_workout_session(self, routine_id: str, user_id: str, notes: str | None = None) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] != routine_id or session['user_id'] != user_id:
continue
workout_payload = {
'user_id': user_id,
'performed_at': session['performed_at'],
'routine_name': session['routine_name'],
'notes': notes,
'exercises': [
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
for exercise in session['exercises']
],
}
saved_workout = self.add_workout(workout_payload)
refreshed_state = self._read()
refreshed_state['active_workout_sessions'] = [
active_session
for active_session in refreshed_state['active_workout_sessions']
if active_session.get('id') != session['id']
]
self._write(refreshed_state)
return saved_workout
raise KeyError((routine_id, user_id))
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]: def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read() state = self._read()
self._user_or_raise(state, payload['user_id']) self._user_or_raise(state, payload['user_id'])
@@ -430,6 +513,7 @@ def _ensure_seeded_library(state: dict[str, Any]) -> dict[str, Any]:
payload.setdefault('weight_entries', []) payload.setdefault('weight_entries', [])
payload.setdefault('measurement_entries', []) payload.setdefault('measurement_entries', [])
payload.setdefault('workout_sessions', []) payload.setdefault('workout_sessions', [])
payload.setdefault('active_workout_sessions', [])
for template in payload['exercise_templates']: for template in payload['exercise_templates']:
legacy_name = template.get('name', '').strip().lower() legacy_name = template.get('name', '').strip().lower()

View File

@@ -50,7 +50,11 @@
<li> <li>
<strong>{{ routine.name }}</strong> <strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises</p> <p class="muted">{{ routine.exercises|length }} exercises</p>
<p><a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a></p> <p>
<a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a>
·
<a class="nav-link" href="/routines/{{ routine.id }}/live">{% if routine.live_session %}Resume {{ routine.name }} live log{% else %}Resume live log{% endif %}</a>
</p>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Live workout logger</span>
<span class="pill">One-at-a-time entry</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Log one exercise at a time while you work through the session. Save each movement as you finish it, then close out the workout when you're done.</p>
{% if routine.notes %}
<p class="muted">{{ routine.notes }}</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Routine field sheet</span>
<span class="pill">{{ routine_sheet|length }} exercises</span>
</div>
<h2>Loaded exercises</h2>
<ul class="list case-list">
{% for exercise in routine_sheet %}
<li>
<strong>{{ exercise.exercise_name }}</strong>
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
{% if not active_session %}
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">Ready</span>
</div>
<h2>Start live session</h2>
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/start">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<button type="submit">Start {{ routine.name }} session</button>
</form>
</article>
</section>
{% else %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">In progress</span>
</div>
<h2>Session in progress</h2>
<p class="muted">Started at {{ active_session.performed_at }} · Logged exercises so far: {{ logged_exercises|length }}</p>
{% if logged_exercises %}
<ul class="list case-list">
{% for exercise in logged_exercises %}
<li>
<strong>{{ exercise.exercise_name }} · {{ exercise.set_count }} sets</strong>
{% if exercise.notes %}<p class="muted">{{ exercise.notes }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No exercises saved yet.</p>
{% endif %}
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/finish">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<label>Session notes
<textarea name="notes" rows="2" placeholder="Energy, pain, machine swaps, or anything worth remembering."></textarea>
</label>
<button type="submit">Finish {{ routine_action_label }} workout</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Remaining queue</span>
<span class="pill">{{ remaining_exercises|length }} left</span>
</div>
<h2>Ready to log next</h2>
{% if remaining_exercises %}
<p class="muted">Each card saves one exercise at a time, so you can log mid-workout without rewriting the whole session.</p>
{% for exercise in remaining_exercises %}
<section class="evidence-slab">
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/add-exercise">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<input type="hidden" name="exercise_name" value="{{ exercise.exercise_name }}">
<input type="hidden" name="set_count" value="{{ exercise.target_sets }}">
<div class="case-legend">
<span class="section-label">Exercise</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<h3>{{ exercise.exercise_name }}</h3>
<p class="muted">{% if exercise.target_reps %}Target reps {{ exercise.target_reps }}{% else %}Enter reps manually{% endif %}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid">
{% for set in exercise.sets %}
<div class="evidence-slab compact-slab">
<strong>Set {{ set.index + 1 }}</strong>
<label>Reps
<input type="number" name="set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
<button type="submit">Save {{ exercise.exercise_name }}</button>
</form>
</section>
{% endfor %}
{% else %}
<p class="muted">Everything in this routine is logged. Finish the workout to move it into history.</p>
{% endif %}
</article>
</section>
{% endif %}
{% endblock %}

View File

@@ -415,6 +415,108 @@ def test_seeded_routine_can_render_prefilled_log_sheet(tmp_path: Path) -> None:
assert re.search(r'name="performed_at" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"', body) assert re.search(r'name="performed_at" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"', body)
def test_seeded_routine_can_render_live_workout_page(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.get(f"/routines/{shoulders['id']}/live")
assert response.status_code == 200
body = response.text
assert 'Live workout logger' in body
assert 'Log one exercise at a time' in body
assert 'Start Thursday: Shoulders session' in body
assert 'Resume live log' in client.get('/').text
def test_live_workout_page_backfills_older_state_without_active_session_key(tmp_path: Path) -> None:
client = make_client(tmp_path)
legacy_state = json.loads((tmp_path / 'state.json').read_text())
legacy_state.pop('active_workout_sessions', None)
(tmp_path / 'state.json').write_text(json.dumps(legacy_state))
response = client.get('/routines/seed-routine-04/live')
assert response.status_code == 200
assert 'Start Thursday: Shoulders session' in response.text
def test_live_workout_session_can_log_exercises_one_at_a_time_and_finish(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
start_response = client.post(
f"/routines/{shoulders['id']}/live/start",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
},
follow_redirects=True,
)
assert start_response.status_code == 200
start_body = start_response.text
assert 'Session in progress' in start_body
assert 'Logged exercises so far: 0' in start_body
assert 'name="exercise_name"' in start_body
assert 'name="set_0_reps"' in start_body
assert 'name="set_0_weight_lbs"' in start_body
add_response = client.post(
f"/routines/{shoulders['id']}/live/add-exercise",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'exercise_name': 'Shoulder Press',
'set_count': '3',
'set_0_reps': '10',
'set_0_weight_lbs': '70',
'set_1_reps': '10',
'set_1_weight_lbs': '80',
'set_2_reps': '8',
'set_2_weight_lbs': '90',
},
follow_redirects=True,
)
assert add_response.status_code == 200
add_body = add_response.text
assert 'Logged exercises so far: 1' in add_body
assert 'Shoulder Press · 3 sets' in add_body
assert 'Upright Row' in add_body
finish_response = client.post(
f"/routines/{shoulders['id']}/live/finish",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'notes': 'Logged between sets.',
},
follow_redirects=False,
)
assert finish_response.status_code == 303
assert finish_response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercise_count'] == 1
assert payload[0]['set_count'] == 3
assert payload[0]['notes'] == 'Logged between sets.'
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
dashboard = client.get('/').text
assert 'Resume Thursday: Shoulders live log' not in dashboard
def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None: def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None:
client = make_client(tmp_path) client = make_client(tmp_path)