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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from codealmanac.services.index.models import (
|
|
6
|
+
HealthReport,
|
|
7
|
+
IndexRefreshResult,
|
|
8
|
+
PageView,
|
|
9
|
+
SearchPageResult,
|
|
10
|
+
TopicDetail,
|
|
11
|
+
TopicSummary,
|
|
12
|
+
)
|
|
13
|
+
from codealmanac.services.tagging.models import TaggingResult
|
|
14
|
+
from codealmanac.services.topics.models import (
|
|
15
|
+
TopicEdgeMutationResult,
|
|
16
|
+
TopicMutationAction,
|
|
17
|
+
TopicMutationResult,
|
|
18
|
+
TopicRewriteMutationResult,
|
|
19
|
+
)
|
|
20
|
+
from codealmanac.services.workspaces.models import (
|
|
21
|
+
DropWorkspaceResult,
|
|
22
|
+
WorkspaceListResult,
|
|
23
|
+
)
|
|
24
|
+
from codealmanac.workflows.garden.models import GardenResult
|
|
25
|
+
from codealmanac.workflows.ingest.models import IngestResult
|
|
26
|
+
from codealmanac.workflows.sync.models import SyncMode, SyncSummary
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_search(rows: tuple[SearchPageResult, ...], json_output: bool) -> None:
|
|
30
|
+
if json_output:
|
|
31
|
+
data = [row.model_dump(mode="json") for row in rows]
|
|
32
|
+
print(json.dumps(data, indent=2))
|
|
33
|
+
return
|
|
34
|
+
if len(rows) == 0:
|
|
35
|
+
print("# 0 results", file=sys.stderr)
|
|
36
|
+
return
|
|
37
|
+
for row in rows:
|
|
38
|
+
print(row.slug)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_workspace_list(result: WorkspaceListResult, json_output: bool) -> None:
|
|
42
|
+
if json_output:
|
|
43
|
+
data = [item.model_dump(mode="json") for item in result.items]
|
|
44
|
+
print(json.dumps(data, indent=2))
|
|
45
|
+
return
|
|
46
|
+
for item in result.items:
|
|
47
|
+
workspace = item.workspace
|
|
48
|
+
print(
|
|
49
|
+
f"{workspace.name}\t{workspace.root_path}\t"
|
|
50
|
+
f"{workspace.almanac_root.as_posix()}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_workspace_drop(result: DropWorkspaceResult, json_output: bool) -> None:
|
|
55
|
+
if json_output:
|
|
56
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
57
|
+
return
|
|
58
|
+
if len(result.dropped) == 0:
|
|
59
|
+
print("# 0 wikis dropped", file=sys.stderr)
|
|
60
|
+
return
|
|
61
|
+
for workspace in result.dropped:
|
|
62
|
+
print(
|
|
63
|
+
f"dropped {workspace.name}\t{workspace.root_path}\t"
|
|
64
|
+
f"{workspace.almanac_root.as_posix()}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_build(workspace_name: str, result: IndexRefreshResult) -> None:
|
|
69
|
+
print(f"built {workspace_name}: {index_summary(result)}")
|
|
70
|
+
|
|
71
|
+
def render_ingest(result: IngestResult) -> None:
|
|
72
|
+
print(f"ingested {result.run.run_id}: {result.run.status.value}")
|
|
73
|
+
print(f"sources: {len(result.sources)}")
|
|
74
|
+
print(f"wiki_changes: {len(result.safety.changed_files)}")
|
|
75
|
+
if result.run.summary is not None:
|
|
76
|
+
print(f"summary: {result.run.summary}")
|
|
77
|
+
|
|
78
|
+
def render_garden(result: GardenResult) -> None:
|
|
79
|
+
print(f"gardened {result.run.run_id}: {result.run.status.value}")
|
|
80
|
+
print(f"wiki_changes: {len(result.safety.changed_files)}")
|
|
81
|
+
print(f"health_before: {health_issue_count(result.health_before)}")
|
|
82
|
+
if result.run.summary is not None:
|
|
83
|
+
print(f"summary: {result.run.summary}")
|
|
84
|
+
|
|
85
|
+
def render_sync_status(summary: SyncSummary, json_output: bool) -> None:
|
|
86
|
+
if json_output:
|
|
87
|
+
print(json.dumps(summary.model_dump(mode="json"), indent=2))
|
|
88
|
+
return
|
|
89
|
+
status_mode = summary.mode == SyncMode.STATUS
|
|
90
|
+
print("sync status:" if status_mode else "sync:")
|
|
91
|
+
print(f" scanned: {summary.scanned}")
|
|
92
|
+
print(f" eligible: {summary.eligible}")
|
|
93
|
+
if status_mode:
|
|
94
|
+
print(f" ready: {len(summary.ready)}")
|
|
95
|
+
else:
|
|
96
|
+
print(f" started: {len(summary.started)}")
|
|
97
|
+
print(f" skipped: {len(summary.skipped)}")
|
|
98
|
+
print(f" needs_attention: {len(summary.needs_attention)}")
|
|
99
|
+
for ready in summary.ready:
|
|
100
|
+
print(
|
|
101
|
+
f" - ready {ready.app.value} {ready.session_id}: "
|
|
102
|
+
f"lines {ready.from_line}-{ready.to_line}"
|
|
103
|
+
)
|
|
104
|
+
for started in summary.started:
|
|
105
|
+
print(
|
|
106
|
+
f" - started {started.app.value} {started.session_id}: "
|
|
107
|
+
f"{started.run_id} (lines {started.from_line}-{started.to_line})"
|
|
108
|
+
)
|
|
109
|
+
for item in summary.needs_attention:
|
|
110
|
+
print(f" - needs attention {item.transcript_path}: {item.reason}")
|
|
111
|
+
|
|
112
|
+
def health_issue_count(report: HealthReport) -> int:
|
|
113
|
+
return sum(
|
|
114
|
+
len(items)
|
|
115
|
+
for items in (
|
|
116
|
+
report.orphans,
|
|
117
|
+
report.dead_refs,
|
|
118
|
+
report.broken_links,
|
|
119
|
+
report.broken_xwiki,
|
|
120
|
+
report.empty_topics,
|
|
121
|
+
report.empty_pages,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def render_reindex(result: IndexRefreshResult, json_output: bool) -> None:
|
|
126
|
+
if json_output:
|
|
127
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
128
|
+
return
|
|
129
|
+
print(f"reindexed: {index_summary(result)}")
|
|
130
|
+
|
|
131
|
+
def index_summary(result: IndexRefreshResult) -> str:
|
|
132
|
+
skip_suffix = (
|
|
133
|
+
f"; {result.files_skipped} skipped" if result.files_skipped > 0 else ""
|
|
134
|
+
)
|
|
135
|
+
return (
|
|
136
|
+
f"{result.pages_indexed} {page_word(result.pages_indexed)} "
|
|
137
|
+
f"({result.changed} updated, {result.removed} removed{skip_suffix})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def render_page(page: PageView, args: argparse.Namespace) -> None:
|
|
141
|
+
if args.json:
|
|
142
|
+
print(json.dumps(page.model_dump(mode="json"), indent=2))
|
|
143
|
+
return
|
|
144
|
+
if args.body:
|
|
145
|
+
print(body_with_trailing_newline(page.body), end="")
|
|
146
|
+
return
|
|
147
|
+
if args.links:
|
|
148
|
+
print_lines(page.wikilinks_out)
|
|
149
|
+
return
|
|
150
|
+
if args.backlinks:
|
|
151
|
+
print_lines(page.wikilinks_in)
|
|
152
|
+
return
|
|
153
|
+
if args.files:
|
|
154
|
+
print_lines(tuple(ref.path for ref in page.file_refs))
|
|
155
|
+
return
|
|
156
|
+
if args.topics:
|
|
157
|
+
print_lines(page.topics)
|
|
158
|
+
return
|
|
159
|
+
if args.meta:
|
|
160
|
+
print(metadata_header(page))
|
|
161
|
+
return
|
|
162
|
+
if args.lead:
|
|
163
|
+
print(first_paragraph(page.body))
|
|
164
|
+
return
|
|
165
|
+
print(body_with_trailing_newline(page.body), end="")
|
|
166
|
+
|
|
167
|
+
def print_lines(values: tuple[str, ...]) -> None:
|
|
168
|
+
for value in values:
|
|
169
|
+
print(value)
|
|
170
|
+
|
|
171
|
+
def metadata_header(page: PageView) -> str:
|
|
172
|
+
lines = [
|
|
173
|
+
f"slug: {page.slug}",
|
|
174
|
+
f"title: {page.title or ''}",
|
|
175
|
+
f"path: {page.file_path}",
|
|
176
|
+
]
|
|
177
|
+
if page.summary:
|
|
178
|
+
lines.append(f"summary: {page.summary}")
|
|
179
|
+
if page.topics:
|
|
180
|
+
lines.append(f"topics: {', '.join(page.topics)}")
|
|
181
|
+
return "\n".join(lines)
|
|
182
|
+
|
|
183
|
+
def first_paragraph(body: str) -> str:
|
|
184
|
+
paragraphs = [part.strip() for part in body.split("\n\n") if part.strip()]
|
|
185
|
+
return paragraphs[0] if paragraphs else ""
|
|
186
|
+
|
|
187
|
+
def body_with_trailing_newline(body: str) -> str:
|
|
188
|
+
if body == "" or body.endswith("\n"):
|
|
189
|
+
return body
|
|
190
|
+
return f"{body}\n"
|
|
191
|
+
|
|
192
|
+
def render_topics(rows: tuple[TopicSummary, ...]) -> None:
|
|
193
|
+
for row in rows:
|
|
194
|
+
title = row.title or row.slug
|
|
195
|
+
print(f"{row.slug}\t{row.page_count}\t{title}")
|
|
196
|
+
|
|
197
|
+
def render_topic(topic: TopicDetail) -> None:
|
|
198
|
+
print(f"slug: {topic.slug}")
|
|
199
|
+
print(f"title: {topic.title or ''}")
|
|
200
|
+
if topic.description:
|
|
201
|
+
print(f"description: {topic.description}")
|
|
202
|
+
if topic.parents:
|
|
203
|
+
print(f"parents: {', '.join(topic.parents)}")
|
|
204
|
+
if topic.children:
|
|
205
|
+
print(f"children: {', '.join(topic.children)}")
|
|
206
|
+
if topic.pages:
|
|
207
|
+
print("pages:")
|
|
208
|
+
for slug in topic.pages:
|
|
209
|
+
print(f" {slug}")
|
|
210
|
+
else:
|
|
211
|
+
print("pages: none")
|
|
212
|
+
|
|
213
|
+
def render_topic_mutation(result: TopicMutationResult) -> None:
|
|
214
|
+
print(f"{result.slug}: {result.action.value}")
|
|
215
|
+
|
|
216
|
+
def render_topic_edge_mutation(result: TopicEdgeMutationResult) -> None:
|
|
217
|
+
if result.action == TopicMutationAction.NO_EDGE:
|
|
218
|
+
print(f"no edge {result.child} -> {result.parent}")
|
|
219
|
+
return
|
|
220
|
+
if result.action == TopicMutationAction.ALREADY_LINKED:
|
|
221
|
+
print(f"edge {result.child} -> {result.parent} already exists")
|
|
222
|
+
return
|
|
223
|
+
print(f"{result.action.value} {result.child} -> {result.parent}")
|
|
224
|
+
|
|
225
|
+
def render_topic_rewrite_mutation(result: TopicRewriteMutationResult) -> None:
|
|
226
|
+
if result.action == TopicMutationAction.UNCHANGED:
|
|
227
|
+
print(f"topic {result.slug} unchanged")
|
|
228
|
+
return
|
|
229
|
+
if result.action == TopicMutationAction.RENAMED:
|
|
230
|
+
print(
|
|
231
|
+
f"renamed {result.slug} -> {result.new_slug} "
|
|
232
|
+
f"({result.pages_updated} {page_word(result.pages_updated)} updated)"
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
if result.action == TopicMutationAction.DELETED:
|
|
236
|
+
print(
|
|
237
|
+
f"deleted {result.slug} "
|
|
238
|
+
f"({result.pages_updated} {page_word(result.pages_updated)} untagged)"
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
print(f"{result.slug}: {result.action.value}")
|
|
242
|
+
|
|
243
|
+
def page_word(count: int) -> str:
|
|
244
|
+
return "page" if count == 1 else "pages"
|
|
245
|
+
|
|
246
|
+
def render_health(report: HealthReport, json_output: bool) -> None:
|
|
247
|
+
if json_output:
|
|
248
|
+
print(json.dumps(report.model_dump(mode="json"), indent=2))
|
|
249
|
+
return
|
|
250
|
+
render_health_section("orphans", tuple(item.slug for item in report.orphans))
|
|
251
|
+
render_health_section(
|
|
252
|
+
"dead_refs",
|
|
253
|
+
tuple(f"{item.slug}\t{item.path}" for item in report.dead_refs),
|
|
254
|
+
)
|
|
255
|
+
render_health_section(
|
|
256
|
+
"broken_links",
|
|
257
|
+
tuple(
|
|
258
|
+
f"{item.source_slug}\t{item.target_slug}" for item in report.broken_links
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
render_health_section(
|
|
262
|
+
"broken_xwiki",
|
|
263
|
+
tuple(
|
|
264
|
+
f"{item.source_slug}\t{item.target_wiki}:{item.target_slug}"
|
|
265
|
+
for item in report.broken_xwiki
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
render_health_section(
|
|
269
|
+
"empty_topics",
|
|
270
|
+
tuple(item.slug for item in report.empty_topics),
|
|
271
|
+
)
|
|
272
|
+
render_health_section(
|
|
273
|
+
"empty_pages",
|
|
274
|
+
tuple(item.slug for item in report.empty_pages),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def render_health_section(name: str, rows: tuple[str, ...]) -> None:
|
|
278
|
+
if not rows:
|
|
279
|
+
print(f"{name} (0): ok")
|
|
280
|
+
return
|
|
281
|
+
print(f"{name} ({len(rows)}):")
|
|
282
|
+
for row in rows:
|
|
283
|
+
print(f" {row}")
|
|
284
|
+
|
|
285
|
+
def render_tagging(changed_label: str, unchanged_label: str, result: TaggingResult):
|
|
286
|
+
if result.changed_topics:
|
|
287
|
+
print(f"{result.slug}: {changed_label} {', '.join(result.changed_topics)}")
|
|
288
|
+
return
|
|
289
|
+
unchanged = ", ".join(result.requested_topics)
|
|
290
|
+
print(f"{result.slug}: {unchanged_label} {unchanged}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
ErrorCode = Literal[
|
|
4
|
+
"codealmanac_error",
|
|
5
|
+
"not_found",
|
|
6
|
+
"conflict",
|
|
7
|
+
"validation_failed",
|
|
8
|
+
"execution_failed",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CodeAlmanacError(Exception):
|
|
13
|
+
"""Base class for product errors that cross CLI and future server edges."""
|
|
14
|
+
|
|
15
|
+
code: ErrorCode = "codealmanac_error"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(CodeAlmanacError):
|
|
19
|
+
code: ErrorCode = "not_found"
|
|
20
|
+
|
|
21
|
+
def __init__(self, resource: str, identifier: str):
|
|
22
|
+
super().__init__(f"{resource} not found: {identifier}")
|
|
23
|
+
self.resource = resource
|
|
24
|
+
self.identifier = identifier
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConflictError(CodeAlmanacError):
|
|
28
|
+
code: ErrorCode = "conflict"
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationFailed(CodeAlmanacError):
|
|
35
|
+
code: ErrorCode = "validation_failed"
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str):
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionFailed(CodeAlmanacError):
|
|
42
|
+
code: ErrorCode = "execution_failed"
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str):
|
|
45
|
+
super().__init__(message)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from codealmanac.core.paths import default_config_path, default_registry_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CodeAlmanacModel(BaseModel):
|
|
9
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppConfig(CodeAlmanacModel):
|
|
13
|
+
registry_path: Path = Field(default_factory=default_registry_path)
|
|
14
|
+
config_path: Path = Field(default_factory=default_config_path)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def home_dir() -> Path:
|
|
5
|
+
return Path.home()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def state_dir_for(home: Path) -> Path:
|
|
9
|
+
return home / ".codealmanac"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def global_state_dir() -> Path:
|
|
13
|
+
return state_dir_for(home_dir())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_registry_path() -> Path:
|
|
17
|
+
return global_state_dir() / "registry.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_config_path() -> Path:
|
|
21
|
+
return global_state_dir() / "config.toml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_path(path: Path) -> Path:
|
|
25
|
+
return path.expanduser().resolve(strict=False)
|
codealmanac/core/slug.py
ADDED
codealmanac/core/text.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from codealmanac.database.sqlite import (
|
|
2
|
+
SQLiteConnection,
|
|
3
|
+
SQLiteMigration,
|
|
4
|
+
SQLiteRow,
|
|
5
|
+
apply_migrations,
|
|
6
|
+
connect_sqlite,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"SQLiteConnection",
|
|
11
|
+
"SQLiteMigration",
|
|
12
|
+
"SQLiteRow",
|
|
13
|
+
"apply_migrations",
|
|
14
|
+
"connect_sqlite",
|
|
15
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import field_validator
|
|
5
|
+
|
|
6
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
7
|
+
from codealmanac.core.text import required_text
|
|
8
|
+
|
|
9
|
+
type SQLiteConnection = sqlite3.Connection
|
|
10
|
+
type SQLiteRow = sqlite3.Row
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLiteMigration(CodeAlmanacModel):
|
|
14
|
+
version: int
|
|
15
|
+
sql: str
|
|
16
|
+
|
|
17
|
+
@field_validator("version")
|
|
18
|
+
@classmethod
|
|
19
|
+
def positive_version(cls, value: int) -> int:
|
|
20
|
+
if value <= 0:
|
|
21
|
+
raise ValueError("SQLite migration version must be positive")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
@field_validator("sql")
|
|
25
|
+
@classmethod
|
|
26
|
+
def require_sql(cls, value: str) -> str:
|
|
27
|
+
return required_text(value, "SQLite migration SQL")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def connect_sqlite(path: Path) -> SQLiteConnection:
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
connection = sqlite3.connect(path)
|
|
33
|
+
connection.row_factory = sqlite3.Row
|
|
34
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
35
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
36
|
+
return connection
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply_migrations(
|
|
40
|
+
connection: SQLiteConnection,
|
|
41
|
+
migrations: tuple[SQLiteMigration, ...],
|
|
42
|
+
) -> None:
|
|
43
|
+
current = user_version(connection)
|
|
44
|
+
for migration in sorted(migrations, key=lambda item: item.version):
|
|
45
|
+
if migration.version <= current:
|
|
46
|
+
continue
|
|
47
|
+
connection.executescript(migration.sql)
|
|
48
|
+
connection.execute(f"PRAGMA user_version = {migration.version}")
|
|
49
|
+
current = migration.version
|
|
50
|
+
connection.commit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def user_version(connection: SQLiteConnection) -> int:
|
|
54
|
+
return int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concrete outside-world adapters."""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import plistlib
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codealmanac.core.errors import ExecutionFailed
|
|
8
|
+
from codealmanac.services.automation.models import (
|
|
9
|
+
EnvironmentVariable,
|
|
10
|
+
ScheduledJob,
|
|
11
|
+
ScheduledJobStatus,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LaunchdSchedulerAdapter:
|
|
16
|
+
def install(self, job: ScheduledJob) -> ScheduledJobStatus:
|
|
17
|
+
job.plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
job.stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
job.stderr_path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
with job.plist_path.open("wb") as handle:
|
|
21
|
+
plistlib.dump(launchd_plist(job), handle, sort_keys=False)
|
|
22
|
+
self._bootout(job)
|
|
23
|
+
self._bootstrap(job)
|
|
24
|
+
return self.status(job)
|
|
25
|
+
|
|
26
|
+
def uninstall(self, job: ScheduledJob) -> bool:
|
|
27
|
+
if not job.plist_path.exists():
|
|
28
|
+
return False
|
|
29
|
+
self._bootout(job)
|
|
30
|
+
job.plist_path.unlink(missing_ok=True)
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def status(self, job: ScheduledJob) -> ScheduledJobStatus:
|
|
34
|
+
if not job.plist_path.exists():
|
|
35
|
+
return ScheduledJobStatus(
|
|
36
|
+
task=job.task,
|
|
37
|
+
label=job.label,
|
|
38
|
+
plist_path=job.plist_path,
|
|
39
|
+
installed=False,
|
|
40
|
+
loaded=self._is_loaded(job),
|
|
41
|
+
)
|
|
42
|
+
data = read_plist(job.plist_path)
|
|
43
|
+
return ScheduledJobStatus(
|
|
44
|
+
task=job.task,
|
|
45
|
+
label=job.label,
|
|
46
|
+
plist_path=job.plist_path,
|
|
47
|
+
installed=True,
|
|
48
|
+
loaded=self._is_loaded(job),
|
|
49
|
+
interval=read_interval(data),
|
|
50
|
+
quiet=read_quiet(data),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _bootstrap(self, job: ScheduledJob) -> None:
|
|
54
|
+
result = self._run_launchctl(
|
|
55
|
+
("bootstrap", launchd_target(), str(job.plist_path))
|
|
56
|
+
)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
raise ExecutionFailed(
|
|
59
|
+
"launchctl bootstrap failed for "
|
|
60
|
+
f"{job.label}: {surface_process_error(result)}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _bootout(self, job: ScheduledJob) -> None:
|
|
64
|
+
self._run_launchctl(("bootout", launchd_target(), str(job.plist_path)))
|
|
65
|
+
|
|
66
|
+
def _is_loaded(self, job: ScheduledJob) -> bool:
|
|
67
|
+
result = self._run_launchctl(("print", f"{launchd_target()}/{job.label}"))
|
|
68
|
+
return result.returncode == 0
|
|
69
|
+
|
|
70
|
+
def _run_launchctl(self, args: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
|
|
71
|
+
try:
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
("launchctl", *args),
|
|
74
|
+
check=False,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
)
|
|
78
|
+
except OSError as error:
|
|
79
|
+
return subprocess.CompletedProcess(
|
|
80
|
+
args=("launchctl", *args),
|
|
81
|
+
returncode=1,
|
|
82
|
+
stdout="",
|
|
83
|
+
stderr=str(error),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def launchd_plist(job: ScheduledJob) -> dict[str, object]:
|
|
88
|
+
data: dict[str, object] = {
|
|
89
|
+
"Label": job.label,
|
|
90
|
+
"ProgramArguments": list(job.program_arguments),
|
|
91
|
+
"StartInterval": int(job.interval.total_seconds()),
|
|
92
|
+
"EnvironmentVariables": environment_dict(job.environment),
|
|
93
|
+
"RunAtLoad": True,
|
|
94
|
+
"StandardOutPath": str(job.stdout_path),
|
|
95
|
+
"StandardErrorPath": str(job.stderr_path),
|
|
96
|
+
}
|
|
97
|
+
if job.working_directory is not None:
|
|
98
|
+
data["WorkingDirectory"] = str(job.working_directory)
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_plist(path: Path) -> dict[str, object]:
|
|
103
|
+
with path.open("rb") as handle:
|
|
104
|
+
data = plistlib.load(handle)
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return {}
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_interval(data: dict[str, object]) -> timedelta | None:
|
|
111
|
+
value = data.get("StartInterval")
|
|
112
|
+
if not isinstance(value, int):
|
|
113
|
+
return None
|
|
114
|
+
return timedelta(seconds=value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_quiet(data: dict[str, object]) -> timedelta | None:
|
|
118
|
+
args = data.get("ProgramArguments")
|
|
119
|
+
if not isinstance(args, list):
|
|
120
|
+
return None
|
|
121
|
+
values = [item for item in args if isinstance(item, str)]
|
|
122
|
+
try:
|
|
123
|
+
index = values.index("--quiet")
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
126
|
+
if index + 1 >= len(values):
|
|
127
|
+
return None
|
|
128
|
+
return parse_compact_duration(values[index + 1])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_compact_duration(value: str) -> timedelta | None:
|
|
132
|
+
if value.endswith("h"):
|
|
133
|
+
parsed = parse_int(value[:-1])
|
|
134
|
+
return None if parsed is None else timedelta(hours=parsed)
|
|
135
|
+
if value.endswith("m"):
|
|
136
|
+
parsed = parse_int(value[:-1])
|
|
137
|
+
return None if parsed is None else timedelta(minutes=parsed)
|
|
138
|
+
if value.endswith("s"):
|
|
139
|
+
parsed = parse_int(value[:-1])
|
|
140
|
+
return None if parsed is None else timedelta(seconds=parsed)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_int(value: str) -> int | None:
|
|
145
|
+
try:
|
|
146
|
+
return int(value)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def environment_dict(values: tuple[EnvironmentVariable, ...]) -> dict[str, str]:
|
|
152
|
+
return {item.name: item.value for item in values}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def launchd_target() -> str:
|
|
156
|
+
return f"gui/{os.getuid()}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def surface_process_error(result: subprocess.CompletedProcess[str]) -> str:
|
|
160
|
+
text = result.stderr.strip() or result.stdout.strip()
|
|
161
|
+
if len(text) > 500:
|
|
162
|
+
return f"{text[:500]}..."
|
|
163
|
+
return text or f"exit {result.returncode}"
|