apmtools 0.2.54__tar.gz → 0.2.55__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 (29) hide show
  1. {apmtools-0.2.54 → apmtools-0.2.55}/PKG-INFO +1 -1
  2. {apmtools-0.2.54 → apmtools-0.2.55}/pyproject.toml +1 -1
  3. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/classes.py +12 -1
  4. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/plot_app.py +17 -3
  5. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/ui.py +129 -30
  6. {apmtools-0.2.54 → apmtools-0.2.55}/.gitignore +0 -0
  7. {apmtools-0.2.54 → apmtools-0.2.55}/LICENSE +0 -0
  8. {apmtools-0.2.54 → apmtools-0.2.55}/README.md +0 -0
  9. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/__init__.py +0 -0
  10. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/data_processing.py +0 -0
  11. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/functions.py +0 -0
  12. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/legacy.py +0 -0
  13. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/plot_configs.py +0 -0
  14. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/__init__.py +0 -0
  15. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/appearance.py +0 -0
  16. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/color_editor.py +0 -0
  17. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/columns.py +0 -0
  18. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/config.py +0 -0
  19. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/data.py +0 -0
  20. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/elements.py +0 -0
  21. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/export.py +0 -0
  22. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/filter_config.py +0 -0
  23. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/histogram_multi.py +0 -0
  24. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  25. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/multi_axis.py +0 -0
  26. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/shiny/summary.py +0 -0
  29. {apmtools-0.2.54 → apmtools-0.2.55}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.54
3
+ Version: 0.2.55
4
4
  Summary: A collection of tools for processing air pollution monitoring data
5
5
  Project-URL: Homepage, https://github.com/federlorenz/apmtools
6
6
  Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.54"
7
+ version = "0.2.55"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
1011
1011
 
1012
1012
  class Summary(pd.DataFrame):
1013
1013
 
1014
- _metadata = ['metadata', 'plots']
1014
+ _metadata = ['metadata', 'plots', 'variable', 'description']
1015
1015
 
1016
1016
  @property
1017
1017
  def _constructor(self):
@@ -1030,6 +1030,17 @@ class Summary(pd.DataFrame):
1030
1030
  raise TypeError("Summary.plot must be None or a list")
1031
1031
  super().__setattr__(name, value)
1032
1032
 
1033
+ def __setstate__(self, state):
1034
+ """Restore DataFrame state and default metadata absent in older pickles."""
1035
+ super().__setstate__(state)
1036
+ # Some older pickles contain an instance-level _metadata list, which
1037
+ # masks the corrected class declaration and breaks future copies.
1038
+ self.__dict__.pop("_metadata", None)
1039
+ for name, default in (("plots", None), ("variable", None),
1040
+ ("description", ""), ("metadata", set())):
1041
+ if name not in self.__dict__:
1042
+ object.__setattr__(self, name, default)
1043
+
1033
1044
  def __finalize__(self, other, method=None, **kwargs):
1034
1045
  if isinstance(other, Summary):
1035
1046
  self.variable = getattr(other, "variable", None)
@@ -83,12 +83,26 @@ class PlotApp:
83
83
  import shinyswatch
84
84
 
85
85
  app_ui = ui.page_fluid(
86
+ ui.tags.button("Hide toolbars", type="button", class_="btn btn-sm btn-outline-secondary my-0", id="standalone-toolbar-collapse-toggle", **{"aria-expanded": "true"}),
86
87
  ui.div(
87
- ui.input_action_button("open_dataset", "Open Dataset", class_="btn-primary"),
88
+ ui.input_action_button("open_dataset", "Open Dataset", class_="btn-outline-primary"),
89
+ ui.div(id="standalone-save-dataset-slot"),
88
90
  ui.output_text("dataset_status", inline=True),
89
- class_="d-flex align-items-center gap-3 my-2",
91
+ ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme ms-auto"),
92
+ class_="d-flex align-items-center gap-1 my-0 standalone-dataset-toolbar",
90
93
  ),
91
- ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme", style="position:absolute;right:1.5rem;top:.5rem;z-index:10"),
94
+ ui.tags.script("""
95
+ (() => {
96
+ const move = () => {
97
+ const slot = document.getElementById('standalone-save-dataset-slot');
98
+ const button = document.querySelector('#summary-dataset-toolbar button');
99
+ if (slot && button && button.parentElement !== slot) slot.appendChild(button);
100
+ };
101
+ new MutationObserver(move).observe(document.body, {childList:true, subtree:true});
102
+ document.addEventListener('DOMContentLoaded', move);
103
+ move();
104
+ })();
105
+ """),
92
106
  ui.output_ui("dataset_workspace"),
93
107
  title="PlotApp",
94
108
  )
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
  from .filter_config import filter_config_fields as _filter_config_fields
