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,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def looks_like_dir(raw: str) -> bool:
|
|
5
|
+
return raw.strip().replace("\\", "/").endswith("/")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_reference_path(raw: str, is_dir: bool) -> str:
|
|
9
|
+
return normalize_reference_shape(raw, is_dir).casefold()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_reference_path_preserving_case(raw: str, is_dir: bool) -> str:
|
|
13
|
+
return normalize_reference_shape(raw, is_dir)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_reference_shape(raw: str, is_dir: bool) -> str:
|
|
17
|
+
text = raw.strip().replace("\\", "/")
|
|
18
|
+
while text.startswith("./"):
|
|
19
|
+
text = text[2:]
|
|
20
|
+
text = re.sub(r"/+", "/", text)
|
|
21
|
+
text = text.lstrip("/")
|
|
22
|
+
if ".." in text.split("/"):
|
|
23
|
+
return ""
|
|
24
|
+
text = text.rstrip("/")
|
|
25
|
+
if is_dir and text:
|
|
26
|
+
return f"{text}/"
|
|
27
|
+
return text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parent_folder_prefixes(file_path: str) -> list[str]:
|
|
31
|
+
prefixes: list[str] = []
|
|
32
|
+
cursor = 0
|
|
33
|
+
while True:
|
|
34
|
+
index = file_path.find("/", cursor)
|
|
35
|
+
if index == -1:
|
|
36
|
+
return prefixes
|
|
37
|
+
prefixes.append(file_path[: index + 1])
|
|
38
|
+
cursor = index + 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def escape_glob_meta(input_path: str) -> str:
|
|
42
|
+
return re.sub(r"([*?\[])", r"[\1]", input_path)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from codealmanac.manual import ManualLibrary
|
|
4
|
+
from codealmanac.services.wiki.templates import (
|
|
5
|
+
gitignore_runtime_block,
|
|
6
|
+
starter_page,
|
|
7
|
+
starter_readme,
|
|
8
|
+
starter_topics_yaml,
|
|
9
|
+
)
|
|
10
|
+
from codealmanac.services.workspaces.service import WorkspacesService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WikiService:
|
|
14
|
+
def __init__(self, workspaces: WorkspacesService, manual: ManualLibrary):
|
|
15
|
+
self.workspaces = workspaces
|
|
16
|
+
self.manual = manual
|
|
17
|
+
|
|
18
|
+
def initialize(self, workspace_id: str) -> None:
|
|
19
|
+
workspace = self.workspaces.get(workspace_id)
|
|
20
|
+
almanac_path = workspace.almanac_path
|
|
21
|
+
pages_path = almanac_path / "pages"
|
|
22
|
+
manual_path = almanac_path / "manual"
|
|
23
|
+
almanac_path.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
pages_path.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
manual_path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
write_if_missing(almanac_path / "README.md", starter_readme())
|
|
27
|
+
write_if_missing(almanac_path / "topics.yaml", starter_topics_yaml())
|
|
28
|
+
write_if_missing(pages_path / "getting-started.md", starter_page())
|
|
29
|
+
self.manual.install_missing(manual_path)
|
|
30
|
+
ensure_root_gitignore(workspace.root_path, workspace.almanac_root)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_if_missing(path: Path, body: str) -> None:
|
|
34
|
+
if path.exists():
|
|
35
|
+
return
|
|
36
|
+
path.write_text(body, encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_root_gitignore(root_path: Path, almanac_root: Path) -> None:
|
|
40
|
+
path = root_path / ".gitignore"
|
|
41
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
42
|
+
lines = {line.strip() for line in existing.splitlines()}
|
|
43
|
+
missing = [
|
|
44
|
+
line
|
|
45
|
+
for line in gitignore_runtime_block(almanac_root)
|
|
46
|
+
if line not in lines
|
|
47
|
+
]
|
|
48
|
+
if len(missing) == 0:
|
|
49
|
+
return
|
|
50
|
+
header = "# codealmanac"
|
|
51
|
+
block_lines = []
|
|
52
|
+
if header not in lines:
|
|
53
|
+
block_lines.append(header)
|
|
54
|
+
block_lines.extend(missing)
|
|
55
|
+
block = "\n".join(block_lines) + "\n"
|
|
56
|
+
separator = "" if existing == "" else "\n" if existing.endswith("\n") else "\n\n"
|
|
57
|
+
path.write_text(f"{existing}{separator}{block}", encoding="utf-8")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def starter_readme() -> str:
|
|
5
|
+
return """# CodeAlmanac Wiki
|
|
6
|
+
|
|
7
|
+
This is the living wiki for this repository. It records the durable knowledge
|
|
8
|
+
the code cannot say: decisions, flows, invariants, incidents, gotchas, and
|
|
9
|
+
project context that future agents should not rediscover from scratch.
|
|
10
|
+
|
|
11
|
+
## Notability Bar
|
|
12
|
+
|
|
13
|
+
Write a page when it preserves non-obvious knowledge that will help a future
|
|
14
|
+
agent work safely in this codebase.
|
|
15
|
+
|
|
16
|
+
Good pages explain:
|
|
17
|
+
|
|
18
|
+
- a decision that took research or trial-and-error
|
|
19
|
+
- a cross-file flow
|
|
20
|
+
- an invariant or gotcha not visible from one file
|
|
21
|
+
- an external dependency as this repo uses it
|
|
22
|
+
- a product or operational constraint that shapes future work
|
|
23
|
+
|
|
24
|
+
Do not write pages that restate nearby code.
|
|
25
|
+
|
|
26
|
+
## Topic Taxonomy
|
|
27
|
+
|
|
28
|
+
Topics live in `topics.yaml`. Pages live in `pages/`.
|
|
29
|
+
|
|
30
|
+
## Manual
|
|
31
|
+
|
|
32
|
+
Read `manual/README.md` before creating, reorganizing, or substantially
|
|
33
|
+
rewriting pages. The manual is bundled with CodeAlmanac and copied here by
|
|
34
|
+
`codealmanac init` and `codealmanac build`.
|
|
35
|
+
|
|
36
|
+
## Links
|
|
37
|
+
|
|
38
|
+
Use `[[page-slug]]` for page links and `[[src/path.py]]` for file references.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def starter_topics_yaml() -> str:
|
|
43
|
+
return """topics:
|
|
44
|
+
- slug: concepts
|
|
45
|
+
title: Concepts
|
|
46
|
+
description: Core vocabulary and mental models for this codebase
|
|
47
|
+
parents: []
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def starter_page() -> str:
|
|
52
|
+
return """---
|
|
53
|
+
title: Getting Started
|
|
54
|
+
topics: [concepts]
|
|
55
|
+
sources: []
|
|
56
|
+
status: active
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Getting Started
|
|
60
|
+
|
|
61
|
+
This starter page marks the wiki as initialized. Replace it with the first
|
|
62
|
+
durable reading path for this repository.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def gitignore_runtime_block(almanac_root: Path) -> list[str]:
|
|
67
|
+
root = almanac_root.as_posix().rstrip("/")
|
|
68
|
+
return [
|
|
69
|
+
f"{root}/index.db",
|
|
70
|
+
f"{root}/index.db-wal",
|
|
71
|
+
f"{root}/index.db-shm",
|
|
72
|
+
f"{root}/jobs/",
|
|
73
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import yaml as pyyaml
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
8
|
+
from ruamel.yaml import YAML
|
|
9
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
10
|
+
from yaml import YAMLError
|
|
11
|
+
|
|
12
|
+
from codealmanac.core.errors import ValidationFailed
|
|
13
|
+
from codealmanac.core.slug import to_kebab_case
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TopicDefinition(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
18
|
+
|
|
19
|
+
slug: str
|
|
20
|
+
title: str | None = None
|
|
21
|
+
description: str | None = None
|
|
22
|
+
parents: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
@field_validator("slug", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def canonical_slug(cls, value: Any) -> str:
|
|
27
|
+
return to_kebab_case(str(value))
|
|
28
|
+
|
|
29
|
+
@field_validator("title", "description", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def optional_text(cls, value: Any) -> str | None:
|
|
32
|
+
if isinstance(value, str) and value.strip():
|
|
33
|
+
return value.strip()
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
@field_validator("parents", mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def parent_slugs(cls, value: Any) -> tuple[str, ...]:
|
|
39
|
+
if not isinstance(value, list | tuple):
|
|
40
|
+
return ()
|
|
41
|
+
parents: list[str] = []
|
|
42
|
+
for item in value:
|
|
43
|
+
slug = to_kebab_case(str(item))
|
|
44
|
+
if slug:
|
|
45
|
+
parents.append(slug)
|
|
46
|
+
return tuple(dict.fromkeys(parents))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TopicsYaml(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
51
|
+
|
|
52
|
+
topics: tuple[TopicDefinition, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_topics_yaml(almanac_path: Path) -> tuple[TopicDefinition, ...]:
|
|
56
|
+
path = almanac_path / "topics.yaml"
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
return ()
|
|
59
|
+
try:
|
|
60
|
+
parsed = pyyaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
61
|
+
except (OSError, YAMLError):
|
|
62
|
+
return ()
|
|
63
|
+
if not isinstance(parsed, dict):
|
|
64
|
+
return ()
|
|
65
|
+
try:
|
|
66
|
+
model = TopicsYaml.model_validate(parsed)
|
|
67
|
+
except ValidationError:
|
|
68
|
+
return ()
|
|
69
|
+
return tuple(topic for topic in model.topics if topic.slug)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TopicsYamlFile:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
path: Path,
|
|
76
|
+
data: CommentedMap,
|
|
77
|
+
topics: CommentedSeq,
|
|
78
|
+
line_ending: str,
|
|
79
|
+
):
|
|
80
|
+
self.path = path
|
|
81
|
+
self.data = data
|
|
82
|
+
self.topics = topics
|
|
83
|
+
self.line_ending = line_ending
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def definitions(self) -> tuple[TopicDefinition, ...]:
|
|
87
|
+
try:
|
|
88
|
+
model = TopicsYaml.model_validate(self.data)
|
|
89
|
+
except ValidationError as error:
|
|
90
|
+
raise ValidationFailed(f"invalid topics.yaml: {error}") from error
|
|
91
|
+
return tuple(topic for topic in model.topics if topic.slug)
|
|
92
|
+
|
|
93
|
+
def has_entry(self, slug: str) -> bool:
|
|
94
|
+
return self.entry_for(slug) is not None
|
|
95
|
+
|
|
96
|
+
def ensure_topic(self, slug: str, title: str | None = None) -> None:
|
|
97
|
+
if self.has_entry(slug):
|
|
98
|
+
return
|
|
99
|
+
entry = CommentedMap()
|
|
100
|
+
entry["slug"] = slug
|
|
101
|
+
entry["title"] = title or title_for_slug(slug)
|
|
102
|
+
entry["parents"] = CommentedSeq()
|
|
103
|
+
self.topics.append(entry)
|
|
104
|
+
|
|
105
|
+
def set_description(self, slug: str, description: str | None) -> None:
|
|
106
|
+
entry = self.required_entry(slug)
|
|
107
|
+
if description:
|
|
108
|
+
entry["description"] = description
|
|
109
|
+
return
|
|
110
|
+
if "description" in entry:
|
|
111
|
+
del entry["description"]
|
|
112
|
+
|
|
113
|
+
def maybe_update_default_title(self, slug: str, title: str) -> None:
|
|
114
|
+
entry = self.required_entry(slug)
|
|
115
|
+
default_title = title_for_slug(slug)
|
|
116
|
+
current_title = entry.get("title")
|
|
117
|
+
if current_title in (None, default_title) and title != default_title:
|
|
118
|
+
entry["title"] = title
|
|
119
|
+
|
|
120
|
+
def add_parent(self, child: str, parent: str) -> bool:
|
|
121
|
+
entry = self.required_entry(child)
|
|
122
|
+
parents = parent_sequence(entry)
|
|
123
|
+
if parent in {str(item) for item in parents}:
|
|
124
|
+
return False
|
|
125
|
+
parents.append(parent)
|
|
126
|
+
entry["parents"] = parents
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def remove_parent(self, child: str, parent: str) -> bool:
|
|
130
|
+
entry = self.entry_for(child)
|
|
131
|
+
if entry is None:
|
|
132
|
+
return False
|
|
133
|
+
parents = parent_sequence(entry)
|
|
134
|
+
removed = False
|
|
135
|
+
for index in range(len(parents) - 1, -1, -1):
|
|
136
|
+
if str(parents[index]) == parent:
|
|
137
|
+
del parents[index]
|
|
138
|
+
removed = True
|
|
139
|
+
entry["parents"] = parents
|
|
140
|
+
return removed
|
|
141
|
+
|
|
142
|
+
def rename_topic(self, old_slug: str, new_slug: str) -> bool:
|
|
143
|
+
changed = False
|
|
144
|
+
entry = self.entry_for(old_slug)
|
|
145
|
+
if entry is not None:
|
|
146
|
+
entry["slug"] = new_slug
|
|
147
|
+
if entry.get("title") == title_for_slug(old_slug):
|
|
148
|
+
entry["title"] = title_for_slug(new_slug)
|
|
149
|
+
changed = True
|
|
150
|
+
for item in self.topics:
|
|
151
|
+
if not isinstance(item, CommentedMap):
|
|
152
|
+
continue
|
|
153
|
+
parents = parent_sequence(item)
|
|
154
|
+
next_parents = replace_parent_slug(parents, old_slug, new_slug)
|
|
155
|
+
if next_parents != tuple(str(parent) for parent in parents):
|
|
156
|
+
item["parents"] = CommentedSeq(next_parents)
|
|
157
|
+
changed = True
|
|
158
|
+
return changed
|
|
159
|
+
|
|
160
|
+
def delete_topic(self, slug: str) -> bool:
|
|
161
|
+
changed = False
|
|
162
|
+
for index in range(len(self.topics) - 1, -1, -1):
|
|
163
|
+
item = self.topics[index]
|
|
164
|
+
if (
|
|
165
|
+
isinstance(item, CommentedMap)
|
|
166
|
+
and to_kebab_case(str(item.get("slug"))) == slug
|
|
167
|
+
):
|
|
168
|
+
del self.topics[index]
|
|
169
|
+
changed = True
|
|
170
|
+
for item in self.topics:
|
|
171
|
+
if not isinstance(item, CommentedMap):
|
|
172
|
+
continue
|
|
173
|
+
parents = parent_sequence(item)
|
|
174
|
+
next_parents = tuple(
|
|
175
|
+
str(parent) for parent in parents if str(parent) != slug
|
|
176
|
+
)
|
|
177
|
+
if next_parents != tuple(str(parent) for parent in parents):
|
|
178
|
+
item["parents"] = CommentedSeq(next_parents)
|
|
179
|
+
changed = True
|
|
180
|
+
return changed
|
|
181
|
+
|
|
182
|
+
def write(self) -> None:
|
|
183
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
yaml = YAML(typ="rt")
|
|
185
|
+
yaml.preserve_quotes = True
|
|
186
|
+
output = StringIO()
|
|
187
|
+
yaml.dump(self.data, output)
|
|
188
|
+
text = output.getvalue()
|
|
189
|
+
if self.line_ending != "\n":
|
|
190
|
+
text = text.replace("\n", self.line_ending)
|
|
191
|
+
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
|
|
192
|
+
temporary.write_text(text, encoding="utf-8")
|
|
193
|
+
temporary.replace(self.path)
|
|
194
|
+
|
|
195
|
+
def required_entry(self, slug: str) -> CommentedMap:
|
|
196
|
+
entry = self.entry_for(slug)
|
|
197
|
+
if entry is None:
|
|
198
|
+
raise ValidationFailed(f'topic "{slug}" is missing from topics.yaml')
|
|
199
|
+
return entry
|
|
200
|
+
|
|
201
|
+
def entry_for(self, slug: str) -> CommentedMap | None:
|
|
202
|
+
for item in self.topics:
|
|
203
|
+
if (
|
|
204
|
+
isinstance(item, CommentedMap)
|
|
205
|
+
and to_kebab_case(str(item.get("slug"))) == slug
|
|
206
|
+
):
|
|
207
|
+
return item
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def load_topics_file(almanac_path: Path) -> TopicsYamlFile:
|
|
212
|
+
path = almanac_path / "topics.yaml"
|
|
213
|
+
raw = read_topics_text(path)
|
|
214
|
+
line_ending = "\r\n" if "\r\n" in raw else "\n"
|
|
215
|
+
yaml = YAML(typ="rt")
|
|
216
|
+
yaml.preserve_quotes = True
|
|
217
|
+
try:
|
|
218
|
+
parsed = yaml.load(raw) if raw.strip() else CommentedMap()
|
|
219
|
+
except Exception as error:
|
|
220
|
+
raise ValidationFailed(f"invalid topics.yaml: {path}") from error
|
|
221
|
+
if parsed is None:
|
|
222
|
+
parsed = CommentedMap()
|
|
223
|
+
if not isinstance(parsed, CommentedMap):
|
|
224
|
+
raise ValidationFailed(f"topics.yaml must be a YAML mapping: {path}")
|
|
225
|
+
topics = parsed.get("topics")
|
|
226
|
+
if topics is None:
|
|
227
|
+
topics = CommentedSeq()
|
|
228
|
+
parsed["topics"] = topics
|
|
229
|
+
if not isinstance(topics, CommentedSeq):
|
|
230
|
+
raise ValidationFailed(f"topics.yaml topics must be a list: {path}")
|
|
231
|
+
file = TopicsYamlFile(path, parsed, topics, line_ending)
|
|
232
|
+
_ = file.definitions
|
|
233
|
+
return file
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def read_topics_text(path: Path) -> str:
|
|
237
|
+
if not path.is_file():
|
|
238
|
+
return ""
|
|
239
|
+
return path.read_text(encoding="utf-8")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parent_sequence(entry: CommentedMap) -> CommentedSeq:
|
|
243
|
+
existing = entry.get("parents")
|
|
244
|
+
if isinstance(existing, CommentedSeq):
|
|
245
|
+
return existing
|
|
246
|
+
sequence = CommentedSeq()
|
|
247
|
+
if isinstance(existing, list):
|
|
248
|
+
sequence.extend(to_kebab_case(str(item)) for item in existing)
|
|
249
|
+
return sequence
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def replace_parent_slug(
|
|
253
|
+
parents: CommentedSeq,
|
|
254
|
+
old_slug: str,
|
|
255
|
+
new_slug: str,
|
|
256
|
+
) -> tuple[str, ...]:
|
|
257
|
+
replaced: list[str] = []
|
|
258
|
+
for parent in parents:
|
|
259
|
+
next_parent = new_slug if str(parent) == old_slug else str(parent)
|
|
260
|
+
if next_parent not in replaced:
|
|
261
|
+
replaced.append(next_parent)
|
|
262
|
+
return tuple(replaced)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def title_for_slug(slug: str) -> str:
|
|
266
|
+
return " ".join(part.capitalize() for part in slug.split("-") if part)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from codealmanac.core.slug import to_kebab_case
|
|
4
|
+
from codealmanac.services.wiki.models import (
|
|
5
|
+
CrossWikiLink,
|
|
6
|
+
FileLink,
|
|
7
|
+
FileReference,
|
|
8
|
+
FolderLink,
|
|
9
|
+
PageLink,
|
|
10
|
+
Wikilink,
|
|
11
|
+
WikilinkKind,
|
|
12
|
+
)
|
|
13
|
+
from codealmanac.services.wiki.paths import (
|
|
14
|
+
looks_like_dir,
|
|
15
|
+
normalize_reference_path,
|
|
16
|
+
normalize_reference_path_preserving_case,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def classify_wikilink(raw: str) -> Wikilink | None:
|
|
21
|
+
target = raw.split("|", maxsplit=1)[0].strip()
|
|
22
|
+
if not target:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
first_colon = target.find(":")
|
|
26
|
+
first_slash = target.find("/")
|
|
27
|
+
|
|
28
|
+
if first_colon != -1 and (first_slash == -1 or first_colon < first_slash):
|
|
29
|
+
wiki = target[:first_colon].strip()
|
|
30
|
+
slug = target[first_colon + 1 :].strip()
|
|
31
|
+
if not wiki or not slug:
|
|
32
|
+
return None
|
|
33
|
+
return CrossWikiLink(kind=WikilinkKind.CROSS_WIKI, wiki=wiki, target=slug)
|
|
34
|
+
|
|
35
|
+
if first_slash != -1:
|
|
36
|
+
is_dir = looks_like_dir(target)
|
|
37
|
+
normalized = normalize_reference_path(target, is_dir)
|
|
38
|
+
original = normalize_reference_path_preserving_case(target, is_dir)
|
|
39
|
+
if not normalized:
|
|
40
|
+
return None
|
|
41
|
+
ref = FileReference(path=normalized, original_path=original, is_dir=is_dir)
|
|
42
|
+
if is_dir:
|
|
43
|
+
return FolderLink(kind=WikilinkKind.FOLDER, ref=ref)
|
|
44
|
+
return FileLink(kind=WikilinkKind.FILE, ref=ref)
|
|
45
|
+
|
|
46
|
+
slug = to_kebab_case(target)
|
|
47
|
+
if not slug:
|
|
48
|
+
return None
|
|
49
|
+
return PageLink(kind=WikilinkKind.PAGE, target=slug)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_wikilinks(body: str) -> tuple[Wikilink, ...]:
|
|
53
|
+
links: list[Wikilink] = []
|
|
54
|
+
for match in re.finditer(r"\[\[([^\]\n]+)\]\]", body):
|
|
55
|
+
link = classify_wikilink(match.group(1))
|
|
56
|
+
if link is not None:
|
|
57
|
+
links.append(link)
|
|
58
|
+
return tuple(links)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
8
|
+
from codealmanac.core.text import required_text
|
|
9
|
+
from codealmanac.services.workspaces.roots import (
|
|
10
|
+
DEFAULT_ALMANAC_ROOT,
|
|
11
|
+
validate_almanac_root_field,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Workspace(CodeAlmanacModel):
|
|
16
|
+
workspace_id: str
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
root_path: Path
|
|
20
|
+
almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
|
|
21
|
+
almanac_path: Path
|
|
22
|
+
registered_at: datetime
|
|
23
|
+
|
|
24
|
+
@field_validator("workspace_id")
|
|
25
|
+
@classmethod
|
|
26
|
+
def require_workspace_id(cls, value: str) -> str:
|
|
27
|
+
return required_text(value, "workspace_id")
|
|
28
|
+
|
|
29
|
+
@field_validator("name")
|
|
30
|
+
@classmethod
|
|
31
|
+
def require_name(cls, value: str) -> str:
|
|
32
|
+
return required_text(value, "workspace name")
|
|
33
|
+
|
|
34
|
+
@field_validator("almanac_root")
|
|
35
|
+
@classmethod
|
|
36
|
+
def validate_almanac_root(cls, value: Path) -> Path:
|
|
37
|
+
return validate_almanac_root_field(value)
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def validate_almanac_path_matches_root(self) -> "Workspace":
|
|
41
|
+
expected = self.root_path / self.almanac_root
|
|
42
|
+
if self.almanac_path != expected:
|
|
43
|
+
raise ValueError("workspace almanac_path must match root_path/almanac_root")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WorkspaceRegistryEntry(CodeAlmanacModel):
|
|
48
|
+
name: str
|
|
49
|
+
description: str = ""
|
|
50
|
+
path: Path
|
|
51
|
+
almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
|
|
52
|
+
registered_at: datetime
|
|
53
|
+
workspace_id: str
|
|
54
|
+
|
|
55
|
+
@field_validator("name")
|
|
56
|
+
@classmethod
|
|
57
|
+
def require_name(cls, value: str) -> str:
|
|
58
|
+
return required_text(value, "workspace name")
|
|
59
|
+
|
|
60
|
+
@field_validator("almanac_root")
|
|
61
|
+
@classmethod
|
|
62
|
+
def validate_almanac_root(cls, value: Path) -> Path:
|
|
63
|
+
return validate_almanac_root_field(value)
|
|
64
|
+
|
|
65
|
+
def to_workspace(self) -> Workspace:
|
|
66
|
+
return Workspace(
|
|
67
|
+
workspace_id=self.workspace_id,
|
|
68
|
+
name=self.name,
|
|
69
|
+
description=self.description,
|
|
70
|
+
root_path=self.path,
|
|
71
|
+
almanac_root=self.almanac_root,
|
|
72
|
+
almanac_path=self.path / self.almanac_root,
|
|
73
|
+
registered_at=self.registered_at,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WorkspaceRegistryStatus(StrEnum):
|
|
78
|
+
AVAILABLE = "available"
|
|
79
|
+
MISSING_REPO = "missing_repo"
|
|
80
|
+
MISSING_ALMANAC = "missing_almanac"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WorkspaceListItem(CodeAlmanacModel):
|
|
84
|
+
workspace: Workspace
|
|
85
|
+
status: WorkspaceRegistryStatus
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WorkspaceListResult(CodeAlmanacModel):
|
|
89
|
+
items: tuple[WorkspaceListItem, ...]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DropWorkspaceResult(CodeAlmanacModel):
|
|
93
|
+
dropped: tuple[Workspace, ...]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class WorkspacePathState(StrEnum):
|
|
97
|
+
ADDED = "added"
|
|
98
|
+
COPIED = "copied"
|
|
99
|
+
DELETED = "deleted"
|
|
100
|
+
MODIFIED = "modified"
|
|
101
|
+
RENAMED = "renamed"
|
|
102
|
+
TYPE_CHANGED = "type_changed"
|
|
103
|
+
UNMERGED = "unmerged"
|
|
104
|
+
UNTRACKED = "untracked"
|
|
105
|
+
UNKNOWN = "unknown"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class WorkspacePathChange(CodeAlmanacModel):
|
|
109
|
+
path: Path
|
|
110
|
+
state: WorkspacePathState
|
|
111
|
+
status: str
|
|
112
|
+
fingerprint: str | None = None
|
|
113
|
+
|
|
114
|
+
@field_validator("status")
|
|
115
|
+
@classmethod
|
|
116
|
+
def require_status(cls, value: str) -> str:
|
|
117
|
+
return required_text(value, "workspace path status")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class WorkspaceChangeSnapshot(CodeAlmanacModel):
|
|
121
|
+
root_path: Path
|
|
122
|
+
available: bool
|
|
123
|
+
changes: tuple[WorkspacePathChange, ...] = ()
|
|
124
|
+
unavailable_reason: str | None = None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
from codealmanac.services.workspaces.models import WorkspaceChangeSnapshot
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WorkspaceChangeProbe(Protocol):
|
|
8
|
+
def snapshot(self, root_path: Path) -> WorkspaceChangeSnapshot:
|
|
9
|
+
"""Return the current observable local change state for a workspace."""
|