Skip to content

Commit a62a253

Browse files
committed
Initial commit
0 parents  commit a62a253

File tree

7 files changed

+272
-0
lines changed

7 files changed

+272
-0
lines changed

.github/workflows/release.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
jobs:
9+
release:
10+
name: Publish to PyPI
11+
runs-on: ubuntu-latest
12+
environment: pypi
13+
permissions:
14+
id-token: write
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Install uv and setup the python version
19+
uses: astral-sh/setup-uv@v5
20+
21+
- name: Install the project
22+
run: uv sync --all-groups
23+
24+
- name: Build wheel
25+
run: uv build
26+
27+
- name: Publish package
28+
run: uv publish

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
test.py
2+
modules/
3+
__pycache__

README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# _# module-inject_
2+
3+
**module-inject** is a Python library designed to inject configuration values into modules *before they are imported*, enabling centralized and declarative control over runtime behavior across dynamic or plugin-based systems.
4+
5+
This is particularly useful in applications where modules need to be configured externally, but standard import mechanisms do not allow mutation of module-level state prior to execution.
6+
7+
## Features
8+
9+
- Inject values into modules before import-time execution
10+
- Fine-grained control with per-module and per-package scopes
11+
- Lazy and dynamic module loading after configuration
12+
- Optional type enforcement and stack-based namespace lookup
13+
14+
## Installation
15+
16+
```bash
17+
pip install module-inject
18+
```
19+
20+
## Usage
21+
22+
Consider a plugin system where plugins are python modules under a `plugins/` directory need to be initialized with context-specific parameters.
23+
24+
### Example
25+
26+
#### `main.py`
27+
28+
```python
29+
from pathlib import Path
30+
from module_inject import Module
31+
32+
# Create a controller for the package
33+
package = Module.from_import_path("plugins")
34+
35+
# Set a package-scoped variable
36+
package.set(role="slave")
37+
38+
# Set a variable for the current script
39+
Module.from_path(Path(__file__)).set(role="master")
40+
41+
# Iterate over all module files and inject values before importing
42+
for path in Path("plugins").glob("*.py"):
43+
module = Module.from_path(path)
44+
module.set(variable=f"value for {module.import_path}")
45+
module.load().init()
46+
```
47+
48+
#### `plugins/interesting_plugin.py`
49+
50+
```python
51+
import module_inject as module
52+
53+
def init():
54+
role = module.get("role", str, package=True)
55+
print(role)
56+
57+
# Module-level config access
58+
print(module.get("variable"))
59+
print(module.get_namespace())
60+
```
61+
62+
### Output
63+
64+
```
65+
value for plugins.interesting_plugin
66+
{'variable': 'value for plugins.interesting_plugin'}
67+
slave
68+
```
69+
70+
## API
71+
72+
### `Module.from_import_path(path: str) -> Module`
73+
74+
Creates a `Module` controller for the given import path (e.g., `"plugins.interesting_plugin"`).
75+
76+
### `Module.from_path(path: Path) -> Module`
77+
78+
Creates a `Module` controller from a filesystem path to a `.py` file.
79+
Searches for the file in `sys.path` under the hood to resolve import path.
80+
81+
### `module.get(key: str, type_: type[T] = None, package: bool = False, recursive: bool = False) -> T`
82+
83+
Retrieves the value associated with `key`.
84+
If `type_` is set - checks if the value in namespace is instance of the provided type.
85+
86+
If `package=True`, it will attempt to retrieve the value from the package scope rather than the module-specific scope.
87+
88+
If `recursive=True`, it will walk over the call stack to find the name.
89+
90+
### `module.get_namespace() -> dict`
91+
92+
Returns a dictionary of all key-value pairs injected into the current module.
93+
94+
### `Module.set(**kwargs) -> None`
95+
96+
Sets key-value pairs to be injected into the associated module upon import.
97+
98+
### `Module.load() -> types.ModuleType`
99+
100+
Loads (imports) the target module with the injected configuration. Must be called to finalize injection.
101+
102+
## Design Philosophy
103+
104+
This library provides a low-level interface to orchestrate dynamic configuration of Python modules, particularly in systems requiring deferred or context-aware initialization. Unlike environment variables or global config objects, this approach allows values to be injected into module namespace dictionaries before execution.
105+

