oaknut-extension 12.0.0__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.
- oaknut_extension-12.0.0/LICENSE +21 -0
- oaknut_extension-12.0.0/PKG-INFO +54 -0
- oaknut_extension-12.0.0/README.md +29 -0
- oaknut_extension-12.0.0/pyproject.toml +49 -0
- oaknut_extension-12.0.0/setup.cfg +4 -0
- oaknut_extension-12.0.0/src/oaknut/extension/__init__.py +293 -0
- oaknut_extension-12.0.0/src/oaknut/extension/_text.py +58 -0
- oaknut_extension-12.0.0/src/oaknut_extension.egg-info/PKG-INFO +54 -0
- oaknut_extension-12.0.0/src/oaknut_extension.egg-info/SOURCES.txt +11 -0
- oaknut_extension-12.0.0/src/oaknut_extension.egg-info/dependency_links.txt +1 -0
- oaknut_extension-12.0.0/src/oaknut_extension.egg-info/requires.txt +2 -0
- oaknut_extension-12.0.0/src/oaknut_extension.egg-info/top_level.txt +1 -0
- oaknut_extension-12.0.0/tests/test_extension.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Smallshire
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oaknut-extension
|
|
3
|
+
Version: 12.0.0
|
|
4
|
+
Summary: Entry-point plug-in framework shared by every extensible axis of the oaknut package family.
|
|
5
|
+
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-extension
|
|
8
|
+
Project-URL: Repository, https://github.com/rob-smallshire/oaknut
|
|
9
|
+
Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: oaknut-exception>=10.0
|
|
23
|
+
Requires-Dist: stevedore>=5.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# oaknut-extension
|
|
27
|
+
|
|
28
|
+
The entry-point plug-in framework shared by every extensible *axis* of the
|
|
29
|
+
`oaknut` package family.
|
|
30
|
+
|
|
31
|
+
An **axis** is one extension point — a family of interchangeable plug-ins that
|
|
32
|
+
all answer the same question. The primary axis is *filesystems* (each plug-in
|
|
33
|
+
detects and operates on one disc format, in `oaknut-filesystem`);
|
|
34
|
+
`oaknut.command` (CLI subcommands contributed by filesystem packages) is
|
|
35
|
+
another.
|
|
36
|
+
|
|
37
|
+
Each axis declares a `kind` (a short identifier such as `"filesystem"`).
|
|
38
|
+
Concrete extensions for that axis subclass `Extension`, override `_kind()`,
|
|
39
|
+
and register themselves under the `oaknut.<kind>` entry-point namespace in
|
|
40
|
+
their package's `pyproject.toml`:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[project.entry-points."oaknut.filesystem"]
|
|
44
|
+
acorn-dfs = "oaknut.dfs.filesystem:AcornDFS"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Consumers discover and load them through `list_extensions()`,
|
|
48
|
+
`create_extension()`, and friends, which wrap [stevedore](https://docs.openstack.org/stevedore/).
|
|
49
|
+
Because discovery is by installed entry point, a plug-in shipped by any package
|
|
50
|
+
appears automatically — no central registry to edit.
|
|
51
|
+
|
|
52
|
+
This package is deliberately domain-agnostic: it knows nothing about discs,
|
|
53
|
+
files, or formats. It depends only on `oaknut-exception` (for the shared error
|
|
54
|
+
hierarchy) and `stevedore`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# oaknut-extension
|
|
2
|
+
|
|
3
|
+
The entry-point plug-in framework shared by every extensible *axis* of the
|
|
4
|
+
`oaknut` package family.
|
|
5
|
+
|
|
6
|
+
An **axis** is one extension point — a family of interchangeable plug-ins that
|
|
7
|
+
all answer the same question. The primary axis is *filesystems* (each plug-in
|
|
8
|
+
detects and operates on one disc format, in `oaknut-filesystem`);
|
|
9
|
+
`oaknut.command` (CLI subcommands contributed by filesystem packages) is
|
|
10
|
+
another.
|
|
11
|
+
|
|
12
|
+
Each axis declares a `kind` (a short identifier such as `"filesystem"`).
|
|
13
|
+
Concrete extensions for that axis subclass `Extension`, override `_kind()`,
|
|
14
|
+
and register themselves under the `oaknut.<kind>` entry-point namespace in
|
|
15
|
+
their package's `pyproject.toml`:
|
|
16
|
+
|
|
17
|
+
```toml
|
|
18
|
+
[project.entry-points."oaknut.filesystem"]
|
|
19
|
+
acorn-dfs = "oaknut.dfs.filesystem:AcornDFS"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Consumers discover and load them through `list_extensions()`,
|
|
23
|
+
`create_extension()`, and friends, which wrap [stevedore](https://docs.openstack.org/stevedore/).
|
|
24
|
+
Because discovery is by installed entry point, a plug-in shipped by any package
|
|
25
|
+
appears automatically — no central registry to edit.
|
|
26
|
+
|
|
27
|
+
This package is deliberately domain-agnostic: it knows nothing about discs,
|
|
28
|
+
files, or formats. It depends only on `oaknut-exception` (for the shared error
|
|
29
|
+
hierarchy) and `stevedore`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oaknut-extension"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
|
|
9
|
+
description = "Entry-point plug-in framework shared by every extensible axis of the oaknut package family."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"oaknut-exception>=10.0",
|
|
27
|
+
"stevedore>=5.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-extension"
|
|
32
|
+
Repository = "https://github.com/rob-smallshire/oaknut"
|
|
33
|
+
Issues = "https://github.com/rob-smallshire/oaknut/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
test = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
]
|
|
39
|
+
dev = [
|
|
40
|
+
"bump-my-version>=0.28.0",
|
|
41
|
+
"pre-commit>=3.0",
|
|
42
|
+
{include-group = "test"},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.dynamic]
|
|
46
|
+
version = { attr = "oaknut.extension.__version__" }
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Generic entry-point plug-in machinery shared by every oaknut extension axis.
|
|
2
|
+
|
|
3
|
+
An *axis* is a single extension point — a family of interchangeable
|
|
4
|
+
plug-ins that all answer the same question (for example the *filesystem*
|
|
5
|
+
axis, where each plug-in detects and operates on one disc format). Each
|
|
6
|
+
axis declares a ``kind`` (a short identifier such as ``"filesystem"``).
|
|
7
|
+
Concrete extensions subclass :class:`Extension`, override
|
|
8
|
+
:meth:`Extension._kind`, and are registered under the ``oaknut.<kind>``
|
|
9
|
+
entry-point namespace in their package's ``pyproject.toml``::
|
|
10
|
+
|
|
11
|
+
[project.entry-points."oaknut.filesystem"]
|
|
12
|
+
acorn-dfs = "oaknut.dfs.filesystem:AcornDFS"
|
|
13
|
+
|
|
14
|
+
Consumers discover and load them via :func:`list_extensions`,
|
|
15
|
+
:func:`create_extension`, :func:`extension`, and :func:`describe_extension`,
|
|
16
|
+
which wrap `stevedore <https://docs.openstack.org/stevedore/>`_. Because
|
|
17
|
+
discovery is by installed entry point, a plug-in shipped by any package
|
|
18
|
+
appears automatically — there is no central registry to edit.
|
|
19
|
+
|
|
20
|
+
This module is the clone-and-own foundation for the family: it is
|
|
21
|
+
deliberately domain-agnostic, knowing nothing about discs, files, or
|
|
22
|
+
formats. Per-axis packages build a thin convenience layer on top (see
|
|
23
|
+
``oaknut.filesystem`` for the filesystem axis).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import functools
|
|
29
|
+
import importlib.resources
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
from abc import ABC, abstractmethod
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional, Type
|
|
35
|
+
|
|
36
|
+
import stevedore
|
|
37
|
+
import stevedore.driver
|
|
38
|
+
import stevedore.exception
|
|
39
|
+
import stevedore.extension
|
|
40
|
+
from oaknut.exception import ConfigurationError
|
|
41
|
+
from oaknut.extension._text import first_line, normalize_name, strip_lines
|
|
42
|
+
|
|
43
|
+
__version__ = "12.0.0"
|
|
44
|
+
|
|
45
|
+
#: Entry-point namespaces are formed as ``"<NAMESPACE_PREFIX>.<kind>"``.
|
|
46
|
+
NAMESPACE_PREFIX = "oaknut"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Extension",
|
|
50
|
+
"ExtensionError",
|
|
51
|
+
"namespace_for",
|
|
52
|
+
"create_extension",
|
|
53
|
+
"extension",
|
|
54
|
+
"describe_extension",
|
|
55
|
+
"list_extensions",
|
|
56
|
+
"list_dirpaths",
|
|
57
|
+
"extension_name_from_class",
|
|
58
|
+
"load_failure_callback",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def namespace_for(kind: str) -> str:
|
|
65
|
+
"""Return the entry-point namespace for an extension *kind*.
|
|
66
|
+
|
|
67
|
+
The convention is ``"oaknut.<kind>"`` — e.g.
|
|
68
|
+
``namespace_for("filesystem")`` is ``"oaknut.filesystem"``.
|
|
69
|
+
"""
|
|
70
|
+
return f"{NAMESPACE_PREFIX}.{kind}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Extension(ABC):
|
|
74
|
+
"""Base class for every oaknut plug-in extension.
|
|
75
|
+
|
|
76
|
+
A concrete extension overrides :meth:`_kind` to name its axis and
|
|
77
|
+
is instantiated with the entry-point ``name`` it was registered
|
|
78
|
+
under. The class docstring doubles as its human-readable
|
|
79
|
+
description (see :meth:`describe`).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, name: str, **kwargs):
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self._name = name
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def kind(cls) -> str:
|
|
88
|
+
"""The kind of extension — the axis this plug-in belongs to."""
|
|
89
|
+
return cls._kind()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def _kind(cls) -> str:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def name(self) -> str:
|
|
98
|
+
"""The entry-point name this extension was loaded under."""
|
|
99
|
+
return self._name
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def dirpath(cls) -> Path:
|
|
103
|
+
"""The directory path to the package providing this extension."""
|
|
104
|
+
package_name = inspect.getmodule(cls).__package__
|
|
105
|
+
return Path(importlib.resources.files(package_name))
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def version(cls) -> str:
|
|
109
|
+
"""The extension version. Override to report something meaningful."""
|
|
110
|
+
return __version__
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def describe(cls, *, single_line: bool = False) -> str:
|
|
114
|
+
"""A human-readable description of the extension.
|
|
115
|
+
|
|
116
|
+
By default this is the extension class's docstring. Override it
|
|
117
|
+
if you want something different.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
single_line: If True, return only the first non-empty line of
|
|
121
|
+
the description. Defaults to False for the full text.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The description. If *single_line* is True, only the first
|
|
125
|
+
non-empty line; otherwise the complete (de-indented) docstring.
|
|
126
|
+
"""
|
|
127
|
+
if cls.__doc__ is None:
|
|
128
|
+
return "No description available."
|
|
129
|
+
full_description = strip_lines(inspect.cleandoc(cls.__doc__))
|
|
130
|
+
if single_line:
|
|
131
|
+
return first_line(full_description)
|
|
132
|
+
return full_description
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
@functools.lru_cache(maxsize=None)
|
|
136
|
+
def entry_point_name(cls) -> str:
|
|
137
|
+
"""The entry-point name (key) this extension class is registered under.
|
|
138
|
+
|
|
139
|
+
Performs a reverse lookup from class to entry-point name by
|
|
140
|
+
searching the axis's namespace. Cached indefinitely since
|
|
141
|
+
entry-point names are immutable for the life of the process.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ExtensionError: If this class is not a registered extension.
|
|
145
|
+
"""
|
|
146
|
+
return extension_name_from_class(namespace_for(cls.kind()), cls)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ExtensionError(ConfigurationError):
|
|
150
|
+
"""Raised when an extension cannot be discovered or loaded.
|
|
151
|
+
|
|
152
|
+
A plug-in that is missing, mis-registered, or fails at import time
|
|
153
|
+
is an environment/setup problem rather than bad input or a library
|
|
154
|
+
bug — hence a :class:`~oaknut.exception.ConfigurationError`.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_extension(
|
|
159
|
+
kind: str,
|
|
160
|
+
namespace: str,
|
|
161
|
+
name: str,
|
|
162
|
+
exception_type: Optional[type[BaseException]] = None,
|
|
163
|
+
**kwargs,
|
|
164
|
+
) -> Extension:
|
|
165
|
+
"""Instantiate an extension by name.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
kind: The kind of extension (for error messages).
|
|
169
|
+
namespace: The entry-point namespace to search.
|
|
170
|
+
name: The name of the extension to load.
|
|
171
|
+
exception_type: Exception raised if the extension cannot be
|
|
172
|
+
loaded. Defaults to :class:`ExtensionError`.
|
|
173
|
+
**kwargs: Forwarded to the extension constructor.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
An :class:`Extension` instance.
|
|
177
|
+
"""
|
|
178
|
+
ext = extension(kind, namespace, name, exception_type)
|
|
179
|
+
return ext(name=normalize_name(name), **kwargs)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def extension(
|
|
183
|
+
kind: str,
|
|
184
|
+
namespace: str,
|
|
185
|
+
name: str,
|
|
186
|
+
exception_type: Optional[type[BaseException]] = None,
|
|
187
|
+
) -> Type[Extension]:
|
|
188
|
+
"""Get an extension *class* (without instantiating it).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
kind: The kind of extension (for error messages).
|
|
192
|
+
namespace: The entry-point namespace to search.
|
|
193
|
+
name: The name of the extension to load.
|
|
194
|
+
exception_type: Exception raised if the extension cannot be
|
|
195
|
+
loaded. Defaults to :class:`ExtensionError`.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The extension class.
|
|
199
|
+
"""
|
|
200
|
+
exception_type = exception_type or ExtensionError
|
|
201
|
+
normal_name = normalize_name(name)
|
|
202
|
+
try:
|
|
203
|
+
manager = stevedore.driver.DriverManager(
|
|
204
|
+
namespace=namespace,
|
|
205
|
+
name=normal_name,
|
|
206
|
+
invoke_on_load=False,
|
|
207
|
+
on_load_failure_callback=load_failure_callback,
|
|
208
|
+
)
|
|
209
|
+
except stevedore.exception.NoMatches as no_matches:
|
|
210
|
+
name_list = ", ".join(list_extensions(namespace))
|
|
211
|
+
raise exception_type(
|
|
212
|
+
f"No {kind} matching {name!r}. Available {kind}s: {name_list}"
|
|
213
|
+
) from no_matches
|
|
214
|
+
return manager.driver
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def describe_extension(
|
|
218
|
+
kind: str,
|
|
219
|
+
namespace: str,
|
|
220
|
+
name: str,
|
|
221
|
+
exception_type: Optional[type[BaseException]] = None,
|
|
222
|
+
*,
|
|
223
|
+
single_line: bool = False,
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Return the description of an extension by name.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
kind: The kind of extension (for error messages).
|
|
229
|
+
namespace: The entry-point namespace to search.
|
|
230
|
+
name: The name of the extension.
|
|
231
|
+
exception_type: Exception raised if the extension cannot be loaded.
|
|
232
|
+
single_line: If True, return only the first non-empty line.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The extension's description string.
|
|
236
|
+
"""
|
|
237
|
+
driver = extension(kind, namespace, name, exception_type)
|
|
238
|
+
return driver.describe(single_line=single_line)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def list_extensions(namespace: str) -> list[str]:
|
|
242
|
+
"""List the names of the extensions registered in *namespace*."""
|
|
243
|
+
manager = stevedore.ExtensionManager(
|
|
244
|
+
namespace=namespace,
|
|
245
|
+
invoke_on_load=False,
|
|
246
|
+
on_load_failure_callback=load_failure_callback,
|
|
247
|
+
)
|
|
248
|
+
return manager.names()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def list_dirpaths(namespace: str) -> dict[str, Path]:
|
|
252
|
+
"""Map each extension name in *namespace* to its package directory path."""
|
|
253
|
+
manager = stevedore.ExtensionManager(
|
|
254
|
+
namespace=namespace,
|
|
255
|
+
invoke_on_load=False,
|
|
256
|
+
on_load_failure_callback=load_failure_callback,
|
|
257
|
+
)
|
|
258
|
+
return {name: _extension_dirpath(ext) for name, ext in manager.items()}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _extension_dirpath(ext: stevedore.extension.Extension) -> Path:
|
|
262
|
+
"""The directory path to the package providing a stevedore extension."""
|
|
263
|
+
return Path(importlib.resources.files(ext.module_name))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def extension_name_from_class(namespace: str, extension_class: Type[Extension]) -> str:
|
|
267
|
+
"""Reverse-lookup the entry-point name for an extension *class*.
|
|
268
|
+
|
|
269
|
+
Iterates the extensions in *namespace* until one whose plug-in is
|
|
270
|
+
*extension_class* is found.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ExtensionError: If the class is not registered in *namespace*.
|
|
274
|
+
"""
|
|
275
|
+
manager = stevedore.ExtensionManager(
|
|
276
|
+
namespace=namespace,
|
|
277
|
+
invoke_on_load=False,
|
|
278
|
+
on_load_failure_callback=load_failure_callback,
|
|
279
|
+
)
|
|
280
|
+
for ext in manager:
|
|
281
|
+
if ext.plugin is extension_class:
|
|
282
|
+
return ext.name
|
|
283
|
+
raise ExtensionError(
|
|
284
|
+
f"Extension class {extension_class.__name__} not found in namespace {namespace!r}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def load_failure_callback(manager, entrypoint, exception):
|
|
289
|
+
"""Turn a stevedore load failure into an :class:`ExtensionError`."""
|
|
290
|
+
raise ExtensionError(
|
|
291
|
+
f"Could not load extension {entrypoint.name!r} from namespace "
|
|
292
|
+
f"{manager.namespace!r}: {exception}"
|
|
293
|
+
) from exception
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Internal text utilities for rendering extension descriptions.
|
|
2
|
+
|
|
3
|
+
These small helpers are used by :mod:`oaknut.extension` to turn an
|
|
4
|
+
extension class's docstring into a one-line summary or a tidied block.
|
|
5
|
+
They are kept internal (underscore-prefixed module) because they are
|
|
6
|
+
not part of the public API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_blank(line: str) -> bool:
|
|
11
|
+
return not line or line.isspace()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def strip_lines(text: str) -> str:
|
|
15
|
+
"""Remove leading and trailing blank lines.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
text: The text to process.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The text with any leading and trailing blank-or-whitespace-only
|
|
22
|
+
lines removed. Interior blank lines are preserved.
|
|
23
|
+
"""
|
|
24
|
+
lines = text.splitlines()
|
|
25
|
+
start = 0
|
|
26
|
+
while start < len(lines) and _is_blank(lines[start]):
|
|
27
|
+
start += 1
|
|
28
|
+
end = len(lines)
|
|
29
|
+
while end > start and _is_blank(lines[end - 1]):
|
|
30
|
+
end -= 1
|
|
31
|
+
return "\n".join(lines[start:end])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_name(name: str) -> str:
|
|
35
|
+
"""Normalise an extension lookup name.
|
|
36
|
+
|
|
37
|
+
Trims surrounding whitespace only; the name is otherwise matched
|
|
38
|
+
against the registered entry-point key verbatim. Hyphens are
|
|
39
|
+
significant — oaknut's user-facing keys are hyphenated
|
|
40
|
+
(``acorn-dfs``) — so they are preserved, not folded to underscores.
|
|
41
|
+
"""
|
|
42
|
+
return name.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def first_line(text: str) -> str:
|
|
46
|
+
"""Extract the first non-empty line from *text*.
|
|
47
|
+
|
|
48
|
+
Useful for one-line summaries in tables where multi-line text wraps
|
|
49
|
+
awkwardly. Returns the empty string if *text* is empty or all
|
|
50
|
+
whitespace.
|
|
51
|
+
"""
|
|
52
|
+
if not text:
|
|
53
|
+
return ""
|
|
54
|
+
for line in text.splitlines():
|
|
55
|
+
stripped = line.strip()
|
|
56
|
+
if stripped:
|
|
57
|
+
return stripped
|
|
58
|
+
return ""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oaknut-extension
|
|
3
|
+
Version: 12.0.0
|
|
4
|
+
Summary: Entry-point plug-in framework shared by every extensible axis of the oaknut package family.
|
|
5
|
+
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-extension
|
|
8
|
+
Project-URL: Repository, https://github.com/rob-smallshire/oaknut
|
|
9
|
+
Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: oaknut-exception>=10.0
|
|
23
|
+
Requires-Dist: stevedore>=5.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# oaknut-extension
|
|
27
|
+
|
|
28
|
+
The entry-point plug-in framework shared by every extensible *axis* of the
|
|
29
|
+
`oaknut` package family.
|
|
30
|
+
|
|
31
|
+
An **axis** is one extension point — a family of interchangeable plug-ins that
|
|
32
|
+
all answer the same question. The primary axis is *filesystems* (each plug-in
|
|
33
|
+
detects and operates on one disc format, in `oaknut-filesystem`);
|
|
34
|
+
`oaknut.command` (CLI subcommands contributed by filesystem packages) is
|
|
35
|
+
another.
|
|
36
|
+
|
|
37
|
+
Each axis declares a `kind` (a short identifier such as `"filesystem"`).
|
|
38
|
+
Concrete extensions for that axis subclass `Extension`, override `_kind()`,
|
|
39
|
+
and register themselves under the `oaknut.<kind>` entry-point namespace in
|
|
40
|
+
their package's `pyproject.toml`:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[project.entry-points."oaknut.filesystem"]
|
|
44
|
+
acorn-dfs = "oaknut.dfs.filesystem:AcornDFS"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Consumers discover and load them through `list_extensions()`,
|
|
48
|
+
`create_extension()`, and friends, which wrap [stevedore](https://docs.openstack.org/stevedore/).
|
|
49
|
+
Because discovery is by installed entry point, a plug-in shipped by any package
|
|
50
|
+
appears automatically — no central registry to edit.
|
|
51
|
+
|
|
52
|
+
This package is deliberately domain-agnostic: it knows nothing about discs,
|
|
53
|
+
files, or formats. It depends only on `oaknut-exception` (for the shared error
|
|
54
|
+
hierarchy) and `stevedore`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/oaknut/extension/__init__.py
|
|
5
|
+
src/oaknut/extension/_text.py
|
|
6
|
+
src/oaknut_extension.egg-info/PKG-INFO
|
|
7
|
+
src/oaknut_extension.egg-info/SOURCES.txt
|
|
8
|
+
src/oaknut_extension.egg-info/dependency_links.txt
|
|
9
|
+
src/oaknut_extension.egg-info/requires.txt
|
|
10
|
+
src/oaknut_extension.egg-info/top_level.txt
|
|
11
|
+
tests/test_extension.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oaknut
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Unit tests for the generic extension framework.
|
|
2
|
+
|
|
3
|
+
End-to-end entry-point *discovery* is exercised where real plug-ins
|
|
4
|
+
are registered (see the filesystem tests in oaknut-dfs / oaknut-adfs).
|
|
5
|
+
Here we cover the axis-agnostic behaviour: the Extension contract,
|
|
6
|
+
namespace formation, docstring-driven descriptions, and the
|
|
7
|
+
not-found error path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from oaknut.exception import ConfigurationError, OaknutException
|
|
12
|
+
from oaknut.extension import (
|
|
13
|
+
Extension,
|
|
14
|
+
ExtensionError,
|
|
15
|
+
extension,
|
|
16
|
+
namespace_for,
|
|
17
|
+
)
|
|
18
|
+
from oaknut.extension._text import first_line, normalize_name, strip_lines
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Widget(Extension):
|
|
22
|
+
"""A test widget extension.
|
|
23
|
+
|
|
24
|
+
Second paragraph that should not appear in the single-line summary.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def _kind(cls):
|
|
29
|
+
return "widget"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Undocumented(Extension): # noqa: D101 — intentionally has no docstring
|
|
33
|
+
@classmethod
|
|
34
|
+
def _kind(cls):
|
|
35
|
+
return "widget"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestNamespace:
|
|
39
|
+
def test_namespace_uses_oaknut_prefix(self):
|
|
40
|
+
assert namespace_for("filesystem") == "oaknut.filesystem"
|
|
41
|
+
|
|
42
|
+
def test_namespace_for_arbitrary_kind(self):
|
|
43
|
+
assert namespace_for("widget") == "oaknut.widget"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestExtensionContract:
|
|
47
|
+
def test_kind_delegates_to_underscore_kind(self):
|
|
48
|
+
assert _Widget.kind() == "widget"
|
|
49
|
+
|
|
50
|
+
def test_name_is_the_constructor_argument(self):
|
|
51
|
+
assert _Widget(name="acme").name == "acme"
|
|
52
|
+
|
|
53
|
+
def test_version_defaults_to_package_version(self):
|
|
54
|
+
# Unified workspace versioning — a dotted release string.
|
|
55
|
+
assert _Widget.version().count(".") == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestDescribe:
|
|
59
|
+
def test_describe_returns_full_docstring(self):
|
|
60
|
+
description = _Widget.describe()
|
|
61
|
+
assert "A test widget extension." in description
|
|
62
|
+
assert "Second paragraph" in description
|
|
63
|
+
|
|
64
|
+
def test_describe_single_line_is_first_line_only(self):
|
|
65
|
+
assert _Widget.describe(single_line=True) == "A test widget extension."
|
|
66
|
+
|
|
67
|
+
def test_describe_handles_missing_docstring(self):
|
|
68
|
+
assert _Undocumented.describe() == "No description available."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestNotFound:
|
|
72
|
+
def test_unknown_extension_raises_extension_error(self):
|
|
73
|
+
with pytest.raises(ExtensionError) as exc_info:
|
|
74
|
+
extension("widget", "oaknut.widget", "does-not-exist")
|
|
75
|
+
# The message names what was sought so the user can self-correct.
|
|
76
|
+
assert "does-not-exist" in str(exc_info.value)
|
|
77
|
+
|
|
78
|
+
def test_extension_error_is_a_configuration_error(self):
|
|
79
|
+
# A mis-installed plug-in is a setup problem, not bad data —
|
|
80
|
+
# so it carries the configuration exit code, not a traceback.
|
|
81
|
+
assert issubclass(ExtensionError, ConfigurationError)
|
|
82
|
+
assert issubclass(ExtensionError, OaknutException)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestTextHelpers:
|
|
86
|
+
def test_strip_lines_trims_blank_edges_but_keeps_interior(self):
|
|
87
|
+
assert strip_lines("\n\n a \n\n b \n\n") == " a \n\n b "
|
|
88
|
+
|
|
89
|
+
def test_first_line_skips_leading_blanks(self):
|
|
90
|
+
assert first_line("\n\n hello \nworld") == "hello"
|
|
91
|
+
|
|
92
|
+
def test_first_line_of_empty_is_empty(self):
|
|
93
|
+
assert first_line(" ") == ""
|
|
94
|
+
|
|
95
|
+
def test_normalize_name_preserves_hyphens(self):
|
|
96
|
+
# Keys are hyphenated and matched verbatim (only whitespace trimmed).
|
|
97
|
+
assert normalize_name(" acorn-dfs ") == "acorn-dfs"
|