keepm 0.2.0__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.
keepm/markdown.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import re
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from keepm.models import MemoryDocument, MemoryLink
11
+
12
+ VALID_TYPES = {"user", "feedback", "project", "reference"}
13
+ LINK_RE = re.compile(
14
+ r"^\s*-\s+(?:(?P<relation>[A-Za-z0-9_-]+)\s+)?"
15
+ r"\[\[(?P<target>[^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]",
16
+ re.MULTILINE,
17
+ )
18
+ WIKILINK_RE = re.compile(
19
+ r"\[\[(?P<target>[^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]"
20
+ )
21
+
22
+
23
+ def slug_filename(name: str) -> str:
24
+ slug = name.casefold().strip().replace(" ", "-")
25
+ slug = re.sub(r"[^\w-]", "", slug, flags=re.UNICODE)
26
+ slug = re.sub(r"[-_]+", "-", slug).strip("-")
27
+ if not slug:
28
+ raise ValueError("memory name cannot produce a filename")
29
+ return f"{slug}.md"
30
+
31
+
32
+ def _date_string(value: object) -> str:
33
+ if isinstance(value, dt.datetime):
34
+ value = value.date()
35
+ if isinstance(value, dt.date):
36
+ return value.isoformat()
37
+ text = str(value)
38
+ try:
39
+ dt.date.fromisoformat(text)
40
+ except ValueError as error:
41
+ raise ValueError("date must be YYYY-MM-DD") from error
42
+ return text
43
+
44
+
45
+ def _has_protocol_label(body: str, label: str) -> bool:
46
+ pattern = rf"(?im)^\s*(?:\*\*)?{re.escape(label)}(?:\*\*)?\s*[::](?:\*\*)?"
47
+ return re.search(pattern, body) is not None
48
+
49
+
50
+ def parse_memory(path: Path, text: str) -> MemoryDocument:
51
+ normalized = text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
52
+ if not normalized.startswith("---\n"):
53
+ raise ValueError(f"{path}: missing YAML frontmatter")
54
+ closing = normalized.find("\n---\n", 4)
55
+ if closing < 0:
56
+ raise ValueError(f"{path}: unclosed YAML frontmatter")
57
+
58
+ try:
59
+ loaded = yaml.safe_load(normalized[4:closing]) or {}
60
+ except yaml.YAMLError as error:
61
+ raise ValueError(f"{path}: invalid YAML frontmatter: {error}") from error
62
+ if not isinstance(loaded, Mapping):
63
+ raise ValueError(f"{path}: YAML frontmatter must be a mapping")
64
+ metadata = dict(loaded)
65
+ body = normalized[closing + 5 :].lstrip("\n")
66
+
67
+ required = ("id", "name", "description", "type", "scope", "created", "updated")
68
+ missing = [key for key in required if metadata.get(key) in (None, "")]
69
+ if missing:
70
+ raise ValueError(f"{path}: missing required fields: {', '.join(missing)}")
71
+
72
+ memory_type = str(metadata["type"])
73
+ if memory_type not in VALID_TYPES:
74
+ raise ValueError(f"{path}: invalid type: {memory_type}")
75
+ try:
76
+ created = _date_string(metadata["created"])
77
+ updated = _date_string(metadata["updated"])
78
+ except ValueError as error:
79
+ raise ValueError(f"{path}: {error}") from error
80
+
81
+ if memory_type in {"feedback", "project"}:
82
+ if not _has_protocol_label(body, "Why"):
83
+ raise ValueError(f"{path}: type {memory_type} requires Why:")
84
+ if not _has_protocol_label(body, "How to apply"):
85
+ raise ValueError(f"{path}: type {memory_type} requires How to apply:")
86
+
87
+ raw_tags = metadata.get("tags", []) or []
88
+ if not isinstance(raw_tags, list):
89
+ raise ValueError(f"{path}: tags must be a YAML list")
90
+ tags = tuple(str(tag).lstrip("#") for tag in raw_tags)
91
+
92
+ raw_pinned = metadata.get("pinned", False)
93
+ if not isinstance(raw_pinned, bool):
94
+ raise ValueError(f"{path}: pinned must be true or false")
95
+
96
+ links: list[MemoryLink] = []
97
+ relation_spans: list[tuple[int, int]] = []
98
+ for match in LINK_RE.finditer(body):
99
+ links.append(
100
+ MemoryLink(
101
+ target=match.group("target").strip(),
102
+ relation_type=match.group("relation") or "links_to",
103
+ )
104
+ )
105
+ relation_spans.append(match.span())
106
+ for match in WIKILINK_RE.finditer(body):
107
+ if any(start <= match.start() < end for start, end in relation_spans):
108
+ continue
109
+ links.append(MemoryLink(target=match.group("target").strip()))
110
+ deduplicated_links = tuple(
111
+ {
112
+ (link.relation_type, link.target.casefold()): link
113
+ for link in links
114
+ }.values()
115
+ )
116
+ return MemoryDocument(
117
+ id=str(metadata["id"]),
118
+ name=str(metadata["name"]),
119
+ description=str(metadata["description"]),
120
+ type=memory_type,
121
+ scope=str(metadata["scope"]),
122
+ created=created,
123
+ updated=updated,
124
+ body=body,
125
+ path=path,
126
+ tags=tags,
127
+ pinned=raw_pinned,
128
+ links=deduplicated_links,
129
+ )
130
+
131
+
132
+ def render_memory(document: MemoryDocument) -> str:
133
+ metadata = {
134
+ "id": document.id,
135
+ "name": document.name,
136
+ "description": document.description,
137
+ "type": document.type,
138
+ "scope": document.scope,
139
+ "tags": list(document.tags),
140
+ "pinned": document.pinned,
141
+ "created": document.created,
142
+ "updated": document.updated,
143
+ }
144
+ frontmatter = yaml.safe_dump(
145
+ metadata,
146
+ sort_keys=False,
147
+ allow_unicode=True,
148
+ default_flow_style=False,
149
+ ).strip()
150
+ body = document.body.rstrip()
151
+ separator = "\n\n" if body else "\n"
152
+ return f"---\n{frontmatter}\n---{separator}{body}\n"
keepm/models.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class MemoryLink:
9
+ target: str
10
+ relation_type: str = "links_to"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class MemoryDocument:
15
+ id: str
16
+ name: str
17
+ description: str
18
+ type: str
19
+ scope: str
20
+ created: str
21
+ updated: str
22
+ body: str
23
+ path: Path
24
+ tags: tuple[str, ...] = ()
25
+ pinned: bool = False
26
+ links: tuple[MemoryLink, ...] = ()
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SearchHit:
31
+ id: str
32
+ name: str
33
+ description: str
34
+ scope: str
35
+ path: Path
36
+ snippet: str
37
+ rank: float
38
+ pinned: bool = False
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class SyncReport:
43
+ new: int = 0
44
+ modified: int = 0
45
+ deleted: int = 0
46
+ moved: int = 0
47
+ unchanged: int = 0
48
+ errors: tuple[str, ...] = field(default_factory=tuple)
keepm/projects.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ import tomllib
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from urllib.parse import unquote, urlparse
9
+
10
+
11
+ def normalize_project_name(name: str) -> str:
12
+ normalized = re.sub(r"[^a-z0-9_-]", "", name.lower().replace(" ", "-"))
13
+ normalized = re.sub(r"[-_]+", "-", normalized).strip("-")
14
+ if not normalized:
15
+ raise ValueError("project name is empty after normalization")
16
+ return normalized
17
+
18
+
19
+ def resolve_workspace(
20
+ client_roots: Sequence[str] | None,
21
+ workspace_path: str | None,
22
+ cwd: Path,
23
+ ) -> Path:
24
+ if client_roots:
25
+ first = client_roots[0]
26
+ parsed = urlparse(first)
27
+ value = unquote(parsed.path) if parsed.scheme == "file" else first
28
+ return Path(value).expanduser().resolve()
29
+ if workspace_path:
30
+ return Path(workspace_path).expanduser().resolve()
31
+ return cwd.expanduser().resolve()
32
+
33
+
34
+ def resolve_project_name(workspace: Path) -> str:
35
+ override = workspace / ".keepm.toml"
36
+ if override.is_file():
37
+ data = tomllib.loads(override.read_text(encoding="utf-8"))
38
+ if data.get("project"):
39
+ return normalize_project_name(str(data["project"]))
40
+
41
+ try:
42
+ result = subprocess.run(
43
+ ["git", "remote", "get-url", "origin"],
44
+ cwd=workspace,
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=2,
48
+ check=False,
49
+ )
50
+ except (FileNotFoundError, subprocess.TimeoutExpired):
51
+ result = None
52
+
53
+ if result and result.returncode == 0 and result.stdout.strip():
54
+ remote_name = result.stdout.strip().rstrip("/").rsplit("/", 1)[-1]
55
+ remote_name = remote_name.rsplit(":", 1)[-1].removesuffix(".git")
56
+ return normalize_project_name(remote_name)
57
+ return normalize_project_name(workspace.name)