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
11 changes: 11 additions & 0 deletions brainpy_state/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
check_brainpy_compatibility()
del check_brainpy_compatibility

# Namespace guard: this package is private; users must reach it through brainpy.state.
from ._namespace import enforce_namespace_access

enforce_namespace_access()
del enforce_namespace_access

from ._version import __version_info__, __version__

# =============================================================================
Expand Down Expand Up @@ -251,6 +257,11 @@
)

__all__ = [
# =========================================================================
# Package metadata (re-exported through the brainpy.state namespace)
# =========================================================================
'__version__', '__version_info__',

# =========================================================================
# Base Models
# =========================================================================
Expand Down
137 changes: 137 additions & 0 deletions brainpy_state/_namespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================

"""Guard ensuring the package is reached through the public ``brainpy.state``.

``brainpy_state`` is the private implementation package; the supported public API
is the ``brainpy.state`` namespace, served by a thin shim in ``brainpy`` that does
``from brainpy_state import *``. Importing ``brainpy_state`` directly bypasses that
namespace, leaks the internal layout, and is unsupported. This module fails such an
import fast and loudly with a single actionable instruction.

The guard runs once, at the very top of ``brainpy_state/__init__.py`` -- so it gates
only the *entry* import. Internal ``from brainpy_state._xxx import ...`` statements
issued *during* initialisation never re-run it (the package is already in
``sys.modules``), which is exactly what the circular-import constraint in
``CLAUDE.md`` requires.
"""

import os
import sys

__all__ = ["ALLOW_DIRECT_IMPORT_ENV", "enforce_namespace_access"]

# Environment variable that, when truthy, lets ``brainpy_state`` be imported directly
# (escape hatch for tooling that legitimately needs the private package).
ALLOW_DIRECT_IMPORT_ENV = "BRAINPY_STATE_ALLOW_DIRECT_IMPORT"

# ``__name__`` of the public shim module. When ``brainpy.state`` (or ``import brainpy``,
# which auto-loads it) triggers the import, this frame sits on the import call stack.
_PUBLIC_SHIM_NAME = "brainpy.state"

_MESSAGE = (
"`brainpy_state` is the private implementation package and must not be "
"imported directly. Use the public `brainpy.state` namespace instead:\n"
"\n"
" import brainpy\n"
" neuron = brainpy.state.LIF(...)\n"
"\n"
" # or\n"
" from brainpy import state\n"
" neuron = state.LIF(...)\n"
"\n"
"(Running the brainpy.state test suite or building its docs is allowed "
f"automatically; set {ALLOW_DIRECT_IMPORT_ENV}=1 to override.)"
)


def _is_internal_access(stack_names, modules, environ):
"""Return whether a direct ``brainpy_state`` import should be permitted.

Pure helper -- every input is injected so the decision is testable without
touching the live interpreter state.

Parameters
----------
stack_names : iterable of str
The ``__name__`` of each frame on the import call stack (innermost first).
modules : container of str
The loaded-module registry to probe, typically :data:`sys.modules`. Tested
with the ``in`` operator, so a ``dict`` or ``set`` both work.
environ : mapping
The process environment, typically :data:`os.environ`.

Returns
-------
bool
``True`` if access is permitted -- i.e. the import was triggered through the
``brainpy.state`` shim, the code is running under ``pytest`` or ``sphinx``, or
the :data:`ALLOW_DIRECT_IMPORT_ENV` override is set to a truthy value.
``False`` for a genuine external ``import brainpy_state``.

Examples
--------
.. code-block:: python

>>> from brainpy_state._namespace import _is_internal_access
>>> _is_internal_access(["brainpy.state", "brainpy"], {}, {})
True
>>> _is_internal_access(["__main__"], {"pytest": object()}, {})
True
>>> _is_internal_access(["__main__"], {}, {})
False
"""
if any(name == _PUBLIC_SHIM_NAME for name in stack_names):
return True
if "pytest" in modules or "sphinx" in modules:
return True
if environ.get(ALLOW_DIRECT_IMPORT_ENV):
return True
return False


def _iter_stack_names():
"""Yield the ``__name__`` of every frame currently on the stack, innermost first.

Yields
------
str
The ``__name__`` global of each frame, or ``""`` when a frame has none.
"""
frame = sys._getframe()
while frame is not None:
yield frame.f_globals.get("__name__", "")
frame = frame.f_back


def enforce_namespace_access():
"""Abort a direct ``brainpy_state`` import, pointing the user at ``brainpy.state``.

Inspects the live import call stack, :data:`sys.modules`, and
:data:`os.environ` via :func:`_is_internal_access`. Allowed imports return
silently; a genuine external ``import brainpy_state`` raises.

Raises
------
ImportError
If ``brainpy_state`` was imported directly rather than through the
``brainpy.state`` namespace, and no maintenance allowance applies.

See Also
--------
brainpy_state._compat.check_brainpy_compatibility : the sibling import-time guard.
"""
if not _is_internal_access(_iter_stack_names(), sys.modules, os.environ):
raise ImportError(_MESSAGE)
161 changes: 161 additions & 0 deletions brainpy_state/_namespace_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2026 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================

# -*- coding: utf-8 -*-

"""Tests for the namespace guard in :mod:`brainpy_state._namespace`.

The guard's whole job is to reject a direct ``import brainpy_state`` while letting
the blessed ``brainpy.state`` path, the test suite, doc builds, and an explicit
override through. These tests pin the pure decision matrix, the enforcer's raise
path and message, and -- in real subprocesses, the only place the live import
machinery actually runs -- the external-blocked / override / blessed end-to-end
behaviour.
"""

import os
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock

from brainpy_state import _namespace
from brainpy_state._namespace import (
ALLOW_DIRECT_IMPORT_ENV,
_is_internal_access,
enforce_namespace_access,
)

_REPO_ROOT = Path(_namespace.__file__).resolve().parent.parent


def _run_import(code, *, allow_override=False):
"""Import ``brainpy_state`` in a clean child interpreter and report the result.

Runs from the repo root (so the local package is importable via ``sys.path[0]``)
with ``pytest``/``sphinx`` absent and -- unless ``allow_override`` -- the override
env var stripped, isolating the genuine external-import code path.
"""
env = {k: v for k, v in os.environ.items() if k != ALLOW_DIRECT_IMPORT_ENV}
if allow_override:
env[ALLOW_DIRECT_IMPORT_ENV] = "1"
return subprocess.run(
[sys.executable, "-c", code],
cwd=_REPO_ROOT,
env=env,
capture_output=True,
text=True,
)


class TestIsInternalAccess(unittest.TestCase):
"""The pure allow/deny decision -- no live interpreter state."""

def test_blessed_shim_frame_allows(self):
self.assertTrue(_is_internal_access(["brainpy.state", "brainpy"], {}, {}))

def test_shim_frame_anywhere_on_stack_allows(self):
# The shim frame need not be the immediate caller.
names = ["brainpy_state._foo", "brainpy.state", "brainpy", "__main__"]
self.assertTrue(_is_internal_access(names, {}, {}))

def test_pytest_loaded_allows(self):
self.assertTrue(_is_internal_access(["__main__"], {"pytest": object()}, {}))

def test_sphinx_loaded_allows(self):
self.assertTrue(_is_internal_access(["__main__"], {"sphinx": object()}, {}))

def test_override_env_allows(self):
self.assertTrue(
_is_internal_access(["__main__"], {}, {ALLOW_DIRECT_IMPORT_ENV: "1"})
)

def test_external_import_denied(self):
self.assertFalse(_is_internal_access(["__main__", "user.app"], {}, {}))

def test_empty_override_value_is_not_an_allowance(self):
# An empty string is falsy -> treated as "not set".
self.assertFalse(
_is_internal_access(["__main__"], {}, {ALLOW_DIRECT_IMPORT_ENV: ""})
)

def test_brainpy_state_frame_is_not_the_shim(self):
# The private package's own frames must not count as the public shim.
self.assertFalse(_is_internal_access(["brainpy_state", "__main__"], {}, {}))


class TestEnforceNamespaceAccess(unittest.TestCase):
"""The enforcer, with the decision mocked for determinism."""

def test_allowed_returns_silently(self):
with mock.patch.object(_namespace, "_is_internal_access", return_value=True):
self.assertIsNone(enforce_namespace_access())

def test_denied_raises_importerror(self):
with mock.patch.object(_namespace, "_is_internal_access", return_value=False):
with self.assertRaises(ImportError):
enforce_namespace_access()

def test_message_points_at_brainpy_state_and_override(self):
with mock.patch.object(_namespace, "_is_internal_access", return_value=False):
with self.assertRaises(ImportError) as ctx:
enforce_namespace_access()
msg = str(ctx.exception)
self.assertIn("brainpy.state", msg) # the public namespace to use
self.assertIn("brainpy.state.LIF", msg) # a concrete example
self.assertIn(ALLOW_DIRECT_IMPORT_ENV, msg) # the documented escape hatch


class TestEndToEndImport(unittest.TestCase):
"""Real subprocesses exercising the live import machinery."""

def test_direct_import_is_blocked(self):
result = _run_import("import brainpy_state")
self.assertNotEqual(result.returncode, 0)
self.assertIn("brainpy.state", result.stderr)
self.assertIn("ImportError", result.stderr)

def test_direct_from_import_is_blocked(self):
result = _run_import("from brainpy_state import LIF")
self.assertNotEqual(result.returncode, 0)
self.assertIn("brainpy.state", result.stderr)

def test_direct_private_submodule_import_is_blocked(self):
# Reaching into a private submodule still runs the package __init__ first.
result = _run_import("from brainpy_state._base import Neuron")
self.assertNotEqual(result.returncode, 0)
self.assertIn("brainpy.state", result.stderr)

def test_override_env_allows_direct_import(self):
result = _run_import(
"import brainpy_state; print('ok', brainpy_state.__version__)",
allow_override=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("ok", result.stdout)

def test_public_namespace_path_succeeds(self):
# The blessed path: brainpy.state -> shim -> brainpy_state, plus the
# re-exported version dunder.
result = _run_import(
"import brainpy.state as s; print('ok', s.LIF.__name__, s.__version__)"
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("ok LIF", result.stdout)


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions brainpy_state/_nest_network/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ class Simulator(brainstate.nn.Module):
.. code-block:: python

>>> import brainunit as u
>>> from brainpy_state import iaf_psc_alpha, poisson_generator, spike_recorder
>>> from brainpy_state.network import Simulator, all_to_all
>>> from brainpy.state import iaf_psc_alpha, poisson_generator, spike_recorder
>>> from brainpy.state import Simulator, all_to_all
>>> sim = Simulator(dt=0.1 * u.ms)
>>> pop = sim.create(iaf_psc_alpha, 10)
>>> noise = sim.create(poisson_generator, rate=8000. * u.Hz)
Expand Down
4 changes: 1 addition & 3 deletions brainpy_state/_nest_neuron/aeif_cond_alpha_multisynapse.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,7 @@ class aeif_cond_alpha_multisynapse(NESTNeuron):

>>> import brainstate
>>> import brainunit as u
>>> from brainpy_state._nest_neuron.aeif_cond_alpha_multisynapse import (
... aeif_cond_alpha_multisynapse,
... )
>>> from brainpy.state import aeif_cond_alpha_multisynapse
>>> _ = brainstate.environ.context(dt=0.1 * u.ms, t=0.0 * u.ms)
>>> neu = aeif_cond_alpha_multisynapse(
... in_size=4,
Expand Down
2 changes: 1 addition & 1 deletion brainpy_state/_nest_neuron/astrocyte_lr_1994.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ class astrocyte_lr_1994(NESTNeuron):

>>> import brainstate as bst
>>> import brainunit as u
>>> import brainpy_state as bps
>>> from brainpy import state as bps
>>> with bst.environ.context(dt=0.1 * u.ms):
... astro = bps.astrocyte_lr_1994(in_size=10)
... astro.init_state()
Expand Down
2 changes: 1 addition & 1 deletion brainpy_state/_nest_neuron/gauss_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ class gauss_rate_ipn(_gauss_rate_base):

.. code-block:: python

>>> import brainpy_state as bpst
>>> from brainpy import state as bpst
>>> import brainunit as u
>>> import brainstate
>>> brainstate.environ.set_dt(0.1 * u.ms)
Expand Down
6 changes: 3 additions & 3 deletions brainpy_state/_nest_neuron/hh_cond_exp_traub.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ class hh_cond_exp_traub(NESTNeuron):

>>> import brainstate as bst
>>> import brainunit as u
>>> from brainpy_state import hh_cond_exp_traub
>>> from brainpy.state import hh_cond_exp_traub
>>>
>>> # Create a population of 100 Traub HH neurons
>>> neurons = hh_cond_exp_traub(100)
Expand Down Expand Up @@ -460,7 +460,7 @@ def init_state(self, **kwargs):

>>> import brainstate as bst
>>> import brainunit as u
>>> from brainpy_state import hh_cond_exp_traub
>>> from brainpy.state import hh_cond_exp_traub
>>>
>>> # Initialize with default rest state
>>> neurons = hh_cond_exp_traub(100)
Expand Down Expand Up @@ -572,7 +572,7 @@ def get_spike(self, V: ArrayLike = None):

>>> import brainunit as u
>>> import jax.numpy as jnp
>>> from brainpy_state import hh_cond_exp_traub
>>> from brainpy.state import hh_cond_exp_traub
>>>
>>> neurons = hh_cond_exp_traub(10)
>>> neurons.init_state()
Expand Down
Loading
Loading