Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions documentation/project-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/spikeforge/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 47 additions & 9 deletions spikeforge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
35 changes: 34 additions & 1 deletion spikeforge/observability/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,29 @@
``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,
reset_logging,
)
from spikeforge.observability.registry import MetricsRegistry

if TYPE_CHECKING:
from spikeforge.observability import metrics, persistence, prometheus

__all__ = [
"MetricsRegistry",
"configure_logging",
Expand All @@ -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
36 changes: 0 additions & 36 deletions spikeforge/observability/json_formatter.py

This file was deleted.

14 changes: 12 additions & 2 deletions spikeforge/observability/logging_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_packaging_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"docs",
}
_EXPECTED_CORE_DEPS = {
"capsize-commons>=0.1.1",
"torch>=2.5",
"torchvision>=0.20",
"snntorch>=1.0",
Expand Down
Loading