4
4
  import threading
5
+ import json
5
6
  from datetime import date, datetime, time, timedelta
6
7
  from collections.abc import Sequence
7
8
  import numpy as np
@@ -1528,6 +1529,21 @@ if (!window.__apmColorPickerInstalled) {
1528
1529
  panel_bounds = {name: _pad_axis_bounds(bounds) for name, bounds in panel_bounds.items()}
1529
1530
  limits = {key: _format_panel_bounds(panel_bounds, key) for key in limits}
1530
1531
  for field, value in limits.items():
1532
+ # A restored plot's explicit axis bounds are authoritative.
1533
+ # The preparation observer fires after the saved inputs mount;
1534
+ # overwriting them here caused the saved limits to flash and
1535
+ # then revert to automatically calculated limits. Continue
1536
+ # recalculating defaults when the user changes X/Y variables.
1537
+ # Restoration is asynchronous: the group, aggregation and
1538
+ # plot selectors can briefly disagree with the saved values
1539
+ # while mounting. That is NOT a user edit and must never
1540
+ # erase a persisted bound. Release the guard only when an
1541
+ # actual X/Y selection change has been observed after the
1542
+ # saved selectors were fully initialized.
1543
+ if (not axis_selection_changed
1544
+ and field in initial_filters
1545
+ and str(initial_filters[field]).strip()):
1546
+ continue
1531
1547
  if backend == "matplotlib":
1532
1548
  ui.update_text_area(field, value=value)
1533
1549
  else:
@@ -1653,6 +1669,12 @@ if (!window.__apmColorPickerInstalled) {
1653
1669
  @reactive.effect
1654
1670
  @reactive.event(input.plot_type, ignore_init=True)
1655
1671
  def reset_on_plot_type_change():
1672
+ # During a restored tab's initial mount, Shiny can report a
1673
+ # transient plot type. Do not clear persisted axis bounds (or
1674
+ # other appearance settings) until the restored selectors have
1675
+ # settled and a real plot-type change can be observed.
1676
+ if initial_filters and not _axis_selection_armed[0]:
1677
+ return
1656
1678
  # Reset every plot-specific selection when the plot type changes.
1657
1679
  for _i in range(max_subset_stages):
1658
1680
  ui.update_select(f"subset_column_{_i}", selected="")
@@ -1781,6 +1803,16 @@ if (!window.__apmColorPickerInstalled) {
1781
1803
  labels = [txt.get_text() for leg in legends for txt in leg.get_texts()]
1782
1804
  excluded = ("Y variable", "Grouping", "Legend", "All observations")
1783
1805
  labels = [label for label in labels if label.strip() and label not in excluded]
1806
+ # A faceted plot can temporarily expose no legend while Shiny
1807
+ # swaps axes (or when the panel variable consumes the only
1808
+ # grouping dimension). Do not clear the editor identities in
1809
+ # that transient state: remounting the text inputs on every
1810
+ # panel transition can disconnect their reactive watchers.
1811
+ with reactive.isolate():
1812
+ panel_mode = (backend == "matplotlib" and
1813
+ bool(_optional_input("multi_panel", False)))
1814
+ if panel_mode and not labels and known_keys:
1815
+ return
1784
1816
  # If this is merely a relabelled rendering of the existing
1785
1817
  # traces, do not rediscover/rename their identities.
1786
1818
  expected = [mapping.get(key, key) for key in known_keys]
@@ -2370,17 +2402,34 @@ if (!window.__apmColorPickerInstalled) {
2370
2402
  .summary-plot-container {width:100%; height:auto; aspect-ratio: 2 / 1; min-width:0; position:relative;}
2371
2403
  .summary-plot-container > * {width:100% !important;}
2372
2404
  .summary-plot-container .shiny-plot-output {min-height:400px;}
2373
- /* Keep the shared app theme picker right-aligned in the top toolbar.
2374
- This selector is independent of each plot's Plot appearance theme. */
2375
- .summary-top-toolbar {display:flex; align-items:flex-end; gap:0.75rem;
2376
- flex-wrap:wrap; margin-bottom:1rem;}
2377
- .summary-toolbar-actions {display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center;}
2378
- .summary-app-theme {margin-left:auto; width:220px; max-width:100%;}
2405
+ .summary-top-toolbar {display:flex; align-items:center; gap:0.5rem;
2406
+ flex-wrap:nowrap; margin-bottom:0; min-width:0;}
2407
+ .summary-toolbar-actions {display:flex; gap:0.4rem; flex-wrap:nowrap;
2408
+ align-items:center; min-width:0; overflow-x:auto;}
2409
+ .summary-toolbar-actions .btn {white-space:nowrap;}
2410
+ .summary-toolbar-right {display:flex; gap:0.4rem; align-items:center;
2411
+ margin-left:auto; flex-shrink:0;}
2412
+ .summary-app-theme {width:200px; flex-shrink:0;}
2379
2413
  .summary-app-theme .shiny-input-container {width:100%; margin-bottom:0;}
2380
- @media (max-width: 480px) {
2381
- .summary-app-theme {width:100%;}
2382
- }
2383
- """),
2414
+ .summary-dataset-toolbar {display:flex; align-items:center; gap:0.5rem;
2415
+ margin-bottom:0; min-width:0;}
2416
+ .summary-dataset-toolbar .btn {white-space:nowrap;}
2417
+ .summary-dataset-toolbar .summary-app-theme {margin-left:auto;}
2418
+ .summary-toolbar-collapse-toggle {margin-bottom:0; padding-top:0.12rem; padding-bottom:0.12rem;}
2419
+ .summary-dataset-toolbar .shiny-input-container,
2420
+ .summary-toolbar-actions .shiny-input-container {margin-bottom:0 !important;}
2421
+ .summary-dataset-toolbar .btn, .summary-toolbar-actions .btn {padding:0.22rem 0.65rem; font-size:0.875rem; line-height:1.5;}
2422
+ .summary-dataset-toolbar .shiny-input-container,
2423
+ .standalone-dataset-toolbar .shiny-input-container {width:auto !important; flex:0 0 auto;}
2424
+ .standalone-dataset-toolbar {margin-bottom:0 !important; gap:0.35rem !important;}
2425
+ .standalone-dataset-toolbar .btn {padding:0.22rem 0.65rem; font-size:0.875rem; line-height:1.5;}
2426
+ .summary-dataset-toolbar {gap:0.35rem;}
2427
+ .summary-toolbar-collapsed .summary-dataset-toolbar,
2428
+ .summary-toolbar-collapsed .summary-toolbar-actions {display:none !important;}
2429
+ body:has(#standalone-toolbar-collapse-toggle) .summary-toolbar-collapse-toggle {display:none;}
2430
+ body:has(#standalone-toolbar-collapse-toggle).summary-toolbar-collapsed
2431
+ .standalone-dataset-toolbar {display:none !important;}
2432
+ """),
2384
2433
  ui.tags.script("""
2385
2434
  (() => {
2386
2435
  // A Plotly relayout mutates its DOM. Observing all descendants and
@@ -2418,24 +2467,34 @@ if (!window.__apmColorPickerInstalled) {
2418
2467
  }).observe(document.body, {childList:true, subtree:true});
2419
2468
  document.addEventListener('shown.bs.tab', () => requestAnimationFrame(install));
2420
2469
  install();
2470
+ // Delegated listener also works when a dataset workspace is replaced.
2471
+ document.addEventListener('click', (event) => {
2472
+ const toggle = event.target.closest('#summary-toolbar-collapse-toggle, #standalone-toolbar-collapse-toggle');
2473
+ if (!toggle) return;
2474
+ const collapsed = document.body.classList.toggle('summary-toolbar-collapsed');
2475
+ document.querySelectorAll('#summary-toolbar-collapse-toggle, #standalone-toolbar-collapse-toggle').forEach(button => {
2476
+ button.textContent = collapsed ? 'Show toolbars' : 'Hide toolbars';
2477
+ button.setAttribute('aria-expanded', String(!collapsed));
2478
+ });
2479
+ });
2421
2480
  })();
2422
2481
  """),
2423
2482
  ui.div(
2483
+ ui.tags.button("Hide toolbars", type="button", class_="btn btn-sm btn-outline-secondary summary-toolbar-collapse-toggle", id="summary-toolbar-collapse-toggle", **{"aria-expanded": "true"}),
2424
2484
  ui.div(
2425
- ui.div(
2426
- ui.input_action_button("add_tab", "+ New plot", class_="btn-primary"),
2427
- ui.input_action_button("add_table", "+ New table", class_="btn-primary"),
2428
- ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
2429
- ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
2430
- ui.input_action_button("restore_plot", "Load saved plot", class_="btn-outline-primary"),
2431
- ui.input_action_button("delete_saved_plot", "Delete plot", class_="btn-outline-danger"),
2432
- ui.input_action_button("save_dataset_pickle", "Save dataset", class_="btn-outline-primary"),
2433
- class_="summary-toolbar-actions",
2434
- ),
2435
- # Exactly one app-wide theme picker, outside dynamically added
2436
- # plot modules. shinyswatch provides its full theme catalogue.
2437
- ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme") if include_theme_picker else ui.div(class_="summary-app-theme"),
2438
- class_="summary-top-toolbar",
2485
+ ui.input_action_button("save_dataset_pickle", "Save dataset", class_="btn-outline-primary"),
2486
+ ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme") if include_theme_picker else ui.div(),
2487
+ class_="summary-dataset-toolbar",
2488
+ id="summary-dataset-toolbar",
2489
+ ),
2490
+ ui.div(
2491
+ ui.input_action_button("add_tab", "New plot", class_="btn-primary"),
2492
+ ui.input_action_button("add_table", "New table", class_="btn-primary"),
2493
+ ui.input_action_button("retain_plot", "Save Plot", class_="btn-outline-primary"),
2494
+ ui.input_action_button("restore_plot", "Load Plot", class_="btn-outline-primary"),
2495
+ ui.input_action_button("delete_saved_plot", "Delete Plot", class_="btn-outline-danger"),
2496
+ ui.input_action_button("close_tab", "Close tab", class_="btn-outline-danger"),
2497
+ class_="summary-toolbar-actions summary-top-toolbar",
2439
2498
  ),
2440
2499
  ui.tags.style("""
2441
2500
  .summary-info-content {max-width: 980px; margin: 1.2rem auto 3rem;
@@ -2503,6 +2562,19 @@ if (!window.__apmColorPickerInstalled) {
2503
2562
  manager.update(selected, tab_filters[selected]())
2504
2563
  manager.retain(selected, input.retained_plot_name())
2505
2564
  saved_revision.set(saved_revision.get() + 1)
2565
+ # Update the existing tab label without remounting its Shiny module.
2566
+ # Replacing the nav panel would destroy the live plot controls.
2567
+ saved_label = str(input.retained_plot_name()).strip()
2568
+ ui.insert_ui(
2569
+ ui.tags.script(
2570
+ "(function(){const tab=" + json.dumps(selected) +
2571
+ ", label=" + json.dumps(saved_label) +
2572
+ "; const a=document.querySelector('[data-value=\"'+CSS.escape(tab)+'\"]');" +
2573
+ "if(a){const span=a.querySelector('span'); if(span)span.textContent=label; else a.textContent=label;}" +
2574
+ "})();"
2575
+ ),
2576
+ selector="body", where="beforeEnd",
2577
+ )
2506
2578
  ui.modal_remove()
2507
2579
  ui.notification_show("Plot configuration retained in Summary.plots", type="message")
2508
2580
  except Exception as exc:
@@ -2543,7 +2615,8 @@ if (!window.__apmColorPickerInstalled) {
2543
2615
  ui.p("Save the current Summary, including saved plot configurations, as a pickle file."),
2544
2616
  ui.input_text("dataset_pickle_path", "Destination path on the computer running Shiny",
2545
2617
  value="summary.pkl", placeholder="/path/to/drive/summary.pkl"),
2546
- ui.p("For a mounted drive, enter its full directory path. Existing files will not be overwritten."),
2618
+ ui.p("For a mounted drive, enter its full directory path. Existing files can be overwritten if you confirm."),
2619
+ ui.input_checkbox("overwrite_dataset_pickle", "Overwrite existing file", value=False),
2547
2620
  title="Save dataset",
2548
2621
  footer=ui.div(ui.modal_button("Cancel"),
2549
2622
  ui.input_action_button("confirm_save_dataset_pickle", "Save pickle", class_="btn-primary")),
@@ -2566,8 +2639,11 @@ if (!window.__apmColorPickerInstalled) {
2566
2639
  raise ValueError("The filename must end in .pkl")
2567
2640
  if not target.parent.is_dir():
2568
2641
  raise FileNotFoundError(f"Directory does not exist: {target.parent}")
2569
- if target.exists():
2570
- raise FileExistsError(f"File already exists: {target}")
2642
+ overwrite = bool(input.overwrite_dataset_pickle())
2643
+ if target.exists() and not overwrite:
2644
+ raise FileExistsError(f"File already exists: {target}. Check Overwrite existing file to replace it.")
2645
+ if target.exists() and not target.is_file():
2646
+ raise ValueError(f"Destination is not a regular file: {target}")
2571
2647
  # Serialize to a temporary file, then publish atomically without
2572
2648
  # overwriting an existing file. Preserve the original on errors.
2573
2649
  temp_path = None
@@ -2576,10 +2652,32 @@ if (!window.__apmColorPickerInstalled) {
2576
2652
  prefix=".apmtools-", suffix=".tmp",
2577
2653
  delete=False) as stream:
2578
2654
  temp_path = Path(stream.name)
2579
- pickle.dump(manager.summary, stream, protocol=pickle.HIGHEST_PROTOCOL)
2655
+ # The manager owns retained plot presets. Never serialize
2656
+ # the working plotting frame, which may have stale metadata.
2657
+ source = manager.summary
2658
+ retained = manager.saved()
2659
+ if not hasattr(source, "plots"):
2660
+ raise RuntimeError("Source Summary has no plots attribute")
2661
+ # Serialize an independent snapshot so that a concurrent
2662
+ # Shiny event cannot mutate metadata during the write.
2663
+ snapshot = source.copy(deep=False)
2664
+ snapshot.plots = retained
2665
+ pickle.dump(snapshot, stream, protocol=pickle.HIGHEST_PROTOCOL)
2580
2666
  stream.flush()
2581
2667
  os.fsync(stream.fileno())
2582
- os.link(temp_path, target) # fails if target already exists
2668
+ # Verify what was actually written before publishing it.
2669
+ with temp_path.open("rb") as check_stream:
2670
+ restored = pickle.load(check_stream)
2671
+ restored_plots = getattr(restored, "plots", None)
2672
+ if restored_plots != retained:
2673
+ raise RuntimeError(
2674
+ "Pickle verification failed: saved plot configurations "
2675
+ "were not preserved; no dataset file was published"
2676
+ )
2677
+ if overwrite:
2678
+ os.replace(temp_path, target) # atomic replacement after verification
2679
+ else:
2680
+ os.link(temp_path, target) # never overwrite unless explicitly requested
2583
2681
  finally:
2584
2682
  if temp_path is not None:
2585
2683
  temp_path.unlink(missing_ok=True)
@@ -2613,7 +2711,8 @@ if (!window.__apmColorPickerInstalled) {
2613
2711
  number = next_tab.get() + 1
2614
2712
  tab_id = f"plot_{number}"
2615
2713
  inherited = manager.restore(preset_id, tab_id)
2616
- panel = ui.nav_panel(f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2714
+ saved_name = next((p.get("name") for p in manager.saved() if p.get("id") == preset_id), None)
2715
+ panel = ui.nav_panel(saved_name or f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2617
2716
  tab_filters[tab_id] = plot_tab_server(tab_id, inherited, config_tab_id=tab_id)
2618
2717
  register_draft(tab_id, tab_filters[tab_id])
2619
2718
  next_tab.set(number)
File without changes
File without changes
File without changes