s-skillkit 0.1.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.
skillkit/filter.py ADDED
@@ -0,0 +1,258 @@
1
+ """Фильтрация файлов skill при install: .skillignore + manifest files allowlist.
2
+
3
+ Логика после git clone:
4
+ 1. Если SKILL.md frontmatter содержит `files:` — это allowlist.
5
+ Оставляем ТОЛЬКО matched paths. `.skillignore` игнорируется.
6
+ 2. Иначе если `.skillignore` есть — gitignore-стиль patterns. Удаляем matched.
7
+ 3. Иначе ничего не делаем.
8
+
9
+ Всегда сохраняются: `.git/`, `SKILL.md`, `.skillignore`, `_skill_meta.json`.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import shutil
16
+ import stat
17
+ from pathlib import Path
18
+
19
+ import pathspec
20
+
21
+ SKILLIGNORE_FILENAME = ".skillignore"
22
+
23
+ # Корневые пути которые НИКОГДА не удаляются (даже если matched ignore-паттерн
24
+ # или НЕ matched allowlist). .git/ — для воспроизводимости; SKILL.md — основа
25
+ # skill; .skillignore сам себя; _skill_meta.json — установщик пишет позже.
26
+ _PRESERVED_ROOT_NAMES: frozenset[str] = frozenset(
27
+ {".git", "SKILL.md", ".skillignore", "_skill_meta.json"}
28
+ )
29
+
30
+
31
+ def _on_rm_error(func, path, exc_info):
32
+ """Чинит read-only-файлы (Windows: .git/objects/...) перед удалением."""
33
+ try:
34
+ os.chmod(path, stat.S_IWRITE)
35
+ func(path)
36
+ except Exception:
37
+ pass
38
+
39
+
40
+ def parse_skill_md_files_allowlist(skill_md_path: Path) -> list[str] | None:
41
+ """Парсит SKILL.md frontmatter; возвращает list patterns если `files:` поле есть.
42
+
43
+ Возвращает None если поле отсутствует, SKILL.md нет frontmatter, или файл не существует.
44
+ """
45
+ if not skill_md_path.exists():
46
+ return None
47
+
48
+ content = skill_md_path.read_text(encoding="utf-8")
49
+ if not content.startswith("---"):
50
+ return None
51
+
52
+ # Найти end of frontmatter (закрывающий `---` после первого).
53
+ end_match = re.search(r"\n---\s*\n", content[3:])
54
+ if not end_match:
55
+ return None
56
+
57
+ frontmatter = content[3 : 3 + end_match.start()]
58
+ files = _extract_yaml_list(frontmatter, "files")
59
+ return files if files else None
60
+
61
+
62
+ def _extract_yaml_list(frontmatter: str, key: str) -> list[str] | None:
63
+ """Простой парсер YAML списка из строки. Без pyyaml dep.
64
+
65
+ Поддерживает:
66
+ files:
67
+ - "SKILL.md"
68
+ - src/**
69
+ - 'tests/*.py'
70
+
71
+ Возвращает list или None если key не найден / список пуст.
72
+ """
73
+ pattern = re.compile(
74
+ rf"^{re.escape(key)}:\s*\n((?:[ \t]+-[ \t]+.+\n?)+)",
75
+ re.MULTILINE,
76
+ )
77
+ match = pattern.search(frontmatter)
78
+ if not match:
79
+ return None
80
+
81
+ items_block = match.group(1)
82
+ items: list[str] = []
83
+ for raw_line in items_block.split("\n"):
84
+ line = raw_line.strip()
85
+ if not line.startswith("- "):
86
+ continue
87
+ value = line[2:].strip()
88
+ # Strip surrounding quotes (single or double).
89
+ if len(value) >= 2 and (
90
+ (value.startswith('"') and value.endswith('"'))
91
+ or (value.startswith("'") and value.endswith("'"))
92
+ ):
93
+ value = value[1:-1]
94
+ if value:
95
+ items.append(value)
96
+ return items or None
97
+
98
+
99
+ def apply_skill_filter(
100
+ slug_dir: Path, *, extra_preserved: tuple[str, ...] = ()
101
+ ) -> dict[str, int]:
102
+ """Применяет .skillignore И/ИЛИ manifest.files allowlist в slug_dir.
103
+
104
+ Логика:
105
+ - Если SKILL.md имеет `files:` → ALLOWLIST mode (оставляет только matched).
106
+ .skillignore игнорируется в этом случае (allowlist эксплицитнее).
107
+ - Иначе если .skillignore есть → IGNORE mode (удаляет matched).
108
+ - Иначе ничего не делает.
109
+
110
+ `extra_preserved` — дополнительные корневые пути (напр. `_local/`,
111
+ `browser_profiles/`), которые НИКОГДА не удаляются. Нужно при update,
112
+ чтобы allowlist новой версии не стёр пользовательский runtime-state.
113
+
114
+ Возвращает {"removed": N, "kept": M}.
115
+ `kept` НЕ учитывает файлы внутри .git/.
116
+ """
117
+ skill_md = slug_dir / "SKILL.md"
118
+ allowlist_patterns = parse_skill_md_files_allowlist(skill_md)
119
+
120
+ if allowlist_patterns is not None:
121
+ return _apply_allowlist(slug_dir, allowlist_patterns, extra_preserved)
122
+
123
+ skillignore = slug_dir / SKILLIGNORE_FILENAME
124
+ if skillignore.exists():
125
+ patterns = [
126
+ line
127
+ for line in skillignore.read_text(encoding="utf-8").splitlines()
128
+ if line.strip() and not line.lstrip().startswith("#")
129
+ ]
130
+ if patterns:
131
+ return _apply_ignore(slug_dir, patterns, extra_preserved)
132
+
133
+ return {"removed": 0, "kept": _count_files(slug_dir)}
134
+
135
+
136
+ def _normalize_preserved_roots(extra_preserved: tuple[str, ...]) -> frozenset[str]:
137
+ """Корневые имена из extra_preserved (`_local/` → `_local`, `.env` → `.env`)."""
138
+ roots: set[str] = set()
139
+ for p in extra_preserved:
140
+ norm = p.replace("\\", "/").strip().strip("/")
141
+ if not norm:
142
+ continue
143
+ roots.add(norm.split("/")[0])
144
+ return frozenset(roots)
145
+
146
+
147
+ def _is_preserved(
148
+ slug_dir: Path, path: Path, rel: Path, extra_roots: frozenset[str] = frozenset()
149
+ ) -> bool:
150
+ """True если path внутри slug_dir НЕЛЬЗЯ удалять (preserved root или внутри .git)."""
151
+ # Файл/папка прямо в корне с защищённым именем.
152
+ if path.parent == slug_dir and path.name in _PRESERVED_ROOT_NAMES:
153
+ return True
154
+ if not rel.parts:
155
+ return False
156
+ # Что угодно внутри .git/ или внутри extra-preserved root (_local/, ...).
157
+ return rel.parts[0] == ".git" or rel.parts[0] in extra_roots
158
+
159
+
160
+ def _remove(path: Path) -> bool:
161
+ """Удаляет file или dir; True если что-то удалили."""
162
+ if path.is_symlink() or path.is_file():
163
+ try:
164
+ path.unlink()
165
+ return True
166
+ except FileNotFoundError:
167
+ return False
168
+ if path.is_dir():
169
+ shutil.rmtree(path, onerror=_on_rm_error)
170
+ return True
171
+ return False
172
+
173
+
174
+ def _apply_ignore(
175
+ slug_dir: Path, patterns: list[str], extra_preserved: tuple[str, ...] = ()
176
+ ) -> dict[str, int]:
177
+ spec = pathspec.PathSpec.from_lines("gitignore", patterns)
178
+ extra_roots = _normalize_preserved_roots(extra_preserved)
179
+ removed = 0
180
+
181
+ # Сортировка по убыванию глубины — сначала листья, потом директории.
182
+ # Это позволяет удалять файлы внутри директории до самой директории.
183
+ for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
184
+ if not path.exists():
185
+ continue # уже удалён вместе с родителем
186
+ try:
187
+ rel = path.relative_to(slug_dir)
188
+ except ValueError:
189
+ continue
190
+ if _is_preserved(slug_dir, path, rel, extra_roots):
191
+ continue
192
+
193
+ rel_str = str(rel).replace("\\", "/")
194
+ # Для директорий pathspec ожидает trailing slash, чтобы matched `tests/`.
195
+ match_target = rel_str + "/" if path.is_dir() else rel_str
196
+ if (spec.match_file(match_target) or spec.match_file(rel_str)) and _remove(path):
197
+ removed += 1
198
+
199
+ return {"removed": removed, "kept": _count_files(slug_dir)}
200
+
201
+
202
+ def _apply_allowlist(
203
+ slug_dir: Path, patterns: list[str], extra_preserved: tuple[str, ...] = ()
204
+ ) -> dict[str, int]:
205
+ spec = pathspec.PathSpec.from_lines("gitignore", patterns)
206
+ extra_roots = _normalize_preserved_roots(extra_preserved)
207
+ removed = 0
208
+
209
+ # Pass 1: удалить файлы НЕ в allowlist.
210
+ for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
211
+ if not path.exists():
212
+ continue
213
+ try:
214
+ rel = path.relative_to(slug_dir)
215
+ except ValueError:
216
+ continue
217
+ if _is_preserved(slug_dir, path, rel, extra_roots):
218
+ continue
219
+ if not path.is_file() and not path.is_symlink():
220
+ continue
221
+
222
+ rel_str = str(rel).replace("\\", "/")
223
+ if not spec.match_file(rel_str) and _remove(path):
224
+ removed += 1
225
+
226
+ # Pass 2: удалить пустые директории (которые остались после удаления детей
227
+ # и сами не в allowlist).
228
+ for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
229
+ if not path.exists() or not path.is_dir():
230
+ continue
231
+ try:
232
+ rel = path.relative_to(slug_dir)
233
+ except ValueError:
234
+ continue
235
+ if _is_preserved(slug_dir, path, rel, extra_roots):
236
+ continue
237
+ if not any(path.iterdir()):
238
+ try:
239
+ path.rmdir()
240
+ removed += 1
241
+ except OSError:
242
+ pass
243
+
244
+ return {"removed": removed, "kept": _count_files(slug_dir)}
245
+
246
+
247
+ def _count_files(slug_dir: Path) -> int:
248
+ count = 0
249
+ for p in slug_dir.rglob("*"):
250
+ try:
251
+ rel = p.relative_to(slug_dir)
252
+ except ValueError:
253
+ continue
254
+ if rel.parts and rel.parts[0] == ".git":
255
+ continue
256
+ if p.is_file():
257
+ count += 1
258
+ return count