ebk 0.4.4__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.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/vfs/resolver.py ADDED
@@ -0,0 +1,228 @@
1
+ """Path resolution for the Virtual File System.
2
+
3
+ Handles path parsing and navigation (cd, ls semantics).
4
+ """
5
+
6
+ from pathlib import PurePosixPath
7
+ from typing import Optional, List, Tuple
8
+
9
+ from ebk.vfs.base import Node, DirectoryNode, SymlinkNode
10
+
11
+
12
+ class PathResolver:
13
+ """Resolves paths in the VFS and handles navigation.
14
+
15
+ This class provides the core navigation logic for cd, ls, etc.
16
+ It handles:
17
+ - Absolute paths: /books/42/title
18
+ - Relative paths: ../other, ./files
19
+ - Special paths: ., .., ~
20
+ - Symlink resolution
21
+ """
22
+
23
+ def __init__(self, root: DirectoryNode):
24
+ """Initialize path resolver.
25
+
26
+ Args:
27
+ root: Root node of the VFS
28
+ """
29
+ self.root = root
30
+
31
+ def resolve(
32
+ self,
33
+ path: str,
34
+ current: DirectoryNode,
35
+ follow_symlinks: bool = True,
36
+ ) -> Optional[Node]:
37
+ """Resolve a path to a node.
38
+
39
+ Args:
40
+ path: Path to resolve (absolute or relative)
41
+ current: Current working directory
42
+ follow_symlinks: Whether to follow symlinks
43
+
44
+ Returns:
45
+ Resolved node or None if path doesn't exist
46
+ """
47
+ # Handle empty path
48
+ if not path or path == ".":
49
+ return current
50
+
51
+ # Parse path
52
+ parts = self._parse_path(path)
53
+
54
+ # Start from root or current directory
55
+ if path.startswith("/"):
56
+ node = self.root
57
+ else:
58
+ node = current
59
+
60
+ # Navigate through path parts
61
+ for part in parts:
62
+ if part == "." or part == "":
63
+ continue
64
+ elif part == "..":
65
+ # Go to parent
66
+ if node.parent is not None:
67
+ node = node.parent
68
+ # Stay at root if already at root
69
+ else:
70
+ # Navigate to child
71
+ if not isinstance(node, DirectoryNode):
72
+ # Can't cd into a file
73
+ return None
74
+
75
+ child = node.get_child(part)
76
+ if child is None:
77
+ return None
78
+
79
+ # Follow symlinks if requested
80
+ if follow_symlinks and isinstance(child, SymlinkNode):
81
+ child = self.resolve(child.target_path, current, follow_symlinks=True)
82
+ if child is None:
83
+ return None
84
+
85
+ node = child
86
+
87
+ return node
88
+
89
+ def resolve_directory(
90
+ self,
91
+ path: str,
92
+ current: DirectoryNode,
93
+ ) -> Optional[DirectoryNode]:
94
+ """Resolve a path to a directory node.
95
+
96
+ Args:
97
+ path: Path to resolve
98
+ current: Current working directory
99
+
100
+ Returns:
101
+ Directory node or None if path doesn't exist or isn't a directory
102
+ """
103
+ node = self.resolve(path, current)
104
+ if node is None or not isinstance(node, DirectoryNode):
105
+ return None
106
+ return node
107
+
108
+ def normalize_path(self, path: str, current: DirectoryNode) -> str:
109
+ """Normalize a path to absolute form.
110
+
111
+ Args:
112
+ path: Path to normalize
113
+ current: Current working directory
114
+
115
+ Returns:
116
+ Normalized absolute path
117
+ """
118
+ # Resolve to node first
119
+ node = self.resolve(path, current)
120
+ if node is None:
121
+ # Path doesn't exist, do best effort normalization
122
+ return self._normalize_nonexistent(path, current)
123
+
124
+ return node.get_path()
125
+
126
+ def complete_path(
127
+ self,
128
+ partial: str,
129
+ current: DirectoryNode,
130
+ ) -> List[str]:
131
+ """Get completion candidates for a partial path.
132
+
133
+ Used for tab completion.
134
+
135
+ Args:
136
+ partial: Partial path to complete
137
+ current: Current working directory
138
+
139
+ Returns:
140
+ List of completion candidates
141
+ """
142
+ # Split into directory part and filename part
143
+ if "/" in partial:
144
+ dir_part, file_part = partial.rsplit("/", 1)
145
+ if partial.startswith("/"):
146
+ dir_part = "/" + dir_part if dir_part else "/"
147
+ else:
148
+ dir_part = ""
149
+ file_part = partial
150
+
151
+ # Resolve directory
152
+ if dir_part:
153
+ dir_node = self.resolve_directory(dir_part, current)
154
+ else:
155
+ dir_node = current
156
+
157
+ if dir_node is None:
158
+ return []
159
+
160
+ # Get children and filter by prefix
161
+ children = dir_node.list_children()
162
+ candidates = []
163
+
164
+ for child in children:
165
+ if child.name.startswith(file_part):
166
+ if dir_part:
167
+ candidates.append(f"{dir_part}/{child.name}")
168
+ else:
169
+ candidates.append(child.name)
170
+
171
+ # Add trailing slash for directories
172
+ if isinstance(child, DirectoryNode):
173
+ candidates[-1] += "/"
174
+
175
+ return candidates
176
+
177
+ def _parse_path(self, path: str) -> List[str]:
178
+ """Parse a path into parts.
179
+
180
+ Args:
181
+ path: Path to parse
182
+
183
+ Returns:
184
+ List of path components
185
+ """
186
+ # Use PurePosixPath for Unix-style path handling
187
+ posix_path = PurePosixPath(path)
188
+
189
+ # Get parts (excluding the root /)
190
+ parts = posix_path.parts
191
+ if parts and parts[0] == "/":
192
+ parts = parts[1:]
193
+
194
+ return list(parts)
195
+
196
+ def _normalize_nonexistent(self, path: str, current: DirectoryNode) -> str:
197
+ """Normalize a path that doesn't exist.
198
+
199
+ Args:
200
+ path: Path to normalize
201
+ current: Current working directory
202
+
203
+ Returns:
204
+ Best-effort normalized path
205
+ """
206
+ if path.startswith("/"):
207
+ # Already absolute
208
+ return str(PurePosixPath(path))
209
+
210
+ # Make relative path absolute
211
+ current_path = current.get_path()
212
+ combined = PurePosixPath(current_path) / path
213
+ return str(combined)
214
+
215
+
216
+ class PathError(Exception):
217
+ """Error resolving a path."""
218
+ pass
219
+
220
+
221
+ class NotADirectoryError(PathError):
222
+ """Attempted to cd into a non-directory."""
223
+ pass
224
+
225
+
226
+ class NotFoundError(PathError):
227
+ """Path does not exist."""
228
+ pass
ebk/vfs_router.py ADDED
@@ -0,0 +1,275 @@
1
+ """VFS REST API Router.
2
+
3
+ Provides REST endpoints for accessing the Virtual File System,
4
+ enabling programmatic navigation of the library structure.
5
+
6
+ Endpoints:
7
+ GET /api/vfs/ - Root directory listing
8
+ GET /api/vfs/{path:path} - Navigate to path, returns directory/file/symlink info
9
+
10
+ This allows API consumers to browse the library like a filesystem:
11
+ - /books/ - List all books
12
+ - /books/42/ - Book 42's directory
13
+ - /books/42/title - Read book title
14
+ - /books/42/files/ - List book files
15
+ - /authors/knuth/ - Books by author
16
+ """
17
+
18
+ from typing import Any, Dict, List, Optional, Union
19
+ from fastapi import APIRouter, HTTPException, Query
20
+ from pydantic import BaseModel
21
+
22
+ from ebk.vfs import LibraryVFS, DirectoryNode, FileNode, SymlinkNode
23
+
24
+
25
+ # Pydantic models for VFS responses
26
+ class VFSChild(BaseModel):
27
+ """Child entry in a directory listing."""
28
+ name: str
29
+ type: str # "directory", "file", "symlink", "virtual"
30
+ info: Dict[str, Any] = {}
31
+
32
+
33
+ class VFSDirectoryResponse(BaseModel):
34
+ """Response for directory nodes."""
35
+ type: str = "directory"
36
+ path: str
37
+ name: str
38
+ info: Dict[str, Any] = {}
39
+ children: List[VFSChild] = []
40
+ children_count: int = 0
41
+
42
+ class Config:
43
+ json_schema_extra = {
44
+ "example": {
45
+ "type": "directory",
46
+ "path": "/books/42",
47
+ "name": "42",
48
+ "info": {"title": "The Art of Computer Programming", "authors": "Donald Knuth"},
49
+ "children": [
50
+ {"name": "title", "type": "file", "info": {}},
51
+ {"name": "authors", "type": "file", "info": {}},
52
+ {"name": "files", "type": "directory", "info": {}},
53
+ {"name": "similar", "type": "virtual", "info": {}},
54
+ ],
55
+ "children_count": 4,
56
+ }
57
+ }
58
+
59
+
60
+ class VFSFileResponse(BaseModel):
61
+ """Response for file nodes."""
62
+ type: str = "file"
63
+ path: str
64
+ name: str
65
+ content: str
66
+ info: Dict[str, Any] = {}
67
+
68
+ class Config:
69
+ json_schema_extra = {
70
+ "example": {
71
+ "type": "file",
72
+ "path": "/books/42/title",
73
+ "name": "title",
74
+ "content": "The Art of Computer Programming",
75
+ "info": {"size": 34, "writable": False},
76
+ }
77
+ }
78
+
79
+
80
+ class VFSSymlinkResponse(BaseModel):
81
+ """Response for symlink nodes."""
82
+ type: str = "symlink"
83
+ path: str
84
+ name: str
85
+ target: str
86
+ info: Dict[str, Any] = {}
87
+
88
+ class Config:
89
+ json_schema_extra = {
90
+ "example": {
91
+ "type": "symlink",
92
+ "path": "/books/42/similar/1",
93
+ "name": "1",
94
+ "target": "/books/101",
95
+ "info": {"title": "Structure and Interpretation..."},
96
+ }
97
+ }
98
+
99
+
100
+ VFSResponse = Union[VFSDirectoryResponse, VFSFileResponse, VFSSymlinkResponse]
101
+
102
+
103
+ # Create router
104
+ router = APIRouter(prefix="/api/vfs", tags=["vfs"])
105
+
106
+
107
+ # Module-level VFS instance (initialized when library is set)
108
+ _vfs: Optional[LibraryVFS] = None
109
+
110
+
111
+ def get_vfs() -> LibraryVFS:
112
+ """Get the VFS instance."""
113
+ if _vfs is None:
114
+ raise HTTPException(status_code=500, detail="VFS not initialized")
115
+ return _vfs
116
+
117
+
118
+ def set_vfs(vfs: LibraryVFS) -> None:
119
+ """Set the VFS instance."""
120
+ global _vfs
121
+ _vfs = vfs
122
+
123
+
124
+ def init_vfs_from_library(library) -> None:
125
+ """Initialize VFS from a library instance."""
126
+ global _vfs
127
+ _vfs = LibraryVFS(library)
128
+
129
+
130
+
131
+ def _build_response(node, path: str, include_content: bool = True) -> VFSResponse:
132
+ """Build appropriate response for a node."""
133
+ info = node.get_info()
134
+
135
+ if isinstance(node, SymlinkNode):
136
+ return VFSSymlinkResponse(
137
+ path=path,
138
+ name=node.name,
139
+ target=node.target_path,
140
+ info={k: v for k, v in info.items() if k not in ("type", "name", "target", "path")},
141
+ )
142
+
143
+ if isinstance(node, FileNode):
144
+ content = ""
145
+ if include_content:
146
+ try:
147
+ content = node.read_content()
148
+ except Exception as e:
149
+ content = f"Error reading content: {e}"
150
+
151
+ return VFSFileResponse(
152
+ path=path,
153
+ name=node.name,
154
+ content=content,
155
+ info={k: v for k, v in info.items() if k not in ("type", "name", "path")},
156
+ )
157
+
158
+ if isinstance(node, DirectoryNode):
159
+ children = []
160
+ try:
161
+ child_nodes = node.list_children()
162
+ for child in child_nodes:
163
+ child_info = child.get_info()
164
+ children.append(VFSChild(
165
+ name=child.name,
166
+ type=child.node_type.value,
167
+ info={k: v for k, v in child_info.items() if k not in ("type", "name", "path")},
168
+ ))
169
+ except Exception as e:
170
+ # Log error but continue
171
+ pass
172
+
173
+ return VFSDirectoryResponse(
174
+ path=path,
175
+ name=node.name,
176
+ info={k: v for k, v in info.items() if k not in ("type", "name", "path", "children_count")},
177
+ children=children,
178
+ children_count=len(children),
179
+ )
180
+
181
+ # Fallback for unknown node types
182
+ raise HTTPException(status_code=500, detail=f"Unknown node type: {type(node)}")
183
+
184
+
185
+ @router.get(
186
+ "/",
187
+ response_model=VFSDirectoryResponse,
188
+ summary="VFS Root",
189
+ description="Get the root directory of the virtual filesystem.",
190
+ responses={
191
+ 200: {
192
+ "description": "Root directory listing",
193
+ "content": {
194
+ "application/json": {
195
+ "example": {
196
+ "type": "directory",
197
+ "path": "/",
198
+ "name": "",
199
+ "info": {},
200
+ "children": [
201
+ {"name": "books", "type": "virtual", "info": {}},
202
+ {"name": "authors", "type": "virtual", "info": {}},
203
+ {"name": "subjects", "type": "virtual", "info": {}},
204
+ {"name": "tags", "type": "virtual", "info": {}},
205
+ ],
206
+ "children_count": 4,
207
+ }
208
+ }
209
+ },
210
+ }
211
+ },
212
+ )
213
+ async def get_vfs_root():
214
+ """Get the VFS root directory listing.
215
+
216
+ Returns the top-level virtual directories:
217
+ - /books/ - Browse all books by ID
218
+ - /authors/ - Browse books by author
219
+ - /subjects/ - Browse books by subject
220
+ - /tags/ - Browse books by tag hierarchy
221
+ """
222
+ vfs = get_vfs()
223
+ return _build_response(vfs.root, "/")
224
+
225
+
226
+ @router.get(
227
+ "/{path:path}",
228
+ response_model=VFSResponse,
229
+ summary="VFS Path",
230
+ description="Navigate to a path in the virtual filesystem.",
231
+ responses={
232
+ 200: {
233
+ "description": "Node at the specified path (directory, file, or symlink)",
234
+ },
235
+ 404: {
236
+ "description": "Path not found",
237
+ },
238
+ },
239
+ )
240
+ async def get_vfs_path(
241
+ path: str,
242
+ follow_symlinks: bool = Query(True, description="Follow symbolic links to their targets"),
243
+ include_content: bool = Query(True, description="Include file content in response (for files only)"),
244
+ ):
245
+ """Navigate to a path in the VFS and return its contents.
246
+
247
+ For directories, returns the listing of children.
248
+ For files, returns the content.
249
+ For symlinks, returns link info (or follows if follow_symlinks=true).
250
+
251
+ Examples:
252
+ - /books/ - List all books
253
+ - /books/42 - Book 42's directory
254
+ - /books/42/title - Read book 42's title
255
+ - /books/42/authors - Read book 42's authors
256
+ - /books/42/files/ - List book 42's files
257
+ - /authors/knuth-donald/ - Books by Donald Knuth
258
+ - /tags/Work/Projects/ - Books tagged Work/Projects
259
+ """
260
+ vfs = get_vfs()
261
+
262
+ # Normalize path
263
+ if not path.startswith("/"):
264
+ path = "/" + path
265
+
266
+ # Handle trailing slash consistently
267
+ clean_path = path.rstrip("/") if path != "/" else path
268
+
269
+ # Resolve the path
270
+ node = vfs.resolver.resolve(clean_path, vfs.root, follow_symlinks=follow_symlinks)
271
+
272
+ if node is None:
273
+ raise HTTPException(status_code=404, detail=f"Path not found: {path}")
274
+
275
+ return _build_response(node, node.get_path(), include_content=include_content)
ebk/views/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ Views DSL for ebk.
3
+
4
+ A composable, non-destructive system for defining curated subsets of a library
5
+ with optional metadata overrides. Following SICP principles:
6
+
7
+ - Primitives: all, none, filter, ids, view references
8
+ - Combination: union, intersect, difference
9
+ - Abstraction: named views become new primitives
10
+ - Closure: combining views yields a view
11
+
12
+ Example:
13
+ from ebk.views import ViewEvaluator
14
+
15
+ evaluator = ViewEvaluator(session)
16
+
17
+ # Evaluate a view definition
18
+ books = evaluator.evaluate({
19
+ 'select': {
20
+ 'intersect': [
21
+ {'filter': {'subject': 'programming'}},
22
+ {'filter': {'favorite': True}}
23
+ ]
24
+ },
25
+ 'order': {'by': 'title'}
26
+ })
27
+ """
28
+
29
+ from .dsl import ViewEvaluator, TransformedBook
30
+ from .service import ViewService
31
+
32
+ __all__ = ['ViewEvaluator', 'TransformedBook', 'ViewService']