edocapi 0.0.2__py3-none-any.whl
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.
- edocapi/__init__.py +49 -0
- edocapi/app.py +255 -0
- edocapi/cli/__init__.py +3 -0
- edocapi/cli/main.py +94 -0
- edocapi/config.py +76 -0
- edocapi/document.py +290 -0
- edocapi/exceptions.py +104 -0
- edocapi/files.py +97 -0
- edocapi/processors/__init__.py +16 -0
- edocapi/processors/base.py +101 -0
- edocapi/processors/docx.py +112 -0
- edocapi/processors/html.py +64 -0
- edocapi/processors/image.py +80 -0
- edocapi/processors/markdown.py +64 -0
- edocapi/processors/pdf.py +231 -0
- edocapi/processors/txt.py +58 -0
- edocapi/responses.py +88 -0
- edocapi/storage/__init__.py +3 -0
- edocapi/storage/temporary.py +132 -0
- edocapi/validation/__init__.py +13 -0
- edocapi/validation/files.py +209 -0
- edocapi-0.0.2.dist-info/METADATA +335 -0
- edocapi-0.0.2.dist-info/RECORD +27 -0
- edocapi-0.0.2.dist-info/WHEEL +5 -0
- edocapi-0.0.2.dist-info/entry_points.txt +2 -0
- edocapi-0.0.2.dist-info/licenses/LICENSE +21 -0
- edocapi-0.0.2.dist-info/top_level.txt +1 -0
edocapi/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eDocAPI - A lightweight Python framework for building document-processing
|
|
3
|
+
and document-automation APIs.
|
|
4
|
+
|
|
5
|
+
Simple API for the developer. Powerful document processing underneath.
|
|
6
|
+
|
|
7
|
+
Author: EMMANUEL EMMANUEL ETIM <emmanuel224etim089@gmail.com>
|
|
8
|
+
Repository: https://github.com/emmanuelemmanueletim/edocApi
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.2"
|
|
12
|
+
__author__ = "EMMANUEL EMMANUEL ETIM"
|
|
13
|
+
__author_email__ = "emmanuel224etim089@gmail.com"
|
|
14
|
+
__license__ = "MIT"
|
|
15
|
+
__copyright__ = "Copyright (c) 2026 EMMANUEL EMMANUEL ETIM"
|
|
16
|
+
__url__ = "https://github.com/emmanuelemmanueletim/edocApi"
|
|
17
|
+
|
|
18
|
+
from edocapi.app import App
|
|
19
|
+
from edocapi.document import Document
|
|
20
|
+
from edocapi.exceptions import (
|
|
21
|
+
EdocAPIError,
|
|
22
|
+
UnsupportedFileType,
|
|
23
|
+
InvalidDocument,
|
|
24
|
+
FileTooLarge,
|
|
25
|
+
FileNotFound,
|
|
26
|
+
ConversionError,
|
|
27
|
+
ProcessingError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
)
|
|
30
|
+
from edocapi.responses import FileResponse, JSONResponse
|
|
31
|
+
|
|
32
|
+
# Ensure processors are registered on import
|
|
33
|
+
import edocapi.processors # noqa: F401
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"App",
|
|
37
|
+
"Document",
|
|
38
|
+
"FileResponse",
|
|
39
|
+
"JSONResponse",
|
|
40
|
+
"EdocAPIError",
|
|
41
|
+
"UnsupportedFileType",
|
|
42
|
+
"InvalidDocument",
|
|
43
|
+
"FileTooLarge",
|
|
44
|
+
"FileNotFound",
|
|
45
|
+
"ConversionError",
|
|
46
|
+
"ProcessingError",
|
|
47
|
+
"ValidationError",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
edocapi/app.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""eDocAPI application object built on Starlette."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Callable, Sequence
|
|
8
|
+
|
|
9
|
+
from starlette.applications import Starlette
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
from starlette.routing import Route
|
|
13
|
+
from starlette.middleware import Middleware
|
|
14
|
+
from starlette.middleware.exceptions import ExceptionMiddleware
|
|
15
|
+
|
|
16
|
+
from edocapi.config import Config
|
|
17
|
+
from edocapi.exceptions import EdocAPIError
|
|
18
|
+
from edocapi.responses import JSONResponse, make_error_response
|
|
19
|
+
from edocapi.storage.temporary import TemporaryStorage
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("edocapi")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class App:
|
|
25
|
+
"""Main eDocAPI application.
|
|
26
|
+
|
|
27
|
+
Example::
|
|
28
|
+
|
|
29
|
+
from edocapi import App, Document
|
|
30
|
+
|
|
31
|
+
app = App()
|
|
32
|
+
|
|
33
|
+
@app.post("/convert")
|
|
34
|
+
def convert(file):
|
|
35
|
+
return Document(file).to_pdf()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
debug: bool = False,
|
|
41
|
+
max_file_size: str | int = "10MB",
|
|
42
|
+
temp_dir: str | None = None,
|
|
43
|
+
**kwargs: Any,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.config = Config.from_kwargs(
|
|
46
|
+
debug=debug,
|
|
47
|
+
max_file_size=max_file_size,
|
|
48
|
+
temp_dir=temp_dir,
|
|
49
|
+
**kwargs,
|
|
50
|
+
)
|
|
51
|
+
self._routes: list[Route] = []
|
|
52
|
+
self._temp_storage = TemporaryStorage(self.config.temp_dir)
|
|
53
|
+
self._starlette: Starlette | None = None
|
|
54
|
+
|
|
55
|
+
# Configure logging
|
|
56
|
+
level = logging.DEBUG if self.config.debug else logging.INFO
|
|
57
|
+
logging.basicConfig(
|
|
58
|
+
level=level,
|
|
59
|
+
format="%(levelname)-5s %(name)s %(message)s",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Routing helpers
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def _add_route(
|
|
67
|
+
self,
|
|
68
|
+
path: str,
|
|
69
|
+
endpoint: Callable,
|
|
70
|
+
methods: Sequence[str],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Register a route, wrapping the endpoint to inject request context."""
|
|
73
|
+
|
|
74
|
+
async def wrapper(request: Request) -> Response:
|
|
75
|
+
return await self._dispatch(endpoint, request)
|
|
76
|
+
|
|
77
|
+
self._routes.append(Route(path, endpoint=wrapper, methods=list(methods)))
|
|
78
|
+
|
|
79
|
+
def get(self, path: str) -> Callable:
|
|
80
|
+
def decorator(func: Callable) -> Callable:
|
|
81
|
+
self._add_route(path, func, ["GET"])
|
|
82
|
+
return func
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
85
|
+
|
|
86
|
+
def post(self, path: str) -> Callable:
|
|
87
|
+
def decorator(func: Callable) -> Callable:
|
|
88
|
+
self._add_route(path, func, ["POST"])
|
|
89
|
+
return func
|
|
90
|
+
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
def put(self, path: str) -> Callable:
|
|
94
|
+
def decorator(func: Callable) -> Callable:
|
|
95
|
+
self._add_route(path, func, ["PUT"])
|
|
96
|
+
return func
|
|
97
|
+
|
|
98
|
+
return decorator
|
|
99
|
+
|
|
100
|
+
def delete(self, path: str) -> Callable:
|
|
101
|
+
def decorator(func: Callable) -> Callable:
|
|
102
|
+
self._add_route(path, func, ["DELETE"])
|
|
103
|
+
return func
|
|
104
|
+
|
|
105
|
+
return decorator
|
|
106
|
+
|
|
107
|
+
def route(self, path: str, methods: Sequence[str] | None = None) -> Callable:
|
|
108
|
+
methods = methods or ["GET"]
|
|
109
|
+
|
|
110
|
+
def decorator(func: Callable) -> Callable:
|
|
111
|
+
self._add_route(path, func, methods)
|
|
112
|
+
return func
|
|
113
|
+
|
|
114
|
+
return decorator
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Request dispatch
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async def _dispatch(self, endpoint: Callable, request: Request) -> Response:
|
|
121
|
+
"""Call the user endpoint, injecting file uploads and handling results."""
|
|
122
|
+
from edocapi.document import Document
|
|
123
|
+
from edocapi.files import extract_uploads
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
# Prepare keyword arguments for the endpoint
|
|
127
|
+
sig = inspect.signature(endpoint)
|
|
128
|
+
kwargs: dict[str, Any] = {}
|
|
129
|
+
|
|
130
|
+
# Inject uploads when the parameter is named 'file' or 'files'
|
|
131
|
+
uploads = None
|
|
132
|
+
if any(p in sig.parameters for p in ("file", "files")):
|
|
133
|
+
uploads = await extract_uploads(
|
|
134
|
+
request,
|
|
135
|
+
max_size=self.config.max_file_size,
|
|
136
|
+
max_files=self.config.max_files,
|
|
137
|
+
temp_storage=self._temp_storage,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
for name, param in sig.parameters.items():
|
|
141
|
+
if name == "request":
|
|
142
|
+
kwargs["request"] = request
|
|
143
|
+
elif name == "file":
|
|
144
|
+
if not uploads:
|
|
145
|
+
raise EdocAPIError("No file uploaded.")
|
|
146
|
+
kwargs["file"] = uploads[0]
|
|
147
|
+
elif name == "files":
|
|
148
|
+
if not uploads:
|
|
149
|
+
raise EdocAPIError("No files uploaded.")
|
|
150
|
+
kwargs["files"] = uploads
|
|
151
|
+
elif name in request.path_params:
|
|
152
|
+
kwargs[name] = request.path_params[name]
|
|
153
|
+
elif param.default is not inspect.Parameter.empty:
|
|
154
|
+
continue
|
|
155
|
+
else:
|
|
156
|
+
# Try query params as fallback
|
|
157
|
+
if name in request.query_params:
|
|
158
|
+
kwargs[name] = request.query_params[name]
|
|
159
|
+
|
|
160
|
+
# Call the endpoint (sync or async)
|
|
161
|
+
if inspect.iscoroutinefunction(endpoint):
|
|
162
|
+
result = await endpoint(**kwargs)
|
|
163
|
+
else:
|
|
164
|
+
result = endpoint(**kwargs)
|
|
165
|
+
|
|
166
|
+
return self._make_response(result)
|
|
167
|
+
|
|
168
|
+
except EdocAPIError as exc:
|
|
169
|
+
status = 400
|
|
170
|
+
if exc.__class__.__name__ == "FileTooLarge":
|
|
171
|
+
status = 413
|
|
172
|
+
logger.warning("eDocAPI error: %s", exc)
|
|
173
|
+
return make_error_response(exc, status_code=status, debug=self.config.debug)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
logger.exception("Unhandled error in endpoint")
|
|
176
|
+
return make_error_response(
|
|
177
|
+
exc, status_code=500, debug=self.config.debug
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _make_response(self, result: Any) -> Response:
|
|
181
|
+
"""Convert an endpoint return value into a Starlette Response."""
|
|
182
|
+
from edocapi.document import Document
|
|
183
|
+
from edocapi.responses import FileResponse, JSONResponse
|
|
184
|
+
from pathlib import Path
|
|
185
|
+
|
|
186
|
+
if result is None:
|
|
187
|
+
return JSONResponse({"success": True})
|
|
188
|
+
|
|
189
|
+
if isinstance(result, Response):
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
if isinstance(result, Document):
|
|
193
|
+
# Document returned directly treat as file response
|
|
194
|
+
path = result.path
|
|
195
|
+
return FileResponse(
|
|
196
|
+
path,
|
|
197
|
+
filename=path.name,
|
|
198
|
+
background=self._cleanup_callback(path, result._temp),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if isinstance(result, Path):
|
|
202
|
+
return FileResponse(
|
|
203
|
+
result,
|
|
204
|
+
filename=result.name,
|
|
205
|
+
background=self._cleanup_callback(result, self._temp_storage),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if isinstance(result, (dict, list)):
|
|
209
|
+
return JSONResponse(result)
|
|
210
|
+
|
|
211
|
+
if isinstance(result, str):
|
|
212
|
+
return JSONResponse({"text": result})
|
|
213
|
+
|
|
214
|
+
if isinstance(result, bytes):
|
|
215
|
+
return Response(content=result, media_type="application/octet-stream")
|
|
216
|
+
|
|
217
|
+
# Fallback
|
|
218
|
+
return JSONResponse({"result": str(result)})
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _cleanup_callback(path: Path, storage: TemporaryStorage):
|
|
222
|
+
"""Clean up managed output files after Starlette finishes streaming."""
|
|
223
|
+
from starlette.background import BackgroundTask
|
|
224
|
+
|
|
225
|
+
return BackgroundTask(storage.discard, path) if path in storage._files else None
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# ASGI interface
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def asgi(self) -> Starlette:
|
|
233
|
+
"""Return the underlying Starlette ASGI application."""
|
|
234
|
+
if self._starlette is None:
|
|
235
|
+
self._starlette = Starlette(
|
|
236
|
+
debug=self.config.debug,
|
|
237
|
+
routes=self._routes,
|
|
238
|
+
exception_handlers={
|
|
239
|
+
Exception: self._global_exception_handler,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
return self._starlette
|
|
243
|
+
|
|
244
|
+
async def _global_exception_handler(
|
|
245
|
+
self, request: Request, exc: Exception
|
|
246
|
+
) -> Response:
|
|
247
|
+
logger.exception("Unhandled ASGI exception")
|
|
248
|
+
return make_error_response(exc, status_code=500, debug=self.config.debug)
|
|
249
|
+
|
|
250
|
+
async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
|
|
251
|
+
"""ASGI callable delegates to the Starlette app."""
|
|
252
|
+
await self.asgi(scope, receive, send)
|
|
253
|
+
|
|
254
|
+
def __repr__(self) -> str:
|
|
255
|
+
return f"<eDocAPI App debug={self.config.debug}>"
|
edocapi/cli/__init__.py
ADDED
edocapi/cli/main.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""eDocAPI command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from edocapi import __author__, __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="edocapi",
|
|
16
|
+
description="eDocAPI - lightweight document-processing API framework",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--version",
|
|
20
|
+
action="version",
|
|
21
|
+
version=f"eDocAPI {__version__} (by {__author__})",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
sub = parser.add_subparsers(dest="command")
|
|
25
|
+
|
|
26
|
+
# edocapi run
|
|
27
|
+
run_parser = sub.add_parser("run", help="Start the ASGI server")
|
|
28
|
+
run_parser.add_argument(
|
|
29
|
+
"app",
|
|
30
|
+
nargs="?",
|
|
31
|
+
default="main:app",
|
|
32
|
+
help="Application import path (default: main:app)",
|
|
33
|
+
)
|
|
34
|
+
run_parser.add_argument(
|
|
35
|
+
"--host",
|
|
36
|
+
default="0.0.0.0",
|
|
37
|
+
help="Bind host (default: 0.0.0.0)",
|
|
38
|
+
)
|
|
39
|
+
run_parser.add_argument(
|
|
40
|
+
"--port",
|
|
41
|
+
type=int,
|
|
42
|
+
default=8000,
|
|
43
|
+
help="Bind port (default: 8000)",
|
|
44
|
+
)
|
|
45
|
+
run_parser.add_argument(
|
|
46
|
+
"--reload",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Enable auto-reload on code changes",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
if args.command is None:
|
|
54
|
+
parser.print_help()
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
if args.command == "run":
|
|
58
|
+
_run_server(args)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_server(args: argparse.Namespace) -> None:
|
|
62
|
+
try:
|
|
63
|
+
import uvicorn
|
|
64
|
+
except ImportError:
|
|
65
|
+
print(
|
|
66
|
+
"uvicorn is required to run the development server.\n"
|
|
67
|
+
"Install with: pip install 'uvicorn[standard]'",
|
|
68
|
+
file=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
module_str, _, attr = args.app.partition(":")
|
|
73
|
+
if not attr:
|
|
74
|
+
attr = "app"
|
|
75
|
+
|
|
76
|
+
print(f"eDocAPI v{__version__}")
|
|
77
|
+
print(f"Author: {__author__}")
|
|
78
|
+
print()
|
|
79
|
+
print(f"Application: {args.app}")
|
|
80
|
+
print(f"Server: http://{args.host}:{args.port}")
|
|
81
|
+
print(f"Reload: {'enabled' if args.reload else 'disabled'}")
|
|
82
|
+
print()
|
|
83
|
+
|
|
84
|
+
uvicorn.run(
|
|
85
|
+
f"{module_str}:{attr}",
|
|
86
|
+
host=args.host,
|
|
87
|
+
port=args.port,
|
|
88
|
+
reload=args.reload,
|
|
89
|
+
log_level="info",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
edocapi/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configuration helpers for eDocAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_SIZE_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$", re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
_SIZE_MULTIPLIERS = {
|
|
14
|
+
"B": 1,
|
|
15
|
+
"KB": 1024,
|
|
16
|
+
"MB": 1024 ** 2,
|
|
17
|
+
"GB": 1024 ** 3,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_size(value: str | int) -> int:
|
|
22
|
+
"""Parse a human-readable size string into bytes.
|
|
23
|
+
|
|
24
|
+
Accepts integers (already in bytes) or strings such as:
|
|
25
|
+
"10MB", "25 MB", "100kb", "512"
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(value, int):
|
|
28
|
+
if value < 0:
|
|
29
|
+
raise ValueError("Size must be non-negative")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
value = value.strip()
|
|
33
|
+
match = _SIZE_PATTERN.match(value)
|
|
34
|
+
if not match:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid size format: {value!r}. "
|
|
37
|
+
"Expected formats like '10MB', '25MB', '100KB'."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
number = float(match.group(1))
|
|
41
|
+
unit = (match.group(2) or "B").upper()
|
|
42
|
+
return int(number * _SIZE_MULTIPLIERS[unit])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Config:
|
|
47
|
+
"""Runtime configuration for an eDocAPI application."""
|
|
48
|
+
|
|
49
|
+
debug: bool = False
|
|
50
|
+
max_file_size: int = 10 * 1024 * 1024 # 10 MB default
|
|
51
|
+
temp_dir: Path | None = None
|
|
52
|
+
max_files: int = 20
|
|
53
|
+
|
|
54
|
+
# Internal: extra options for future use
|
|
55
|
+
_extra: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if isinstance(self.max_file_size, str):
|
|
59
|
+
self.max_file_size = parse_size(self.max_file_size)
|
|
60
|
+
if self.temp_dir is not None and not isinstance(self.temp_dir, Path):
|
|
61
|
+
self.temp_dir = Path(self.temp_dir)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_kwargs(cls, **kwargs: Any) -> "Config":
|
|
65
|
+
"""Build a Config from App() keyword arguments."""
|
|
66
|
+
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore
|
|
67
|
+
config_kwargs = {}
|
|
68
|
+
extra = {}
|
|
69
|
+
for key, value in kwargs.items():
|
|
70
|
+
if key in known and key != "_extra":
|
|
71
|
+
config_kwargs[key] = value
|
|
72
|
+
else:
|
|
73
|
+
extra[key] = value
|
|
74
|
+
cfg = cls(**config_kwargs)
|
|
75
|
+
cfg._extra = extra
|
|
76
|
+
return cfg
|