diff --git a/documentation/project-layout.md b/documentation/project-layout.md index ec652e3..ae336e3 100644 --- a/documentation/project-layout.md +++ b/documentation/project-layout.md @@ -98,8 +98,8 @@ spikeforge/ cli.py save / list / compare CLI (spikeforge-benchmark) __main__.py python -m spikeforge.benchmark observability/ Opt-in structured logging + metrics - logging_setup.py configure_logging / reset_logging (reversible) - json_formatter.py JsonFormatter: one JSON object per log line + logging_setup.py configure_logging / reset_logging (reversible); + JSON shape via capsize_commons.logging.JsonFormatter registry.py MetricsRegistry: counters/gauges/timers timer.py Context-manager timer recording into a registry metrics.py Shared registry + snapshot/JSON helpers diff --git a/packages/spikeforge/pyproject.toml b/packages/spikeforge/pyproject.toml index c7d4f34..f3bbafc 100644 --- a/packages/spikeforge/pyproject.toml +++ b/packages/spikeforge/pyproject.toml @@ -56,6 +56,10 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + # Shared §14 JSON logging. Logging is part of the capsize-commons base + # install — 0.1.1 defines no separate "logging" extra — so the dependency + # carries no bracket. + "capsize-commons>=0.1.1", "torch>=2.5", "torchvision>=0.20", "snntorch>=1.0", diff --git a/spikeforge/__init__.py b/spikeforge/__init__.py index befadc9..6da94e2 100644 --- a/spikeforge/__init__.py +++ b/spikeforge/__init__.py @@ -6,19 +6,33 @@ older, narrower helper that loads an MNIST subset and rate-codes it -- it backs the tutorial demo and the animation walkthroughs, and it is not the class to reach for when training a network. + +The ML exports are resolved on first access (PEP 562) rather than at import +time, so ``import spikeforge`` no longer pulls ``torch`` / ``snntorch`` / +``torchvision``. That is what lets the non-ML surfaces — +``spikeforge.observability`` and ``spikeforge.config`` — be imported and +tested without the ML stack. ``from spikeforge import TrainingEngine`` and the +attribute names below are unchanged. """ -from spikeforge.encoding.delta_trainer import DeltaTrainer -from spikeforge.encoding.latency_trainer import ( - LatencyTrainer, - convert_to_time, -) -from spikeforge.encoding.random_spikegen import RandomSpikeGenerator -from spikeforge.training.logger import SNNTrainerLogger -from spikeforge.training.trainer import SNNTrainer -from spikeforge.training.training_engine import TrainingEngine +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + from spikeforge.version import __version__ +if TYPE_CHECKING: + from spikeforge.encoding.delta_trainer import DeltaTrainer + from spikeforge.encoding.latency_trainer import ( + LatencyTrainer, + convert_to_time, + ) + from spikeforge.encoding.random_spikegen import RandomSpikeGenerator + from spikeforge.training.logger import SNNTrainerLogger + from spikeforge.training.trainer import SNNTrainer + from spikeforge.training.training_engine import TrainingEngine + __all__ = [ "__version__", "TrainingEngine", @@ -29,3 +43,27 @@ class to reach for when training a network. "RandomSpikeGenerator", "convert_to_time", ] + +#: Public export -> defining module, resolved on first attribute access. +_EXPORTS = { + "DeltaTrainer": "spikeforge.encoding.delta_trainer", + "LatencyTrainer": "spikeforge.encoding.latency_trainer", + "convert_to_time": "spikeforge.encoding.latency_trainer", + "RandomSpikeGenerator": "spikeforge.encoding.random_spikegen", + "SNNTrainerLogger": "spikeforge.training.logger", + "SNNTrainer": "spikeforge.training.trainer", + "TrainingEngine": "spikeforge.training.training_engine", +} + + +def __getattr__(name: str) -> Any: + """Resolve a public ML export on first access (PEP 562).""" + try: + module_name = _EXPORTS[name] + except KeyError: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from None + value = getattr(import_module(module_name), name) + globals()[name] = value + return value diff --git a/spikeforge/observability/__init__.py b/spikeforge/observability/__init__.py index 11c69e0..d060834 100644 --- a/spikeforge/observability/__init__.py +++ b/spikeforge/observability/__init__.py @@ -5,9 +5,19 @@ ``force=True``), and the metrics registry is a plain in-process object whose :meth:`~spikeforge.observability.registry.MetricsRegistry.snapshot` returns JSON-able data for the dashboard. + +``metrics`` / ``persistence`` / ``prometheus`` are resolved on first access +(PEP 562) instead of at import time, so the logging surface — and the +:class:`~spikeforge.observability.logging_setup.configure_logging` entry point +that uses the shared ``capsize_commons`` formatter — is importable without +dragging in the metrics stack. """ -from spikeforge.observability import metrics, persistence, prometheus +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + from spikeforge.observability.logging_setup import ( configure_logging, logging_enabled, @@ -15,6 +25,9 @@ ) from spikeforge.observability.registry import MetricsRegistry +if TYPE_CHECKING: + from spikeforge.observability import metrics, persistence, prometheus + __all__ = [ "MetricsRegistry", "configure_logging", @@ -24,3 +37,23 @@ "prometheus", "reset_logging", ] + +#: Submodule name -> import path, resolved on first attribute access. +_LAZY_SUBMODULES = { + "metrics": "spikeforge.observability.metrics", + "persistence": "spikeforge.observability.persistence", + "prometheus": "spikeforge.observability.prometheus", +} + + +def __getattr__(name: str) -> Any: + """Import a lazier observability submodule on first access (PEP 562).""" + try: + module_name = _LAZY_SUBMODULES[name] + except KeyError: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from None + module = import_module(module_name) + globals()[name] = module + return module diff --git a/spikeforge/observability/json_formatter.py b/spikeforge/observability/json_formatter.py deleted file mode 100644 index 30520a9..0000000 --- a/spikeforge/observability/json_formatter.py +++ /dev/null @@ -1,36 +0,0 @@ -"""A logging formatter that renders each record as one JSON line.""" - -import json -import logging -from datetime import datetime, timezone -from typing import Any, Dict - -#: Record attributes copied into the payload when the caller supplies them. -_IDENTIFIERS = ("run_id", "config_id", "config_hash") - - -class JsonFormatter(logging.Formatter): - """Format a log record as a single JSON object on one line.""" - - def format(self, record: logging.LogRecord) -> str: - """Return the record as compact, parseable JSON.""" - return json.dumps(self._payload(record), default=str) - - def _payload(self, record: logging.LogRecord) -> Dict[str, Any]: - """Assemble the timestamp/level/event and identifier fields.""" - payload: Dict[str, Any] = { - "timestamp": datetime.fromtimestamp( - record.created, timezone.utc - ).isoformat(), - "level": record.levelname, - "event": record.getMessage(), - "logger": record.name, - } - for key in _IDENTIFIERS: - value = getattr(record, key, None) - if value is not None: - payload[key] = value - fields = getattr(record, "fields", None) - if fields: - payload["fields"] = fields - return payload diff --git a/spikeforge/observability/logging_setup.py b/spikeforge/observability/logging_setup.py index 7f6660a..7a0f585 100644 --- a/spikeforge/observability/logging_setup.py +++ b/spikeforge/observability/logging_setup.py @@ -12,7 +12,10 @@ import os from typing import Any, Optional, Tuple -from spikeforge.observability.json_formatter import JsonFormatter +# The §14 JSON shape is owned by capsize-commons. ``message_key`` and +# ``identifier_fields`` reproduce spikeforge's established payload — the +# ``event`` key plus the three correlation fields — without a private copy. +from capsize_commons.logging import JsonFormatter #: Package logger structured logging attaches to (never the root logger). LOGGER_NAME = "spikeforge" @@ -21,6 +24,9 @@ #: Values that turn an environment flag off. _FALSEY = ("", "0", "false", "no", "off") _HUMAN_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s" +#: Field names preserving spikeforge's JSON payload shape (see module note). +_MESSAGE_KEY = "event" +_IDENTIFIER_FIELDS = ("run_id", "config_id", "config_hash") _logger = logging.getLogger(LOGGER_NAME) _handler: Optional[logging.Handler] = None @@ -51,7 +57,11 @@ def _handler_for(use_json: bool, stream: Any) -> logging.Handler: """Return a stream handler carrying the JSON or human formatter.""" handler = logging.StreamHandler(stream) formatter = ( - JsonFormatter() if use_json else logging.Formatter(_HUMAN_FORMAT) + JsonFormatter( + message_key=_MESSAGE_KEY, identifier_fields=_IDENTIFIER_FIELDS + ) + if use_json + else logging.Formatter(_HUMAN_FORMAT) ) handler.setFormatter(formatter) return handler diff --git a/tests/test_packaging_profiles.py b/tests/test_packaging_profiles.py index ec1e97c..7dc94fb 100644 --- a/tests/test_packaging_profiles.py +++ b/tests/test_packaging_profiles.py @@ -41,6 +41,7 @@ "docs", } _EXPECTED_CORE_DEPS = { + "capsize-commons>=0.1.1", "torch>=2.5", "torchvision>=0.20", "snntorch>=1.0",