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 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}>"
@@ -0,0 +1,3 @@
1
+ from edocapi.cli.main import main
2
+
3
+ __all__ = ["main"]
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