mpengine 0.3.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation
5
5
  Author: Amandeep Singh
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mpengine"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -83,4 +83,4 @@ __all__ = [
83
83
  "parts_to_molecules",
84
84
  ]
85
85
 
86
- __version__ = "0.3.0"
86
+ __version__ = "0.3.1"
@@ -1,12 +1,5 @@
1
- """The run banner - purely cosmetic, deliberately kept out of everything else.
2
-
3
- `orchestrator.py` imports exactly one name from here and calls it once. All the
4
- ASCII art, formatting and printing lives in this module, so the engine and the
5
- orchestration layer stay free of decoration.
6
-
7
- The spider is the mascot: a multiprocessing engine, and a creature famous for
8
- having eight legs. The art itself lives in `spider.txt` rather than as a string
9
- literal here - it is data, not code, the same call made for `worker_names.json`.
1
+ """
2
+ The run "spider" banner - purely cosmetic, deliberately kept out of everything else.
10
3
  """
11
4
 
12
5
  from __future__ import annotations
@@ -25,6 +25,15 @@ from typing import Any, Callable
25
25
  import cloudpickle
26
26
 
27
27
  _log = logging.getLogger("mpengine.engine")
28
+ # Lifecycle logging (dispatch start/done, worker-count clamping, progress
29
+ # milestones) is always captured in full by orchestrator.run()'s run.log, but
30
+ # is deliberately never allowed to bubble up past this logger to whatever the
31
+ # CALLER's own logging setup is (root, via logging.basicConfig or similar) -
32
+ # a caller configuring logging for their own unrelated purposes should not
33
+ # suddenly also see mpengine's internal chatter. Only "mpengine.summary" (the
34
+ # stored-here paths and the worker ranking, see orchestrator.py) is meant to
35
+ # surface there.
36
+ _log.propagate = False
28
37
 
29
38
 
30
39
  def expand_call(kargs: dict[str, Any]) -> Any:
@@ -108,6 +117,7 @@ def process_jobs(
108
117
  on_progress: Callable[[int, float, Any], None] | None = None,
109
118
  on_job_error: Callable[[int, BaseException], None] | None = None,
110
119
  text_progress: bool | None = None,
120
+ milestones: bool = True,
111
121
  ) -> list[Any]:
112
122
  """Ch.20 Snippet 20.9 (`processJobs`) - parallel dispatch over a process pool.
113
123
 
@@ -153,7 +163,12 @@ def process_jobs(
153
163
  is running AND stderr is a real terminal. That distinction matters in
154
164
  production - redirected to a log file, a `\\r` line per job is unreadable
155
165
  noise, so when the text display is off this logs periodic completion
156
- milestones at INFO instead. Pass True/False to force it either way.
166
+ milestones at INFO instead - unless `milestones=False`, which a caller
167
+ passes when `on_progress` is itself a live visual display (a tqdm bar,
168
+ say) rather than merely a logging hook: the milestones would otherwise be
169
+ redundant with - and print right alongside - that display. `on_progress`
170
+ alone isn't the right signal for this, since a caller can legitimately
171
+ want per-job callbacks (for logging, say) with no visual display at all.
157
172
 
158
173
  `on_job_error`, if given, is called as `on_job_error(index, exc)` for any
159
174
  job that cannot be *serialized* for submission, and that job is skipped
@@ -210,8 +225,11 @@ def process_jobs(
210
225
  if text_progress is None:
211
226
  text_progress = on_progress is None and sys.stderr.isatty()
212
227
  # When nothing is drawing live, log milestones so a redirected run still
213
- # shows movement - roughly ten lines, whatever the job count.
214
- milestone = max(1, n_submitted // 10) if not text_progress else 0
228
+ # shows movement - roughly ten lines, whatever the job count. `milestones`
229
+ # is the caller's explicit say on this, separate from text_progress/
230
+ # on_progress: it's the only signal that actually means "something else
231
+ # is already showing a visual progress display, don't duplicate it."
232
+ milestone = max(1, n_submitted // 10) if (not text_progress and milestones) else 0
215
233
 
216
234
  _log.info(
217
235
  "dispatch start task=%s n_jobs=%d n_workers=%d text_progress=%s",
@@ -36,6 +36,7 @@ from pathlib import Path
36
36
  from typing import Any, Callable, Iterator
37
37
 
38
38
  from tqdm import tqdm
39
+ from tqdm.contrib.logging import logging_redirect_tqdm
39
40
 
40
41
  from mpengine.banner import print_banner
41
42
  from mpengine.engine import expand_call, process_jobs, process_jobs_
@@ -48,6 +49,66 @@ N_WORKERS = os.cpu_count() or 4
48
49
  # who want to see this call logging.basicConfig(level=logging.INFO). The
49
50
  # spider banner is the one deliberate exception and still prints.
50
51
  _log = logging.getLogger("mpengine.orchestrator")
52
+ # See the matching note on "mpengine.engine" in engine.py: lifecycle logging
53
+ # is always captured in run.log but never bubbles up to the caller's own
54
+ # logging setup, so configuring logging for unrelated reasons doesn't also
55
+ # surface mpengine's internal chatter.
56
+ _log.propagate = False
57
+
58
+ # A separate logger, and deliberately NOT a child of "mpengine.orchestrator"
59
+ # (it sits directly under "mpengine" instead) - for the "what happened, where
60
+ # did it land" summary a person watching a terminal actually wants: the
61
+ # stored-here paths and the worker ranking. Kept apart from `_log` for two
62
+ # reasons: `_log` also carries dispatch-lifecycle chatter that would show up
63
+ # too if a handler were attached to it directly, and `_log.propagate = False`
64
+ # above would otherwise also cut this logger off from the caller's own
65
+ # logging setup, were it a child of `_log`. These are real `logging` calls
66
+ # throughout, never a print() dressed up to look like one -
67
+ # `_terminal_summary_handler` below is what makes them visible on an
68
+ # interactive terminal without the caller configuring anything, and normal
69
+ # propagation is what makes them visible via the caller's own setup too.
70
+ _summary_log = logging.getLogger("mpengine.summary")
71
+
72
+
73
+ def _has_external_handler() -> bool:
74
+ """Whether the caller has configured logging somewhere that would already
75
+ show these records - `logging.basicConfig()` (attaches to root) or a
76
+ handler attached directly to `mpengine`. If so, leave it alone entirely
77
+ rather than risk showing anything twice or fighting the caller's own
78
+ formatting."""
79
+ if logging.getLogger().handlers:
80
+ return True
81
+ return any(
82
+ not isinstance(h, logging.NullHandler)
83
+ for h in logging.getLogger("mpengine").handlers
84
+ )
85
+
86
+
87
+ @contextmanager
88
+ def _terminal_summary_handler(interactive: bool) -> Iterator[None]:
89
+ """Make `_summary_log` visible on an interactive terminal with zero
90
+ configuration, for the duration of one run.
91
+
92
+ Only ever active when `interactive` is True AND the caller has not
93
+ configured logging themselves (`_has_external_handler`) - prod stays
94
+ exactly as silent as the caller's own logging setup dictates, and a
95
+ caller who has taken control of logging is never overridden. Attached and
96
+ removed per run, mirroring `_run_log_file`, so nothing leaks across calls.
97
+ """
98
+ if not interactive or _has_external_handler():
99
+ yield
100
+ return
101
+ handler = logging.StreamHandler(sys.stdout)
102
+ handler.setFormatter(logging.Formatter("%(levelname)-7s %(name)s: %(message)s"))
103
+ previous_level = _summary_log.level
104
+ _summary_log.setLevel(logging.INFO)
105
+ _summary_log.addHandler(handler)
106
+ try:
107
+ yield
108
+ finally:
109
+ _summary_log.removeHandler(handler)
110
+ handler.close()
111
+ _summary_log.setLevel(previous_level)
51
112
 
52
113
  # one FileHandler-backed logger per worker *process*, lazily created on that
53
114
  # process's first job and reused for every job after - keyed by pid, which is
@@ -136,37 +197,46 @@ def _run_log_file(log_path: Path) -> Iterator[None]:
136
197
  so one run is one self-contained, durable record. That is the audit trail
137
198
  this library is actually differentiated on.
138
199
 
139
- The handler is attached to the shared "mpengine" parent logger, so records
140
- from both `mpengine.engine` and `mpengine.orchestrator` land in it.
141
-
142
- Level handling is deliberately restrained. A logger's level decides whether
143
- a record is created at all, and that decision is shared by every handler
144
- downstream - so forcing DEBUG here to enrich the file would also push
145
- DEBUG records into the caller's own handlers (a per-job line for all
146
- 10,000 jobs appearing in someone's console who asked for INFO). Instead the
147
- level is only lowered as far as INFO, and only when it was coarser than
148
- that, so the file always captures the full lifecycle and every failure
149
- without ever making the caller's output noisier than they configured. A
150
- caller who genuinely wants per-job DEBUG detail in the file just sets DEBUG
151
- themselves. Everything is restored on the way out, including if the run
152
- raises.
200
+ Attached directly to THREE loggers, not just the shared "mpengine" parent:
201
+ "mpengine.orchestrator" and "mpengine.engine" both set `propagate = False`
202
+ (so their lifecycle chatter never reaches a caller's own logging setup -
203
+ see the note by each `_log` definition), which means a handler placed
204
+ only on "mpengine" would never see their records at all. Attaching
205
+ directly to each stops there being any gap; "mpengine.summary" (the
206
+ stored-here paths and ranking) still climbs normally, so attaching to
207
+ "mpengine" as well is what captures those. Level handling is deliberately
208
+ restrained: forcing DEBUG on any of these would also push DEBUG records
209
+ into a caller's own handlers wherever they DO propagate (a per-job line
210
+ for all 10,000 jobs appearing in someone's console who asked for INFO) -
211
+ instead each logger's level is only ever lowered as far as INFO, and only
212
+ when it was coarser than that, so the file always captures the full
213
+ lifecycle and every failure without ever making the caller's own output
214
+ noisier than they configured. A caller who genuinely wants per-job DEBUG
215
+ detail in the file just sets DEBUG themselves. Everything is restored on
216
+ the way out, including if the run raises.
153
217
  """
154
- parent = logging.getLogger("mpengine")
218
+ loggers = [
219
+ logging.getLogger("mpengine"),
220
+ logging.getLogger("mpengine.orchestrator"),
221
+ logging.getLogger("mpengine.engine"),
222
+ ]
155
223
  handler = logging.FileHandler(log_path / "run.log", encoding="utf-8")
156
224
  handler.setLevel(logging.DEBUG)
157
225
  handler.setFormatter(
158
226
  logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
159
227
  )
160
- previous_level = parent.level
161
- if not parent.isEnabledFor(logging.INFO):
162
- parent.setLevel(logging.INFO)
163
- parent.addHandler(handler)
228
+ previous_levels = [lg.level for lg in loggers]
229
+ for lg in loggers:
230
+ if not lg.isEnabledFor(logging.INFO):
231
+ lg.setLevel(logging.INFO)
232
+ lg.addHandler(handler)
164
233
  try:
165
234
  yield
166
235
  finally:
167
- parent.removeHandler(handler)
236
+ for lg, level in zip(loggers, previous_levels):
237
+ lg.removeHandler(handler)
238
+ lg.setLevel(level)
168
239
  handler.close()
169
- parent.setLevel(previous_level)
170
240
 
171
241
 
172
242
  def _validate_labels(labels: list[str]) -> None:
@@ -388,15 +458,18 @@ def _progress_renderer(
388
458
  def _log_worker_ranking(stats: dict[int, WorkerStats]) -> None:
389
459
  """Rank workers fastest-to-slowest by seconds per atom.
390
460
 
391
- Emitted through `logging`, not `print`: this is run reporting, and a
392
- library that writes it unconditionally to stdout breaks piping, notebooks
393
- and log aggregation for anything embedding mpengine. Callers who want to
394
- see it configure logging (e.g. `logging.basicConfig(level=logging.INFO)`);
395
- callers who do not are no longer forced to.
396
-
397
- Note the caveat logged alongside: with uneven atom sizes - the normal case
398
- in quant work - a low s/atom can mean small atoms rather than a genuinely
399
- faster worker, so the fastest/slowest tags are a hint, not a measurement.
461
+ A real `_summary_log.info(...)` call, nothing else - visibility on an
462
+ interactive terminal comes entirely from `_terminal_summary_handler`
463
+ attaching a real handler for the run's duration, not from a second print
464
+ alongside the log call. This can only ever produce output when `stats` is
465
+ non-empty, which only happens in the first place when the terminal is
466
+ real (see `run()` - `stats` is populated solely by the live-display
467
+ renderer).
468
+
469
+ Note the caveat logged alongside: with uneven atom sizes - the normal
470
+ case in quant work - a low s/atom can mean small atoms rather than a
471
+ genuinely faster worker, so the fastest/slowest tags are a hint, not a
472
+ measurement.
400
473
  """
401
474
  if not stats:
402
475
  return
@@ -412,7 +485,7 @@ def _log_worker_ranking(stats: dict[int, WorkerStats]) -> None:
412
485
  f"avg {s.avg_atom_s:6.2f}s/atom {s.atoms_per_s:6.2f} atoms/s{tag}"
413
486
  )
414
487
  lines.append(" (uneven atom sizes: a low s/atom can mean small atoms, not a faster worker)")
415
- _log.info("\n".join(lines))
488
+ _summary_log.info("\n".join(lines))
416
489
 
417
490
 
418
491
  def run(
@@ -429,6 +502,7 @@ def run(
429
502
  n_workers: int = N_WORKERS,
430
503
  debug: bool = False,
431
504
  show_progress: bool = False,
505
+ text_progress: bool | None = None,
432
506
  ) -> RunSummary:
433
507
  """Run `func(**params)` for every dict in `param_sets`, organized.
434
508
 
@@ -455,6 +529,12 @@ def run(
455
529
  moment that worker finishes an atom. It also prints a fastest-to-slowest
456
530
  ranking at the end, and fills `RunSummary.worker_stats` (keyed by pid) so
457
531
  the same numbers are available programmatically.
532
+
533
+ `text_progress` overrides whether the book's `\r`-overwriting stderr line
534
+ runs (only relevant when `show_progress=False`; the default, `None`,
535
+ keeps today's behaviour - on when interactive, off otherwise). Force it
536
+ to `False` if you're watching per-job DEBUG/INFO logging instead: the two
537
+ would otherwise both write to the terminal and visually clash.
458
538
  """
459
539
  if base_dir is not None:
460
540
  base = Path(base_dir)
@@ -532,13 +612,28 @@ def run(
532
612
  f.write(f" {label}: {params}\n")
533
613
  f.write("\n")
534
614
 
615
+ # The terminal/prod split lives here: a real terminal gets the spider, the
616
+ # live progress display and the worker summary logs made visible with zero
617
+ # configuration, since those are things worth seeing live. Redirected -
618
+ # piped, under a supervisor, in CI - none of that shows; only `logging`
619
+ # output exists, for whatever the deployment itself configures to collect it.
620
+ interactive = sys.stderr.isatty()
621
+
535
622
  # One durable record per run: everything from here down - dispatch,
536
623
  # milestones, every job outcome, the summary - is captured to
537
624
  # <log_dir>/run.log as well as going to the caller's own handlers.
538
- with _run_log_file(log_path):
625
+ # `_terminal_summary_handler` is what makes the stored-here paths and the
626
+ # worker ranking (both real `_summary_log` calls, see above) actually
627
+ # visible on an interactive terminal without any caller configuration.
628
+ # Order matters: _terminal_summary_handler must check for an externally
629
+ # configured handler BEFORE _run_log_file attaches its own FileHandler to
630
+ # "mpengine" - reversed, it would mistake our own run.log handler for a
631
+ # caller-configured one and skip attaching the terminal handler entirely.
632
+ with _terminal_summary_handler(interactive), _run_log_file(log_path):
539
633
  worker_stats: dict[int, WorkerStats] = {}
540
634
 
541
- print_banner(task, n_workers, debug)
635
+ if interactive:
636
+ print_banner(task, n_workers, debug)
542
637
  _log.info(
543
638
  "run start run_id=%s task=%s func=%s n_jobs=%d n_workers=%d "
544
639
  "debug=%s show_progress=%s",
@@ -583,7 +678,7 @@ def run(
583
678
  # The live display is a terminal affordance. Interactively it is the whole
584
679
  # point; redirected to a log file tqdm's redraws are unreadable noise, so
585
680
  # in prod we fall back to the engine's periodic INFO milestones instead.
586
- interactive = sys.stderr.isatty()
681
+ # (`interactive` itself was already computed above, before the banner.)
587
682
  live_display = show_progress and not debug and interactive
588
683
  if show_progress and not debug and not interactive:
589
684
  _log.info(
@@ -597,7 +692,15 @@ def run(
597
692
  for r in raw_results:
598
693
  log_job(os.getpid(), 0.0, r)
599
694
  elif live_display:
600
- with _progress_renderer(len(jobs), task, worker_stats) as render:
695
+ # logging_redirect_tqdm is tqdm's own answer to exactly this
696
+ # clash: for its duration, any logging call that would otherwise
697
+ # write straight to the terminal (ours - log_job's per-job
698
+ # DEBUG/WARNING - or a caller's own configured handler) is instead
699
+ # routed through tqdm.write(), which clears the active bars,
700
+ # prints the line cleanly above them, and redraws them - instead
701
+ # of corrupting their redraw or spawning duplicate bar lines.
702
+ with _progress_renderer(len(jobs), task, worker_stats) as render, \
703
+ logging_redirect_tqdm():
601
704
  def on_progress(pid: int, atom_s: float, result: Any) -> None:
602
705
  log_job(pid, atom_s, result)
603
706
  render(pid, atom_s, result)
@@ -606,12 +709,24 @@ def run(
606
709
  jobs, task=task, n_workers=n_workers,
607
710
  on_progress=on_progress, on_job_error=on_job_error,
608
711
  text_progress=False,
712
+ # The tqdm bar above already shows aggregate progress -
713
+ # the engine's own milestone logging would just repeat it.
714
+ milestones=False,
609
715
  )
610
716
  else:
717
+ # `text_progress=None` keeps the existing default (the book's
718
+ # `\r` line whenever interactive and show_progress=False);
719
+ # passing it explicitly - e.g. to force it off so per-job DEBUG
720
+ # logging isn't fighting a `\r`-rewriting line on the same
721
+ # stream - always wins.
722
+ effective_text_progress = (
723
+ text_progress if text_progress is not None
724
+ else (not show_progress) and interactive
725
+ )
611
726
  raw_results = process_jobs(
612
727
  jobs, task=task, n_workers=n_workers,
613
728
  on_progress=log_job, on_job_error=on_job_error,
614
- text_progress=(not show_progress) and interactive,
729
+ text_progress=effective_text_progress,
615
730
  )
616
731
  elapsed_s = time.perf_counter() - t0
617
732
 
@@ -647,9 +762,17 @@ def run(
647
762
  run_id, task, n_ok, n_failed, elapsed_s,
648
763
  (len(raw_results) / elapsed_s) if elapsed_s > 0 else 0.0,
649
764
  )
650
- _log.info("Logs stored here - %s", log_path)
651
- _log.info("Output stored here - %s", output_path)
652
- _log.info("Manifest stored here - %s", manifest_path)
765
+ # Real _summary_log.info(...) calls - reaches run.log and any handler
766
+ # a prod deployment configures either way. On an interactive terminal
767
+ # with nothing configured, `_terminal_summary_handler` (entered above)
768
+ # is what makes them actually appear on screen - there is no separate
769
+ # print() standing in for a log line here.
770
+ for label, value in (
771
+ ("Logs stored here ", log_path),
772
+ ("Output stored here ", output_path),
773
+ ("Manifest stored here", manifest_path),
774
+ ):
775
+ _summary_log.info("%s - %s", label, value)
653
776
 
654
777
  return RunSummary(
655
778
  run_id=run_id,
@@ -2,8 +2,8 @@
2
2
  ,; '.
3
3
  ;: :;
4
4
  :: ::
5
- :: ::
6
- ': :
5
+ :: MULTI PROCESSOR ::
6
+ ': ENGAGED :
7
7
  :. :
8
8
  ;' :: :: '
9
9
  .' '; ;' '.
@@ -6,5 +6,5 @@
6
6
  "Basilisk", "Cyclone", "Draco", "Ember", "Fenrir", "Gargoyle", "Helix", "Icarus",
7
7
  "Juggernaut", "Karma", "Labyrinth", "Maverick", "Nexus", "Oracle", "Paradox", "Quantum",
8
8
  "Ronin", "Sentinel", "Talisman", "Ultra", "Valkyrie", "Warden", "Xenon", "Yeti",
9
- "Zodiac", "Anomaly", "Banshee", "Catalyst", "Dynamo", "Eclipse", "Frost", "Glitch"
9
+ "Zodiac", "Anomaly", "Banshee", "Catalyst", "Dynamo", "Eclipse", "Frost", "Glitch", "UltraLinda"
10
10
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation
5
5
  Author: Amandeep Singh
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes