labnotes 0.0.5__tar.gz → 0.0.7__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 (40) hide show
  1. {labnotes-0.0.5 → labnotes-0.0.7}/PKG-INFO +1 -1
  2. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/create_app.py +4 -1
  3. labnotes-0.0.7/src/labnotes/list_runs.py +29 -0
  4. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/load_experiment.py +12 -3
  5. labnotes-0.0.7/src/labnotes/models.py +9 -0
  6. labnotes-0.0.7/src/labnotes/parse_run_view.py +20 -0
  7. labnotes-0.0.7/src/labnotes/sort_href.py +13 -0
  8. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/static/labnotes.css +65 -4
  9. labnotes-0.0.7/src/labnotes/static/row-link.js +9 -0
  10. labnotes-0.0.7/src/labnotes/static/view-toggle.js +15 -0
  11. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/templates/experiment.html +22 -4
  12. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/templates/index.html +10 -2
  13. labnotes-0.0.5/src/labnotes/list_runs.py +0 -12
  14. {labnotes-0.0.5 → labnotes-0.0.7}/.gitignore +0 -0
  15. {labnotes-0.0.5 → labnotes-0.0.7}/CHANGELOG.md +0 -0
  16. {labnotes-0.0.5 → labnotes-0.0.7}/MIGRATIONS.md +0 -0
  17. {labnotes-0.0.5 → labnotes-0.0.7}/README.md +0 -0
  18. {labnotes-0.0.5 → labnotes-0.0.7}/pyproject.toml +0 -0
  19. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/__init__.py +0 -0
  20. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/cell_progress.py +0 -0
  21. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/cli.py +0 -0
  22. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/count_runs.py +0 -0
  23. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/drain_watch.py +0 -0
  24. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/dump_experiments.py +0 -0
  25. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/format_value.py +0 -0
  26. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/list_experiments.py +0 -0
  27. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/open_experiments.py +0 -0
  28. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/open_runs.py +0 -0
  29. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/pivot_cells.py +0 -0
  30. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/read_design.py +0 -0
  31. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/read_note.py +0 -0
  32. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/render_markdown.py +0 -0
  33. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/run_columns.py +0 -0
  34. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/serve_app.py +0 -0
  35. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/sql_quote.py +0 -0
  36. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/study_progress.py +0 -0
  37. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/summarize_design.py +0 -0
  38. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/templates/base.html +0 -0
  39. {labnotes-0.0.5 → labnotes-0.0.7}/src/labnotes/templates/macros.html +0 -0
  40. {labnotes-0.0.5 → labnotes-0.0.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -52,7 +52,10 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
52
52
  async def experiment(request: Request) -> Response:
53
53
  state = request.app.state
54
54
  loaded = await load_experiment(
55
- state.experiments, state.runs, request.path_params["name"]
55
+ state.experiments,
56
+ state.runs,
57
+ request.path_params["name"],
58
+ request.query_params,
56
59
  )
57
60
  if loaded is None:
58
61
  raise HTTPException(404, "No such experiment")
@@ -0,0 +1,29 @@
1
+ from dirsql import DirSQL
2
+
3
+ from labnotes.models import RunView
4
+ from labnotes.sql_quote import quote_identifier, quote_string
5
+
6
+
7
+ async def list_runs(
8
+ runs: DirSQL, study: str, completed_run: str, view: RunView
9
+ ) -> list[dict[str, object]]:
10
+ where = [f"study = {quote_string(study)}"]
11
+ if view.status:
12
+ negate = {"complete": "", "incomplete": "NOT "}[view.status]
13
+ where.append(f"{negate}({completed_run})")
14
+ where += [
15
+ f"instr(lower(CAST({quote_identifier(column)} AS TEXT)),"
16
+ f" lower({quote_string(value)})) > 0"
17
+ for column, value in view.filters.items()
18
+ ]
19
+ direction = "DESC" if view.descending else "ASC"
20
+ if view.sort == "status":
21
+ order = f"labnotes_completed {direction}"
22
+ elif view.sort:
23
+ order = f"{quote_identifier(view.sort)} {direction}"
24
+ else:
25
+ order = "2 DESC"
26
+ return await runs.query(
27
+ f"SELECT ({completed_run}) AS labnotes_completed, * FROM runs"
28
+ f" WHERE {' AND '.join(where)} ORDER BY {order}"
29
+ )
@@ -1,18 +1,21 @@
1
1
  import json
2
+ from collections.abc import Mapping
2
3
  from typing import cast
3
4
 
4
5
  from dirsql import DirSQL
5
6
 
6
7
  from labnotes.list_runs import list_runs
8
+ from labnotes.parse_run_view import parse_run_view
7
9
  from labnotes.render_markdown import render_markdown
8
10
  from labnotes.run_columns import run_columns
11
+ from labnotes.sort_href import sort_href
9
12
  from labnotes.sql_quote import quote_string
10
13
  from labnotes.study_progress import study_progress
11
14
  from labnotes.summarize_design import summarize_design
12
15
 
13
16
 
14
17
  async def load_experiment(
15
- experiments: DirSQL, runs: DirSQL, name: str
18
+ experiments: DirSQL, runs: DirSQL, name: str, params: Mapping[str, str]
16
19
  ) -> dict[str, object] | None:
17
20
  found = await experiments.query(
18
21
  f"SELECT name, study, design FROM experiments WHERE name = {quote_string(name)}"
@@ -29,10 +32,12 @@ async def load_experiment(
29
32
  study = None if row["study"] is None else str(row["study"])
30
33
  columns = await run_columns(runs)
31
34
  progress = await study_progress(runs, study, summary, columns)
35
+ shown = columns[:1] + [key for key in varies if key in columns[1:]]
36
+ view = parse_run_view(params, shown)
32
37
  listed = (
33
38
  []
34
39
  if study is None
35
- else await list_runs(runs, study, str(summary["completed_run"]))
40
+ else await list_runs(runs, study, str(summary["completed_run"]), view)
36
41
  )
37
42
  return {
38
43
  "name": row["name"],
@@ -44,5 +49,9 @@ async def load_experiment(
44
49
  for note in notes
45
50
  ],
46
51
  "runs_list": listed,
47
- "run_columns": columns[:1] + [key for key in varies if key in columns[1:]],
52
+ "run_columns": shown,
53
+ "run_view": view,
54
+ "sort_hrefs": {
55
+ column: sort_href(view, column) for column in ["status", *shown]
56
+ },
48
57
  }
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class RunView:
6
+ sort: str | None = None
7
+ descending: bool = False
8
+ status: str | None = None
9
+ filters: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,20 @@
1
+ from collections.abc import Mapping
2
+
3
+ from labnotes.models import RunView
4
+
5
+ STATUSES = {"complete", "incomplete"}
6
+
7
+
8
+ def parse_run_view(params: Mapping[str, str], columns: list[str]) -> RunView:
9
+ sort = params.get("sort")
10
+ status = params.get("status")
11
+ return RunView(
12
+ sort=sort if sort in columns or sort == "status" else None,
13
+ descending=params.get("order") == "desc",
14
+ status=status if status in STATUSES else None,
15
+ filters={
16
+ column: value
17
+ for column in columns
18
+ if (value := params.get(f"f.{column}", "").strip())
19
+ },
20
+ )
@@ -0,0 +1,13 @@
1
+ from urllib.parse import urlencode
2
+
3
+ from labnotes.models import RunView
4
+
5
+
6
+ def sort_href(view: RunView, column: str) -> str:
7
+ params = {"sort": column}
8
+ if view.sort == column and not view.descending:
9
+ params["order"] = "desc"
10
+ if view.status:
11
+ params["status"] = view.status
12
+ params.update({f"f.{name}": value for name, value in view.filters.items()})
13
+ return f"?{urlencode(params)}#runs"
@@ -163,9 +163,8 @@ h3 {
163
163
  }
164
164
  .ledger td { padding: 1.1rem 1.25rem; border-bottom: 1px solid var(--rule); vertical-align: middle; }
165
165
  .ledger tbody tr:last-child td { border-bottom: 0; }
166
- .ledger tbody tr { position: relative; cursor: pointer; }
166
+ .ledger tbody tr { cursor: pointer; }
167
167
  .ledger tbody tr:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
168
- .ledger .title-link::after { content: ""; position: absolute; inset: 0; }
169
168
  .ledger .num { text-align: right; }
170
169
  .ledger th.num { text-align: right; }
171
170
 
@@ -291,14 +290,14 @@ h3 {
291
290
 
292
291
  /* Runs */
293
292
  .runs {
294
- --row: 1.5rem repeat(var(--columns), minmax(0, 1fr));
293
+ --row: 2.75rem repeat(var(--columns), minmax(0, 1fr));
295
294
  background: var(--surface);
296
295
  border: 1px solid var(--rule);
297
296
  border-radius: 10px;
298
297
  overflow: hidden;
299
298
  font-size: 0.86rem;
300
299
  }
301
- .run-head, .run summary {
300
+ .run-head, .run summary, .run-filters {
302
301
  display: grid;
303
302
  grid-template-columns: var(--row);
304
303
  gap: 1rem;
@@ -313,6 +312,25 @@ h3 {
313
312
  color: var(--ink-3);
314
313
  border-bottom: 1px solid var(--rule);
315
314
  }
315
+ .run-head .sort { color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
316
+ .run-head .sort:hover, .run-head .sorted { color: var(--ink); }
317
+ .run-head .arrow { margin-left: 0.3rem; color: var(--accent); }
318
+ .run-filters { padding-top: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--rule); background: var(--paper); }
319
+ .run-filters input, .run-filters select {
320
+ width: 100%;
321
+ min-width: 0;
322
+ font: inherit;
323
+ font-size: 0.82rem;
324
+ padding: 0.2rem 0.45rem;
325
+ color: var(--ink);
326
+ background: var(--surface);
327
+ border: 1px solid var(--rule);
328
+ border-radius: 5px;
329
+ }
330
+ .run-filters select { padding: 0.2rem 0; }
331
+ .run-filters input:focus, .run-filters select:focus { outline: none; border-color: var(--accent); }
332
+ .run-empty { margin: 0; padding: 1rem 1.25rem; }
333
+ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; color: var(--ink-3); }
316
334
  .run { border-bottom: 1px solid var(--rule); }
317
335
  .run:last-child { border-bottom: 0; }
318
336
  .run summary { cursor: pointer; list-style: none; }
@@ -338,6 +356,49 @@ h3 {
338
356
  .record code { background: none; padding: 0; word-break: break-word; }
339
357
  .record pre { margin: 0; white-space: pre-wrap; word-break: break-word; max-height: 22rem; overflow: auto; font-size: 0.78rem; color: var(--ink-2); }
340
358
 
359
+ /* Index view toggle */
360
+ .view-toggle {
361
+ float: right;
362
+ display: inline-flex;
363
+ margin-top: 0.6rem;
364
+ border: 1px solid var(--rule-strong);
365
+ border-radius: 7px;
366
+ overflow: hidden;
367
+ }
368
+ .view-toggle button {
369
+ font: inherit;
370
+ font-size: 0.82rem;
371
+ padding: 0.3rem 0.8rem;
372
+ border: 0;
373
+ background: var(--surface);
374
+ color: var(--ink-2);
375
+ cursor: pointer;
376
+ }
377
+ .view-toggle button + button { border-left: 1px solid var(--rule-strong); }
378
+ .view-toggle button[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); }
379
+
380
+ /* Index as cards: the same table, laid out as a grid. */
381
+ .ledger[data-view="cards"] { display: block; background: none; border: 0; border-radius: 0; overflow: visible; }
382
+ .ledger[data-view="cards"] thead { display: none; }
383
+ .ledger[data-view="cards"] tbody {
384
+ display: grid;
385
+ grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
386
+ gap: 1rem;
387
+ }
388
+ .ledger[data-view="cards"] tbody tr {
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 0.7rem;
392
+ padding: 1.1rem 1.25rem;
393
+ background: var(--surface);
394
+ border: 1px solid var(--rule);
395
+ border-radius: 10px;
396
+ }
397
+ .ledger[data-view="cards"] tbody tr:hover { background: var(--surface); border-color: var(--accent); }
398
+ .ledger[data-view="cards"] td { display: block; padding: 0; border: 0; text-align: left; }
399
+ .ledger[data-view="cards"] .progress-col { width: auto; }
400
+ .ledger[data-view="cards"] td[data-label]::before { content: attr(data-label) " "; color: var(--ink-3); font-size: 0.82rem; }
401
+
341
402
  @media (max-width: 52rem) {
342
403
  main, .masthead { padding-left: 1.1rem; padding-right: 1.1rem; }
343
404
  h1 { font-size: 2rem; }
@@ -0,0 +1,9 @@
1
+ // Safari ignores position: relative on <tr>, so a stretched ::after link covers the whole page.
2
+ for (const row of document.querySelectorAll(".ledger tbody tr")) {
3
+ const link = row.querySelector(".title-link");
4
+ row.addEventListener("click", (event) => {
5
+ if (event.target.closest("a") || getSelection().toString()) return;
6
+ if (event.metaKey || event.ctrlKey) window.open(link.href, "_blank");
7
+ else location.href = link.href;
8
+ });
9
+ }
@@ -0,0 +1,15 @@
1
+ const ledger = document.querySelector(".ledger");
2
+ const buttons = document.querySelectorAll(".view-toggle button");
3
+
4
+ function show(view) {
5
+ ledger.dataset.view = view;
6
+ for (const button of buttons) {
7
+ button.setAttribute("aria-pressed", String(button.dataset.view === view));
8
+ }
9
+ localStorage.setItem("labnotes.view", view);
10
+ }
11
+
12
+ for (const button of buttons) {
13
+ button.addEventListener("click", () => show(button.dataset.view));
14
+ }
15
+ show(localStorage.getItem("labnotes.view") ?? "table");
@@ -121,13 +121,29 @@
121
121
 
122
122
  {% if e.study %}
123
123
  <section id="runs" class="block">
124
- <h2>Runs <span class="count num">{{ e.runs_list | length }}</span></h2>
125
- {% if e.runs_list %}
124
+ {% set view = e.run_view %}
125
+ {% set narrowed = view.status or view.filters %}
126
+ <h2>Runs <span class="count num">{{ e.runs_list | length }}{% if narrowed %} of {{ e.runs }}{% endif %}</span>
127
+ {% if narrowed %}<a class="clear" href="{{ '?sort=' ~ (view.sort | urlencode) ~ ('&order=desc' if view.descending else '') if view.sort else '?' }}#runs">Clear filters</a>{% endif %}</h2>
128
+ {% if e.runs %}
126
129
  <div class="runs" style="--columns: {{ e.run_columns | length }}">
127
130
  <div class="run-head">
128
- <span></span>
129
- {% for column in e.run_columns %}<span>{{ column }}</span>{% endfor %}
131
+ {% for column in ['status'] + e.run_columns %}
132
+ {% set sorted = view.sort == column %}
133
+ <a class="sort{{ ' sorted' if sorted }}" href="{{ e.sort_hrefs[column] }}"{% if sorted %} aria-sort="{{ 'descending' if view.descending else 'ascending' }}"{% endif %} title="Sort by {{ column }}">{{ '' if column == 'status' else column }}{% if sorted %}<span class="arrow">{{ '↓' if view.descending else '↑' }}</span>{% endif %}</a>
134
+ {% endfor %}
130
135
  </div>
136
+ <form class="run-filters" method="get" action="#runs">
137
+ {% if view.sort %}<input type="hidden" name="sort" value="{{ view.sort }}">{% endif %}
138
+ {% if view.descending %}<input type="hidden" name="order" value="desc">{% endif %}
139
+ <select name="status" aria-label="Filter by status" onchange="this.form.requestSubmit()">
140
+ <option value=""{{ ' selected' if not view.status }}>all</option>
141
+ <option value="complete"{{ ' selected' if view.status == 'complete' }}>✓</option>
142
+ <option value="incomplete"{{ ' selected' if view.status == 'incomplete' }}>✕</option>
143
+ </select>
144
+ {% for column in e.run_columns %}<input type="search" name="f.{{ column }}" value="{{ view.filters.get(column, '') }}" placeholder="filter" aria-label="Filter {{ column }}">{% endfor %}
145
+ <button type="submit" hidden>Filter</button>
146
+ </form>
131
147
  {% for run in e.runs_list %}
132
148
  <details class="run">
133
149
  <summary>
@@ -142,6 +158,8 @@
142
158
  {% endfor %}
143
159
  </dl>
144
160
  </details>
161
+ {% else %}
162
+ <p class="empty run-empty">No runs match these filters.</p>
145
163
  {% endfor %}
146
164
  </div>
147
165
  {% else %}
@@ -3,13 +3,19 @@
3
3
  {% block crumbs %}<span class="crumb">{{ folder }}</span>{% endblock %}
4
4
  {% block main %}
5
5
  <section class="intro">
6
+ {% if experiments %}
7
+ <div class="view-toggle" role="group" aria-label="View">
8
+ <button type="button" data-view="table" aria-pressed="true">Table</button>
9
+ <button type="button" data-view="cards" aria-pressed="false">Cards</button>
10
+ </div>
11
+ {% endif %}
6
12
  <h1>Experiments</h1>
7
13
  <p class="lede">{{ experiments | length }} experiment{{ '' if experiments | length == 1 else 's' }},
8
14
  {{ experiments | sum(attribute='runs') }} runs recorded.</p>
9
15
  </section>
10
16
 
11
17
  {% if experiments %}
12
- <table class="ledger">
18
+ <table class="ledger" data-view="table">
13
19
  <thead>
14
20
  <tr>
15
21
  <th scope="col">Experiment</th>
@@ -29,7 +35,7 @@
29
35
  {% for key, values in experiment.varies.items() %}<span class="factor">{{ key }} <b>{{ values | length }}</b>{% if not loop.last or experiment.runs_per_cell %}<span class="times">×</span>{% endif %}</span> {% endfor %}
30
36
  {% if experiment.runs_per_cell %}<span class="factor">runs <b>{{ experiment.runs_per_cell }}</b></span>{% endif %}
31
37
  </td>
32
- <td class="num">{{ experiment.runs }}</td>
38
+ <td class="num" data-label="Runs">{{ experiment.runs }}</td>
33
39
  <td class="progress-col">
34
40
  {% if experiment.filled is not none and experiment.target %}
35
41
  <div class="progress-line">
@@ -43,6 +49,8 @@
43
49
  {% endfor %}
44
50
  </tbody>
45
51
  </table>
52
+ <script src="{{ url_for('static', path='view-toggle.js') }}" defer></script>
53
+ <script src="{{ url_for('static', path='row-link.js') }}" defer></script>
46
54
  {% else %}
47
55
  <p class="empty">No experiments found. Each experiment is a folder holding a <code>design.yaml</code> and its markdown notes.</p>
48
56
  {% endif %}
@@ -1,12 +0,0 @@
1
- from dirsql import DirSQL
2
-
3
- from labnotes.sql_quote import quote_string
4
-
5
-
6
- async def list_runs(
7
- runs: DirSQL, study: str, completed_run: str
8
- ) -> list[dict[str, object]]:
9
- return await runs.query(
10
- f"SELECT ({completed_run}) AS labnotes_completed, * FROM runs"
11
- f" WHERE study = {quote_string(study)} ORDER BY 2 DESC"
12
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes