codealmanac 0.1.0.dev0__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 (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,297 @@
1
+ from codealmanac.core.errors import ConflictError, NotFoundError, ValidationFailed
2
+ from codealmanac.core.slug import to_kebab_case
3
+ from codealmanac.services.index.models import TopicDetail, TopicSummary
4
+ from codealmanac.services.index.service import IndexService
5
+ from codealmanac.services.topics.models import (
6
+ TopicEdgeMutationResult,
7
+ TopicMutationAction,
8
+ TopicMutationResult,
9
+ TopicRewriteMutationResult,
10
+ )
11
+ from codealmanac.services.topics.requests import (
12
+ CreateTopicRequest,
13
+ DeleteTopicRequest,
14
+ DescribeTopicRequest,
15
+ LinkTopicRequest,
16
+ ListTopicsRequest,
17
+ RenameTopicRequest,
18
+ ShowTopicRequest,
19
+ UnlinkTopicRequest,
20
+ )
21
+ from codealmanac.services.wiki.frontmatter_rewrite import (
22
+ apply_page_topic_rewrites,
23
+ plan_page_topic_rewrites,
24
+ )
25
+ from codealmanac.services.wiki.topics import (
26
+ TopicDefinition,
27
+ load_topics_file,
28
+ title_for_slug,
29
+ )
30
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
31
+ from codealmanac.services.workspaces.service import WorkspacesService
32
+
33
+
34
+ class TopicsService:
35
+ def __init__(self, workspaces: WorkspacesService, index: IndexService):
36
+ self.workspaces = workspaces
37
+ self.index = index
38
+
39
+ def list(self, request: ListTopicsRequest) -> tuple[TopicSummary, ...]:
40
+ workspace = resolve_workspace(
41
+ self.workspaces,
42
+ request.cwd,
43
+ request.wiki,
44
+ )
45
+ return self.index.list_topics(workspace.workspace_id)
46
+
47
+ def show(self, request: ShowTopicRequest) -> TopicDetail:
48
+ workspace = resolve_workspace(
49
+ self.workspaces,
50
+ request.cwd,
51
+ request.wiki,
52
+ )
53
+ slug = to_kebab_case(request.slug)
54
+ topic = self.index.get_topic(
55
+ workspace.workspace_id,
56
+ slug,
57
+ request.include_descendants,
58
+ )
59
+ if topic is None:
60
+ raise NotFoundError("topic", request.slug)
61
+ return topic
62
+
63
+ def create(self, request: CreateTopicRequest) -> TopicMutationResult:
64
+ workspace = resolve_workspace(
65
+ self.workspaces,
66
+ request.cwd,
67
+ request.wiki,
68
+ )
69
+ slug = to_kebab_case(request.name)
70
+ if not slug:
71
+ raise ValidationFailed("topic name must contain slug-able characters")
72
+ existing = existing_topic_slugs(self.index, workspace.workspace_id)
73
+ topic_file = load_topics_file(workspace.almanac_path)
74
+ validate_parents_exist(slug, request.parents, existing)
75
+ for parent in request.parents:
76
+ topic_file.ensure_topic(parent, title_for_slug(parent))
77
+
78
+ existed_before = slug in existing or topic_file.has_entry(slug)
79
+ topic_file.ensure_topic(slug, request.name.strip())
80
+ topic_file.maybe_update_default_title(slug, request.name.strip())
81
+
82
+ for parent in request.parents:
83
+ if topic_file.add_parent(slug, parent):
84
+ reject_cycle(topic_file.definitions, slug, parent)
85
+
86
+ topic_file.write()
87
+ self.index.ensure_fresh(workspace.workspace_id)
88
+ action = (
89
+ TopicMutationAction.UPDATED
90
+ if existed_before
91
+ else TopicMutationAction.CREATED
92
+ )
93
+ topic = self.index.get_topic(workspace.workspace_id, slug, False)
94
+ return TopicMutationResult(
95
+ action=action,
96
+ slug=slug,
97
+ parents=topic.parents if topic is not None else request.parents,
98
+ description=topic.description if topic is not None else None,
99
+ )
100
+
101
+ def describe(self, request: DescribeTopicRequest) -> TopicMutationResult:
102
+ workspace = resolve_workspace(
103
+ self.workspaces,
104
+ request.cwd,
105
+ request.wiki,
106
+ )
107
+ existing = existing_topic_slugs(self.index, workspace.workspace_id)
108
+ if request.slug not in existing:
109
+ raise NotFoundError("topic", request.slug)
110
+ topic_file = load_topics_file(workspace.almanac_path)
111
+ topic_file.ensure_topic(request.slug, title_for_slug(request.slug))
112
+ description = request.description or None
113
+ topic_file.set_description(request.slug, description)
114
+ topic_file.write()
115
+ self.index.ensure_fresh(workspace.workspace_id)
116
+ topic = self.index.get_topic(workspace.workspace_id, request.slug, False)
117
+ return TopicMutationResult(
118
+ action=TopicMutationAction.DESCRIBED,
119
+ slug=request.slug,
120
+ parents=topic.parents if topic is not None else (),
121
+ description=description,
122
+ )
123
+
124
+ def link(self, request: LinkTopicRequest) -> TopicEdgeMutationResult:
125
+ workspace = resolve_workspace(
126
+ self.workspaces,
127
+ request.cwd,
128
+ request.wiki,
129
+ )
130
+ validate_not_self_parent(request.child, request.parent)
131
+ existing = existing_topic_slugs(self.index, workspace.workspace_id)
132
+ require_topics(existing, request.child, request.parent)
133
+ topic_file = load_topics_file(workspace.almanac_path)
134
+ topic_file.ensure_topic(request.child, title_for_slug(request.child))
135
+ topic_file.ensure_topic(request.parent, title_for_slug(request.parent))
136
+ if not topic_file.add_parent(request.child, request.parent):
137
+ return TopicEdgeMutationResult(
138
+ action=TopicMutationAction.ALREADY_LINKED,
139
+ child=request.child,
140
+ parent=request.parent,
141
+ )
142
+ reject_cycle(topic_file.definitions, request.child, request.parent)
143
+ topic_file.write()
144
+ self.index.ensure_fresh(workspace.workspace_id)
145
+ return TopicEdgeMutationResult(
146
+ action=TopicMutationAction.LINKED,
147
+ child=request.child,
148
+ parent=request.parent,
149
+ )
150
+
151
+ def unlink(self, request: UnlinkTopicRequest) -> TopicEdgeMutationResult:
152
+ workspace = resolve_workspace(
153
+ self.workspaces,
154
+ request.cwd,
155
+ request.wiki,
156
+ )
157
+ topic_file = load_topics_file(workspace.almanac_path)
158
+ if not topic_file.remove_parent(request.child, request.parent):
159
+ return TopicEdgeMutationResult(
160
+ action=TopicMutationAction.NO_EDGE,
161
+ child=request.child,
162
+ parent=request.parent,
163
+ )
164
+ topic_file.write()
165
+ self.index.ensure_fresh(workspace.workspace_id)
166
+ return TopicEdgeMutationResult(
167
+ action=TopicMutationAction.UNLINKED,
168
+ child=request.child,
169
+ parent=request.parent,
170
+ )
171
+
172
+ def rename(self, request: RenameTopicRequest) -> TopicRewriteMutationResult:
173
+ workspace = resolve_workspace(
174
+ self.workspaces,
175
+ request.cwd,
176
+ request.wiki,
177
+ )
178
+ if request.old_slug == request.new_slug:
179
+ return TopicRewriteMutationResult(
180
+ action=TopicMutationAction.UNCHANGED,
181
+ slug=request.old_slug,
182
+ new_slug=request.new_slug,
183
+ )
184
+ existing = existing_topic_slugs(self.index, workspace.workspace_id)
185
+ if request.old_slug not in existing:
186
+ raise NotFoundError("topic", request.old_slug)
187
+ if request.new_slug in existing:
188
+ raise ConflictError(
189
+ f'topic "{request.new_slug}" already exists; delete it first '
190
+ "if you intend to merge"
191
+ )
192
+
193
+ rewrites = plan_page_topic_rewrites(
194
+ workspace.almanac_path / "pages",
195
+ lambda topics: tuple(
196
+ request.new_slug if topic == request.old_slug else topic
197
+ for topic in topics
198
+ ),
199
+ )
200
+ topic_file = load_topics_file(workspace.almanac_path)
201
+ topic_file.rename_topic(request.old_slug, request.new_slug)
202
+ _ = topic_file.definitions
203
+ topic_file.write()
204
+ pages_updated = apply_page_topic_rewrites(rewrites)
205
+ self.index.ensure_fresh(workspace.workspace_id)
206
+ return TopicRewriteMutationResult(
207
+ action=TopicMutationAction.RENAMED,
208
+ slug=request.old_slug,
209
+ new_slug=request.new_slug,
210
+ pages_updated=pages_updated,
211
+ )
212
+
213
+ def delete(self, request: DeleteTopicRequest) -> TopicRewriteMutationResult:
214
+ workspace = resolve_workspace(
215
+ self.workspaces,
216
+ request.cwd,
217
+ request.wiki,
218
+ )
219
+ existing = existing_topic_slugs(self.index, workspace.workspace_id)
220
+ if request.slug not in existing:
221
+ raise NotFoundError("topic", request.slug)
222
+
223
+ rewrites = plan_page_topic_rewrites(
224
+ workspace.almanac_path / "pages",
225
+ lambda topics: tuple(topic for topic in topics if topic != request.slug),
226
+ )
227
+ topic_file = load_topics_file(workspace.almanac_path)
228
+ topic_file.delete_topic(request.slug)
229
+ _ = topic_file.definitions
230
+ topic_file.write()
231
+ pages_updated = apply_page_topic_rewrites(rewrites)
232
+ self.index.ensure_fresh(workspace.workspace_id)
233
+ return TopicRewriteMutationResult(
234
+ action=TopicMutationAction.DELETED,
235
+ slug=request.slug,
236
+ pages_updated=pages_updated,
237
+ )
238
+
239
+
240
+ def resolve_workspace(workspaces: WorkspacesService, cwd, wiki):
241
+ if wiki is None:
242
+ return workspaces.resolve(cwd)
243
+ return workspaces.select(SelectWorkspaceRequest(selector=wiki, base_path=cwd))
244
+
245
+
246
+ def existing_topic_slugs(index: IndexService, workspace_id: str) -> set[str]:
247
+ return {topic.slug for topic in index.list_topics(workspace_id)}
248
+
249
+
250
+ def validate_parents_exist(
251
+ child: str,
252
+ parents: tuple[str, ...],
253
+ existing: set[str],
254
+ ) -> None:
255
+ for parent in parents:
256
+ validate_not_self_parent(child, parent)
257
+ if parent not in existing:
258
+ raise NotFoundError("topic", parent)
259
+
260
+
261
+ def require_topics(existing: set[str], *slugs: str) -> None:
262
+ for slug in slugs:
263
+ if slug not in existing:
264
+ raise NotFoundError("topic", slug)
265
+
266
+
267
+ def validate_not_self_parent(child: str, parent: str) -> None:
268
+ if child == parent:
269
+ raise ValidationFailed("topic cannot be its own parent")
270
+
271
+
272
+ def reject_cycle(
273
+ definitions: tuple[TopicDefinition, ...],
274
+ child: str,
275
+ parent: str,
276
+ ) -> None:
277
+ if child in ancestors_of(definitions, parent):
278
+ raise ConflictError(
279
+ f"adding {parent} as parent of {child} would create a cycle"
280
+ )
281
+
282
+
283
+ def ancestors_of(definitions: tuple[TopicDefinition, ...], slug: str) -> set[str]:
284
+ parents_by_child = {
285
+ definition.slug: set(definition.parents) for definition in definitions
286
+ }
287
+ ancestors: set[str] = set()
288
+ frontier = list(parents_by_child.get(slug, set()))
289
+ depth = 0
290
+ while frontier and depth < 32:
291
+ depth += 1
292
+ parent = frontier.pop()
293
+ if parent in ancestors:
294
+ continue
295
+ ancestors.add(parent)
296
+ frontier.extend(parents_by_child.get(parent, set()))
297
+ return ancestors
@@ -0,0 +1,4 @@
1
+ from codealmanac.services.updates.service import UpdatesService
2
+
3
+ __all__ = ["UpdatesService"]
4
+
@@ -0,0 +1,83 @@
1
+ import sys
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
5
+ from pydantic import field_validator
6
+
7
+ from codealmanac.core.models import CodeAlmanacModel
8
+ from codealmanac.core.text import required_text
9
+
10
+ PACKAGE_NAME = "codealmanac"
11
+
12
+
13
+ class UpdateInstallMethod(StrEnum):
14
+ UV_TOOL = "uv-tool"
15
+ PIP = "pip"
16
+ EDITABLE = "editable"
17
+ UNKNOWN = "unknown"
18
+
19
+
20
+ class UpdateStatus(StrEnum):
21
+ READY = "ready"
22
+ COMPLETED = "completed"
23
+ UNSUPPORTED = "unsupported"
24
+ FAILED = "failed"
25
+
26
+
27
+ class PackageInstallMetadata(CodeAlmanacModel):
28
+ package: str = PACKAGE_NAME
29
+ version: str
30
+ installer: str | None = None
31
+ editable: bool = False
32
+ source_url: str | None = None
33
+ python_executable: Path = Path(sys.executable)
34
+
35
+ @field_validator("package", "version")
36
+ @classmethod
37
+ def require_text_fields(cls, value: str) -> str:
38
+ return required_text(value, "package install metadata")
39
+
40
+ @field_validator("installer", "source_url")
41
+ @classmethod
42
+ def normalize_optional_text(cls, value: str | None) -> str | None:
43
+ if value is None:
44
+ return None
45
+ return required_text(value, "package install metadata")
46
+
47
+
48
+ class PackageCommandResult(CodeAlmanacModel):
49
+ exit_code: int
50
+ stdout: str = ""
51
+ stderr: str = ""
52
+
53
+
54
+ class UpdatePlan(CodeAlmanacModel):
55
+ status: UpdateStatus
56
+ method: UpdateInstallMethod
57
+ installed_version: str
58
+ command: tuple[str, ...] = ()
59
+ message: str
60
+ fix: str | None = None
61
+ installer: str | None = None
62
+ editable: bool = False
63
+ source_url: str | None = None
64
+
65
+ @field_validator("installed_version", "message")
66
+ @classmethod
67
+ def require_text_fields(cls, value: str) -> str:
68
+ return required_text(value, "update plan")
69
+
70
+ @field_validator("fix", "installer", "source_url")
71
+ @classmethod
72
+ def normalize_optional_text(cls, value: str | None) -> str | None:
73
+ if value is None:
74
+ return None
75
+ return required_text(value, "update plan")
76
+
77
+
78
+ class UpdateResult(CodeAlmanacModel):
79
+ status: UpdateStatus
80
+ plan: UpdatePlan
81
+ exit_code: int | None = None
82
+ stdout: str = ""
83
+ stderr: str = ""
@@ -0,0 +1,17 @@
1
+ from typing import Protocol
2
+
3
+ from codealmanac.services.updates.models import (
4
+ PackageCommandResult,
5
+ PackageInstallMetadata,
6
+ )
7
+
8
+
9
+ class PackageInstallMetadataProvider(Protocol):
10
+ def read(self) -> PackageInstallMetadata:
11
+ """Return metadata for the currently running package installation."""
12
+
13
+
14
+ class PackageCommandRunner(Protocol):
15
+ def run(self, command: tuple[str, ...]) -> PackageCommandResult:
16
+ """Run a foreground package-manager command."""
17
+
@@ -0,0 +1,10 @@
1
+ from codealmanac.core.models import CodeAlmanacModel
2
+
3
+
4
+ class CheckUpdateRequest(CodeAlmanacModel):
5
+ pass
6
+
7
+
8
+ class RunUpdateRequest(CodeAlmanacModel):
9
+ pass
10
+
@@ -0,0 +1,113 @@
1
+ from codealmanac.services.updates.models import (
2
+ PACKAGE_NAME,
3
+ PackageInstallMetadata,
4
+ UpdateInstallMethod,
5
+ UpdatePlan,
6
+ UpdateResult,
7
+ UpdateStatus,
8
+ )
9
+ from codealmanac.services.updates.ports import (
10
+ PackageCommandRunner,
11
+ PackageInstallMetadataProvider,
12
+ )
13
+ from codealmanac.services.updates.requests import CheckUpdateRequest, RunUpdateRequest
14
+
15
+
16
+ class UpdatesService:
17
+ def __init__(
18
+ self,
19
+ metadata: PackageInstallMetadataProvider,
20
+ runner: PackageCommandRunner,
21
+ ):
22
+ self.metadata = metadata
23
+ self.runner = runner
24
+
25
+ def check(self, request: CheckUpdateRequest) -> UpdatePlan:
26
+ return plan_update(self.metadata.read())
27
+
28
+ def run(self, request: RunUpdateRequest) -> UpdateResult:
29
+ plan = self.check(CheckUpdateRequest())
30
+ if plan.status != UpdateStatus.READY:
31
+ return UpdateResult(status=plan.status, plan=plan)
32
+ output = self.runner.run(plan.command)
33
+ status = (
34
+ UpdateStatus.COMPLETED
35
+ if output.exit_code == 0
36
+ else UpdateStatus.FAILED
37
+ )
38
+ return UpdateResult(
39
+ status=status,
40
+ plan=plan,
41
+ exit_code=output.exit_code,
42
+ stdout=output.stdout,
43
+ stderr=output.stderr,
44
+ )
45
+
46
+
47
+ def plan_update(metadata: PackageInstallMetadata) -> UpdatePlan:
48
+ method = update_method(metadata)
49
+ if method == UpdateInstallMethod.EDITABLE:
50
+ return UpdatePlan(
51
+ status=UpdateStatus.UNSUPPORTED,
52
+ method=method,
53
+ installed_version=metadata.version,
54
+ message="editable source install cannot be self-updated",
55
+ fix="run: git pull && uv sync",
56
+ installer=metadata.installer,
57
+ editable=metadata.editable,
58
+ source_url=metadata.source_url,
59
+ )
60
+ if method == UpdateInstallMethod.UV_TOOL:
61
+ command = ("uv", "tool", "upgrade", PACKAGE_NAME)
62
+ return runnable_plan(metadata, method, command)
63
+ if method == UpdateInstallMethod.PIP:
64
+ command = (
65
+ str(metadata.python_executable),
66
+ "-m",
67
+ "pip",
68
+ "install",
69
+ "--upgrade",
70
+ PACKAGE_NAME,
71
+ )
72
+ return runnable_plan(metadata, method, command)
73
+ return UpdatePlan(
74
+ status=UpdateStatus.UNSUPPORTED,
75
+ method=method,
76
+ installed_version=metadata.version,
77
+ message="unknown package installer; refusing automatic update",
78
+ fix=(
79
+ f"run: uv tool upgrade {PACKAGE_NAME} "
80
+ f"or {metadata.python_executable} -m pip install --upgrade {PACKAGE_NAME}"
81
+ ),
82
+ installer=metadata.installer,
83
+ editable=metadata.editable,
84
+ source_url=metadata.source_url,
85
+ )
86
+
87
+
88
+ def runnable_plan(
89
+ metadata: PackageInstallMetadata,
90
+ method: UpdateInstallMethod,
91
+ command: tuple[str, ...],
92
+ ) -> UpdatePlan:
93
+ return UpdatePlan(
94
+ status=UpdateStatus.READY,
95
+ method=method,
96
+ installed_version=metadata.version,
97
+ command=command,
98
+ message="ready to run foreground package update",
99
+ installer=metadata.installer,
100
+ editable=metadata.editable,
101
+ source_url=metadata.source_url,
102
+ )
103
+
104
+
105
+ def update_method(metadata: PackageInstallMetadata) -> UpdateInstallMethod:
106
+ if metadata.editable:
107
+ return UpdateInstallMethod.EDITABLE
108
+ installer = (metadata.installer or "").strip().casefold()
109
+ if installer == "uv":
110
+ return UpdateInstallMethod.UV_TOOL
111
+ if installer == "pip":
112
+ return UpdateInstallMethod.PIP
113
+ return UpdateInstallMethod.UNKNOWN
@@ -0,0 +1 @@
1
+ """Read-only browser viewer service."""
@@ -0,0 +1,80 @@
1
+ from enum import StrEnum
2
+ from pathlib import Path
3
+
4
+ from codealmanac.core.models import CodeAlmanacModel
5
+
6
+
7
+ class ViewerFileKind(StrEnum):
8
+ FILE = "file"
9
+ DIRECTORY = "directory"
10
+
11
+
12
+ class ViewerWorkspace(CodeAlmanacModel):
13
+ name: str
14
+ root_path: Path
15
+
16
+
17
+ class ViewerPageSummary(CodeAlmanacModel):
18
+ slug: str
19
+ title: str | None
20
+ summary: str | None
21
+ topics: tuple[str, ...]
22
+ archived: bool
23
+
24
+
25
+ class ViewerTopicSummary(CodeAlmanacModel):
26
+ slug: str
27
+ title: str | None
28
+ description: str | None
29
+ page_count: int
30
+
31
+
32
+ class ViewerFileReference(CodeAlmanacModel):
33
+ path: str
34
+ is_dir: bool
35
+
36
+
37
+ class ViewerOverview(CodeAlmanacModel):
38
+ workspace: ViewerWorkspace
39
+ page_count: int
40
+ topic_count: int
41
+ pages: tuple[ViewerPageSummary, ...]
42
+ topics: tuple[ViewerTopicSummary, ...]
43
+ featured_page: ViewerPageSummary | None
44
+
45
+
46
+ class ViewerPage(CodeAlmanacModel):
47
+ workspace: ViewerWorkspace
48
+ slug: str
49
+ title: str | None
50
+ summary: str | None
51
+ topics: tuple[str, ...]
52
+ body: str
53
+ html: str
54
+ backlinks: tuple[str, ...]
55
+ outgoing_links: tuple[str, ...]
56
+ file_refs: tuple[ViewerFileReference, ...]
57
+ related_pages: tuple[ViewerPageSummary, ...]
58
+
59
+
60
+ class ViewerSearch(CodeAlmanacModel):
61
+ workspace: ViewerWorkspace
62
+ query: str | None
63
+ pages: tuple[ViewerPageSummary, ...]
64
+
65
+
66
+ class ViewerFile(CodeAlmanacModel):
67
+ workspace: ViewerWorkspace
68
+ path: str
69
+ kind: ViewerFileKind
70
+ pages: tuple[ViewerPageSummary, ...]
71
+
72
+
73
+ class ViewerTopic(CodeAlmanacModel):
74
+ workspace: ViewerWorkspace
75
+ slug: str
76
+ title: str | None
77
+ description: str | None
78
+ parents: tuple[str, ...]
79
+ children: tuple[str, ...]
80
+ pages: tuple[ViewerPageSummary, ...]
@@ -0,0 +1,89 @@
1
+ import re
2
+ from urllib.parse import quote
3
+
4
+ from markdown_it import MarkdownIt
5
+ from markdown_it.token import Token
6
+
7
+ from codealmanac.services.wiki.models import (
8
+ CrossWikiLink,
9
+ FileLink,
10
+ FolderLink,
11
+ PageLink,
12
+ )
13
+ from codealmanac.services.wiki.wikilinks import classify_wikilink
14
+
15
+ WIKILINK_RE = re.compile(r"\[\[([^\]\n]+)\]\]")
16
+
17
+
18
+ class MarkdownRenderer:
19
+ def __init__(self):
20
+ self.markdown = MarkdownIt("commonmark", {"html": False, "linkify": False})
21
+
22
+ def render(self, body: str) -> str:
23
+ env: dict[str, object] = {}
24
+ tokens = self.markdown.parse(body, env)
25
+ for token in tokens:
26
+ if token.type == "inline" and token.children is not None:
27
+ token.children = rewrite_wikilinks(token.children)
28
+ return self.markdown.renderer.render(tokens, self.markdown.options, env)
29
+
30
+
31
+ def rewrite_wikilinks(tokens: list[Token]) -> list[Token]:
32
+ rewritten: list[Token] = []
33
+ for token in tokens:
34
+ if token.type != "text":
35
+ rewritten.append(token)
36
+ continue
37
+ rewritten.extend(rewrite_text_token(token.content))
38
+ return rewritten
39
+
40
+
41
+ def rewrite_text_token(value: str) -> list[Token]:
42
+ rewritten: list[Token] = []
43
+ position = 0
44
+ for match in WIKILINK_RE.finditer(value):
45
+ if match.start() > position:
46
+ rewritten.append(text_token(value[position : match.start()]))
47
+ rewritten.extend(tokens_for_wikilink_match(match))
48
+ position = match.end()
49
+ if position < len(value):
50
+ rewritten.append(text_token(value[position:]))
51
+ return rewritten or [text_token(value)]
52
+
53
+
54
+ def tokens_for_wikilink_match(match: re.Match[str]) -> list[Token]:
55
+ raw = match.group(1)
56
+ link = classify_wikilink(raw)
57
+ if link is None:
58
+ return [text_token(match.group(0))]
59
+ label = wikilink_label(raw)
60
+ if isinstance(link, PageLink):
61
+ slug = quote(link.target, safe="")
62
+ return link_tokens(label, f"#/page/{slug}")
63
+ if isinstance(link, FileLink | FolderLink):
64
+ return [code_token(label)]
65
+ if isinstance(link, CrossWikiLink):
66
+ return [code_token(label)]
67
+ return [text_token(match.group(0))]
68
+
69
+
70
+ def text_token(value: str) -> Token:
71
+ return Token("text", "", 0, content=value)
72
+
73
+
74
+ def code_token(value: str) -> Token:
75
+ return Token("code_inline", "code", 0, content=value)
76
+
77
+
78
+ def link_tokens(label: str, href: str) -> list[Token]:
79
+ opening = Token("link_open", "a", 1, attrs={"href": href})
80
+ closing = Token("link_close", "a", -1)
81
+ return [opening, text_token(label), closing]
82
+
83
+
84
+ def wikilink_label(raw: str) -> str:
85
+ target, separator, label = raw.partition("|")
86
+ if separator:
87
+ return label.strip() or target.strip()
88
+ return target.strip()
89
+