labnotes 0.0.5__tar.gz → 0.0.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 (39) hide show
  1. {labnotes-0.0.5 → labnotes-0.0.6}/PKG-INFO +1 -1
  2. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/create_app.py +4 -1
  3. labnotes-0.0.6/src/labnotes/list_runs.py +29 -0
  4. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/load_experiment.py +12 -3
  5. labnotes-0.0.6/src/labnotes/models.py +9 -0
  6. labnotes-0.0.6/src/labnotes/parse_run_view.py +20 -0
  7. labnotes-0.0.6/src/labnotes/sort_href.py +13 -0
  8. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/static/labnotes.css +64 -2
  9. labnotes-0.0.6/src/labnotes/static/view-toggle.js +15 -0
  10. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/templates/experiment.html +22 -4
  11. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/templates/index.html +9 -2
  12. {labnotes-0.0.5 → labnotes-0.0.6}/uv.lock +3 -0
  13. labnotes-0.0.5/src/labnotes/list_runs.py +0 -12
  14. {labnotes-0.0.5 → labnotes-0.0.6}/.gitignore +0 -0
  15. {labnotes-0.0.5 → labnotes-0.0.6}/CHANGELOG.md +0 -0
  16. {labnotes-0.0.5 → labnotes-0.0.6}/MIGRATIONS.md +0 -0
  17. {labnotes-0.0.5 → labnotes-0.0.6}/README.md +0 -0
  18. {labnotes-0.0.5 → labnotes-0.0.6}/pyproject.toml +0 -0
  19. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/__init__.py +0 -0
  20. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/cell_progress.py +0 -0
  21. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/cli.py +0 -0
  22. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/count_runs.py +0 -0
  23. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/drain_watch.py +0 -0
  24. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/dump_experiments.py +0 -0
  25. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/format_value.py +0 -0
  26. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/list_experiments.py +0 -0
  27. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/open_experiments.py +0 -0
  28. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/open_runs.py +0 -0
  29. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/pivot_cells.py +0 -0
  30. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/read_design.py +0 -0
  31. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/read_note.py +0 -0
  32. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/render_markdown.py +0 -0
  33. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/run_columns.py +0 -0
  34. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/serve_app.py +0 -0
  35. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/sql_quote.py +0 -0
  36. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/study_progress.py +0 -0
  37. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/summarize_design.py +0 -0
  38. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/templates/base.html +0 -0
  39. {labnotes-0.0.5 → labnotes-0.0.6}/src/labnotes/templates/macros.html +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.6
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"
@@ -291,14 +291,14 @@ h3 {
291
291
 
292
292
  /* Runs */
293
293
  .runs {
294
- --row: 1.5rem repeat(var(--columns), minmax(0, 1fr));
294
+ --row: 2.75rem repeat(var(--columns), minmax(0, 1fr));
295
295
  background: var(--surface);
296
296
  border: 1px solid var(--rule);
297
297
  border-radius: 10px;
298
298
  overflow: hidden;
299
299
  font-size: 0.86rem;
300
300
  }
301
- .run-head, .run summary {
301
+ .run-head, .run summary, .run-filters {
302
302
  display: grid;
303
303
  grid-template-columns: var(--row);
304
304
  gap: 1rem;
@@ -313,6 +313,25 @@ h3 {
313
313
  color: var(--ink-3);
314
314
  border-bottom: 1px solid var(--rule);
315
315
  }
316
+ .run-head .sort { color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
317
+ .run-head .sort:hover, .run-head .sorted { color: var(--ink); }
318
+ .run-head .arrow { margin-left: 0.3rem; color: var(--accent); }
319
+ .run-filters { padding-top: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--rule); background: var(--paper); }
320
+ .run-filters input, .run-filters select {
321
+ width: 100%;
322
+ min-width: 0;
323
+ font: inherit;
324
+ font-size: 0.82rem;
325
+ padding: 0.2rem 0.45rem;
326
+ color: var(--ink);
327
+ background: var(--surface);
328
+ border: 1px solid var(--rule);
329
+ border-radius: 5px;
330
+ }
331
+ .run-filters select { padding: 0.2rem 0; }
332
+ .run-filters input:focus, .run-filters select:focus { outline: none; border-color: var(--accent); }
333
+ .run-empty { margin: 0; padding: 1rem 1.25rem; }
334
+ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; color: var(--ink-3); }
316
335
  .run { border-bottom: 1px solid var(--rule); }
317
336
  .run:last-child { border-bottom: 0; }
318
337
  .run summary { cursor: pointer; list-style: none; }
@@ -338,6 +357,49 @@ h3 {
338
357
  .record code { background: none; padding: 0; word-break: break-word; }
339
358
  .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
359
 
360
+ /* Index view toggle */
361
+ .view-toggle {
362
+ float: right;
363
+ display: inline-flex;
364
+ margin-top: 0.6rem;
365
+ border: 1px solid var(--rule-strong);
366
+ border-radius: 7px;
367
+ overflow: hidden;
368
+ }
369
+ .view-toggle button {
370
+ font: inherit;
371
+ font-size: 0.82rem;
372
+ padding: 0.3rem 0.8rem;
373
+ border: 0;
374
+ background: var(--surface);
375
+ color: var(--ink-2);
376
+ cursor: pointer;
377
+ }
378
+ .view-toggle button + button { border-left: 1px solid var(--rule-strong); }
379
+ .view-toggle button[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); }
380
+
381
+ /* Index as cards: the same table, laid out as a grid. */
382
+ .ledger[data-view="cards"] { display: block; background: none; border: 0; border-radius: 0; overflow: visible; }
383
+ .ledger[data-view="cards"] thead { display: none; }
384
+ .ledger[data-view="cards"] tbody {
385
+ display: grid;
386
+ grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
387
+ gap: 1rem;
388
+ }
389
+ .ledger[data-view="cards"] tbody tr {
390
+ display: flex;
391
+ flex-direction: column;
392
+ gap: 0.7rem;
393
+ padding: 1.1rem 1.25rem;
394
+ background: var(--surface);
395
+ border: 1px solid var(--rule);
396
+ border-radius: 10px;
397
+ }
398
+ .ledger[data-view="cards"] tbody tr:hover { background: var(--surface); border-color: var(--accent); }
399
+ .ledger[data-view="cards"] td { display: block; padding: 0; border: 0; text-align: left; }
400
+ .ledger[data-view="cards"] .progress-col { width: auto; }
401
+ .ledger[data-view="cards"] td[data-label]::before { content: attr(data-label) " "; color: var(--ink-3); font-size: 0.82rem; }
402
+
341
403
  @media (max-width: 52rem) {
342
404
  main, .masthead { padding-left: 1.1rem; padding-right: 1.1rem; }
343
405
  h1 { font-size: 2rem; }
@@ -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,7 @@
43
49
  {% endfor %}
44
50
  </tbody>
45
51
  </table>
52
+ <script src="{{ url_for('static', path='view-toggle.js') }}" defer></script>
46
53
  {% else %}
47
54
  <p class="empty">No experiments found. Each experiment is a folder holding a <code>design.yaml</code> and its markdown notes.</p>
48
55
  {% endif %}
@@ -10,6 +10,9 @@ resolution-markers = [
10
10
  exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
11
11
  exclude-newer-span = "P3D"
12
12
 
13
+ [options.exclude-newer-package]
14
+ labnotes = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" }
15
+
13
16
  [[package]]
14
17
  name = "anyio"
15
18
  version = "4.15.1"
@@ -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