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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ oaknut-exception>=10.0
2
+ stevedore>=5.0
@@ -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"