module_inject/__init__.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import typing
2+
import inspect
3+
import collections.abc
4+
from pathlib import Path
5+
from .module import Module
6+
7+
__all__ = ["get", "Module"]
8+
9+
T = typing.TypeVar("T")
10+
11+
12+
def get_namespace(
13+
package: bool = False,
14+
get_path: typing.Callable[[], Path] = lambda: Path(inspect.stack()[2].filename),
15+
) -> collections.abc.Mapping[str, typing.Any]:
16+
"""
17+
Get namespace for passed module path.
18+
19+
if package is set to true - gets namespace from directory in which module is stored.
20+
"""
21+
caller_file = get_path()
22+
if package:
23+
caller_file = caller_file.parent
24+
25+
return Module.from_path(caller_file).namespace.copy()
26+
27+
28+
@typing.overload
29+
def get(name: str, *, package: bool = False, recursive: bool = False) -> typing.Any: ...
30+
@typing.overload
31+
def get(
32+
name: str, type_: type[T], *, package: bool = False, recursive: bool = False
33+
) -> T: ...
34+
def get(
35+
name: str,
36+
type_: typing.Any = None,
37+
*,
38+
package: bool = False,
39+
recursive: bool = False,
40+
) -> typing.Any:
41+
"""
42+
Get value from caller's module namespace.
43+
44+
if package is set to True - gets value from module's package namespace.
45+
46+
if recursive is set to True - walks for all callstack to find the value and returns first entry.
47+
"""
48+
file_paths = iter(map(lambda x: Path(x.filename), inspect.stack()[1:]))
49+
50+
while True:
51+
try:
52+
namespace = get_namespace(package, lambda: next(file_paths))
53+
except StopIteration as exc:
54+
raise KeyError(name) from exc
55+
56+
if name not in namespace:
57+
if not recursive:
58+
raise KeyError(name)
59+
continue
60+
61+
value = namespace[name]
62+
63+
if type_ is not None and not isinstance(value, type_):
64+
raise TypeError(f'Expected {type_!r} for "{name}", but got {type(value)!r}')
65+
66+
return value

module_inject/module.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import sys
2+
import types
3+
import typing
4+
import importlib
5+
from pathlib import Path
6+
7+
8+
def get_import_path(filepath: Path) -> str:
9+
"""Find module's import path using sys.path"""
10+
for library in map(lambda x: Path(x).absolute(), sys.path):
11+
if filepath.is_relative_to(library):
12+
path = filepath.relative_to(library)
13+
break
14+
else:
15+
raise ValueError("Module import path not found")
16+
17+
return ".".join(path.parts[:-1] + (path.stem,))
18+
19+
20+
class Module:
21+
instances: dict[str, "Module"] = {}
22+
23+
def __init__(self, importpath: str) -> None:
24+
self.import_path: str = importpath
25+
self.namespace: dict[str, typing.Any] = {}
26+
27+
Module.instances[importpath] = self
28+
29+
@classmethod
30+
def from_path(cls, path: Path) -> "Module":
31+
"""Get or create instance using filesystem path"""
32+
import_path = get_import_path(path.absolute())
33+
34+
return cls.from_import_path(import_path)
35+
36+
@classmethod
37+
def from_import_path(cls, import_path: str) -> "Module":
38+
"""Get or create instance using import path"""
39+
if import_path in cls.instances:
40+
return cls.instances[import_path]
41+
42+
return cls(import_path)
43+
44+
def set(self, **kwargs: typing.Any) -> None:
45+
"""
46+
Set values into module's namespace. Shortcut for Module.namespace.update()
47+
"""
48+
self.namespace.update(kwargs)
49+
50+
def load(self) -> types.ModuleType:
51+
"""Import the module"""
52+
return importlib.import_module(self.import_path)

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[project]
2+
name = "module-inject"
3+
version = "0.0.1"
4+
description = "Module value injection"
5+
readme="README.md"
6+
requires-python = ">=3.10"
7+
dependencies = []
8+
9+
[tool.pyright]
10+
reportAny = false

uv.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)