clutterxp 1.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.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
clutterxp/organizer.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Organizer facade coordinating Scanner, RuleEngine, Planner, Mover, and History.
|
|
2
|
+
|
|
3
|
+
Implements Organizer Protocol from clutterxp.interfaces.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from clutterxp import paths, safety
|
|
14
|
+
from clutterxp.config import loader
|
|
15
|
+
from clutterxp.config.runtime import RuntimeConfig
|
|
16
|
+
from clutterxp.interfaces import History, Mover, Organizer, RuleEngine
|
|
17
|
+
from clutterxp.locking import fs_lock
|
|
18
|
+
from clutterxp.models import (
|
|
19
|
+
BatchResult,
|
|
20
|
+
ErrorCode,
|
|
21
|
+
FileInfo,
|
|
22
|
+
MoveResult,
|
|
23
|
+
Plan,
|
|
24
|
+
PlannedAction,
|
|
25
|
+
PlanStatus,
|
|
26
|
+
ProgressCb,
|
|
27
|
+
UndoOutcome,
|
|
28
|
+
)
|
|
29
|
+
from clutterxp.planner import build_plan
|
|
30
|
+
from clutterxp.rules import build_engine
|
|
31
|
+
from clutterxp.scanner import scan
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def needs_confirmation(plan: Plan, threshold: int = 10) -> bool:
|
|
37
|
+
"""Return True if plan moves at least threshold files (or if threshold == 0 and files > 0)."""
|
|
38
|
+
moves = plan.count(PlanStatus.WILL_MOVE) + plan.count(PlanStatus.WILL_MOVE_RENAMED)
|
|
39
|
+
if moves == 0:
|
|
40
|
+
return False
|
|
41
|
+
if threshold == 0:
|
|
42
|
+
return True
|
|
43
|
+
return moves >= threshold
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrganizerImpl(Organizer):
|
|
47
|
+
"""Facade implementation wires together RuleEngine, Mover, History and Planner."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
config: RuntimeConfig,
|
|
52
|
+
engine: RuleEngine,
|
|
53
|
+
mover: Mover,
|
|
54
|
+
history: History,
|
|
55
|
+
*,
|
|
56
|
+
undo_service: Any = None,
|
|
57
|
+
fs_lock_path: Path | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.config: RuntimeConfig = config
|
|
60
|
+
self.engine: RuleEngine = engine
|
|
61
|
+
self.mover: Mover = mover
|
|
62
|
+
self.history: History = history
|
|
63
|
+
self.undo_service: Any = undo_service
|
|
64
|
+
self._fs_lock_path: Path | None = fs_lock_path
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def fs_lock_path(self) -> Path:
|
|
68
|
+
"""Filesystem lock path."""
|
|
69
|
+
if self._fs_lock_path is not None:
|
|
70
|
+
return self._fs_lock_path
|
|
71
|
+
return paths.fs_lock_path()
|
|
72
|
+
|
|
73
|
+
def plan(
|
|
74
|
+
self,
|
|
75
|
+
paths: Iterable[Path] | None = None,
|
|
76
|
+
*,
|
|
77
|
+
recursive: bool | None = None,
|
|
78
|
+
only_rules: Sequence[str] | None = None,
|
|
79
|
+
) -> Plan:
|
|
80
|
+
"""Scan specified folders (or watch_entries) and build a dry-run execution Plan."""
|
|
81
|
+
all_actions: list[PlannedAction] = []
|
|
82
|
+
scanned_folders: list[Path] = []
|
|
83
|
+
|
|
84
|
+
if only_rules is not None:
|
|
85
|
+
target_names = {r.casefold() for r in only_rules}
|
|
86
|
+
active_rules = [r for r in self.config.rules if r.name.casefold() in target_names]
|
|
87
|
+
plan_engine: RuleEngine = build_engine(
|
|
88
|
+
active_rules,
|
|
89
|
+
self.config.settings,
|
|
90
|
+
watch=list(self.config.watch_entries),
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
plan_engine = self.engine
|
|
94
|
+
|
|
95
|
+
if paths is None:
|
|
96
|
+
for entry in self.config.watch_entries:
|
|
97
|
+
folder_p = Path(entry.path)
|
|
98
|
+
rec = entry.recursive if recursive is None else recursive
|
|
99
|
+
safety.check_source_root(folder_p, recursive=rec)
|
|
100
|
+
scanned_folders.append(folder_p)
|
|
101
|
+
infos = scan(
|
|
102
|
+
folder_p,
|
|
103
|
+
recursive=rec,
|
|
104
|
+
settings=self.config.settings,
|
|
105
|
+
ignore_patterns=entry.ignore,
|
|
106
|
+
)
|
|
107
|
+
folder_plan = build_plan(
|
|
108
|
+
infos,
|
|
109
|
+
plan_engine,
|
|
110
|
+
self.config,
|
|
111
|
+
fs_check=True,
|
|
112
|
+
folder=folder_p,
|
|
113
|
+
)
|
|
114
|
+
all_actions.extend(folder_plan.actions)
|
|
115
|
+
else:
|
|
116
|
+
for p in paths:
|
|
117
|
+
folder_p = Path(p)
|
|
118
|
+
if folder_p.is_file():
|
|
119
|
+
safety.check_source_root(folder_p.parent, recursive=False)
|
|
120
|
+
scanned_folders.append(folder_p.parent)
|
|
121
|
+
infos_iter = iter([FileInfo.from_path(folder_p)])
|
|
122
|
+
folder_plan = build_plan(
|
|
123
|
+
infos_iter,
|
|
124
|
+
plan_engine,
|
|
125
|
+
self.config,
|
|
126
|
+
fs_check=True,
|
|
127
|
+
folder=folder_p.parent,
|
|
128
|
+
)
|
|
129
|
+
all_actions.extend(folder_plan.actions)
|
|
130
|
+
else:
|
|
131
|
+
rec = False if recursive is None else recursive
|
|
132
|
+
safety.check_source_root(folder_p, recursive=rec)
|
|
133
|
+
scanned_folders.append(folder_p)
|
|
134
|
+
infos = scan(
|
|
135
|
+
folder_p,
|
|
136
|
+
recursive=rec,
|
|
137
|
+
settings=self.config.settings,
|
|
138
|
+
ignore_patterns=(),
|
|
139
|
+
)
|
|
140
|
+
folder_plan = build_plan(
|
|
141
|
+
infos,
|
|
142
|
+
plan_engine,
|
|
143
|
+
self.config,
|
|
144
|
+
fs_check=True,
|
|
145
|
+
folder=folder_p,
|
|
146
|
+
)
|
|
147
|
+
all_actions.extend(folder_plan.actions)
|
|
148
|
+
|
|
149
|
+
return Plan(folders=tuple(scanned_folders), actions=tuple(all_actions))
|
|
150
|
+
|
|
151
|
+
def apply(
|
|
152
|
+
self,
|
|
153
|
+
plan: Plan,
|
|
154
|
+
*,
|
|
155
|
+
command: str = "organize",
|
|
156
|
+
progress: ProgressCb | None = None,
|
|
157
|
+
) -> BatchResult:
|
|
158
|
+
"""Execute plan actions under journal-first protocol and fs lock."""
|
|
159
|
+
first_folder = plan.folders[0] if plan.folders else None
|
|
160
|
+
batch_id = self.history.begin_batch(command, folder=first_folder)
|
|
161
|
+
|
|
162
|
+
moved = 0
|
|
163
|
+
skipped = 0
|
|
164
|
+
blocked = 0
|
|
165
|
+
failed = 0
|
|
166
|
+
results: list[MoveResult] = []
|
|
167
|
+
|
|
168
|
+
for action in plan.actions:
|
|
169
|
+
if action.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
|
|
170
|
+
if action.decision.hit is None or action.dest_path is None:
|
|
171
|
+
failed += 1
|
|
172
|
+
res = MoveResult(
|
|
173
|
+
ok=False,
|
|
174
|
+
src=action.file.path,
|
|
175
|
+
dst=None,
|
|
176
|
+
strategy=None,
|
|
177
|
+
code=ErrorCode.UNKNOWN,
|
|
178
|
+
message="Missing rule hit or destination path for planned move",
|
|
179
|
+
)
|
|
180
|
+
results.append(res)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
with fs_lock(self.fs_lock_path):
|
|
184
|
+
# TOCTOU check: re-verify source file exists and is unchanged
|
|
185
|
+
try:
|
|
186
|
+
st = action.file.path.lstat()
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
failed += 1
|
|
189
|
+
res = MoveResult(
|
|
190
|
+
ok=False,
|
|
191
|
+
src=action.file.path,
|
|
192
|
+
dst=None,
|
|
193
|
+
strategy=None,
|
|
194
|
+
code=ErrorCode.SRC_VANISHED,
|
|
195
|
+
message=f"Source file {action.file.path} vanished since plan",
|
|
196
|
+
)
|
|
197
|
+
results.append(res)
|
|
198
|
+
self.history.record_event(
|
|
199
|
+
"error",
|
|
200
|
+
res.message,
|
|
201
|
+
batch_id=batch_id,
|
|
202
|
+
path=action.file.path,
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
except OSError as e:
|
|
206
|
+
failed += 1
|
|
207
|
+
res = MoveResult(
|
|
208
|
+
ok=False,
|
|
209
|
+
src=action.file.path,
|
|
210
|
+
dst=None,
|
|
211
|
+
strategy=None,
|
|
212
|
+
code=ErrorCode.PERMISSION,
|
|
213
|
+
message=str(e),
|
|
214
|
+
)
|
|
215
|
+
results.append(res)
|
|
216
|
+
self.history.record_event(
|
|
217
|
+
"error",
|
|
218
|
+
res.message,
|
|
219
|
+
batch_id=batch_id,
|
|
220
|
+
path=action.file.path,
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
if st.st_size != action.file.size or st.st_mtime_ns != action.file.mtime_ns:
|
|
225
|
+
failed += 1
|
|
226
|
+
res = MoveResult(
|
|
227
|
+
ok=False,
|
|
228
|
+
src=action.file.path,
|
|
229
|
+
dst=None,
|
|
230
|
+
strategy=None,
|
|
231
|
+
code=ErrorCode.SRC_CHANGED,
|
|
232
|
+
message=f"Source file {action.file.path} changed since plan",
|
|
233
|
+
)
|
|
234
|
+
results.append(res)
|
|
235
|
+
self.history.record_event(
|
|
236
|
+
"error",
|
|
237
|
+
res.message,
|
|
238
|
+
batch_id=batch_id,
|
|
239
|
+
path=action.file.path,
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Invariant S5: Journal durable pending row before move
|
|
244
|
+
op_id = self.history.begin_op(
|
|
245
|
+
batch_id,
|
|
246
|
+
action.file.path,
|
|
247
|
+
action.decision.hit.rule_name,
|
|
248
|
+
action.file,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
result = self.mover.move_file(
|
|
252
|
+
action.file.path,
|
|
253
|
+
action.dest_path.parent,
|
|
254
|
+
new_name=action.dest_path.name,
|
|
255
|
+
collision=action.decision.hit.collision,
|
|
256
|
+
create=action.decision.hit.create,
|
|
257
|
+
static_root=action.decision.hit.static_root,
|
|
258
|
+
expect_size=action.file.size,
|
|
259
|
+
expect_mtime_ns=action.file.mtime_ns,
|
|
260
|
+
progress=progress,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
self.history.finish_op(op_id, result)
|
|
264
|
+
results.append(result)
|
|
265
|
+
|
|
266
|
+
if result.ok:
|
|
267
|
+
moved += 1
|
|
268
|
+
else:
|
|
269
|
+
if result.code == ErrorCode.COLLISION:
|
|
270
|
+
blocked += 1
|
|
271
|
+
else:
|
|
272
|
+
failed += 1
|
|
273
|
+
|
|
274
|
+
elif action.status == PlanStatus.AMBIGUOUS:
|
|
275
|
+
skipped += 1
|
|
276
|
+
self.history.record_event(
|
|
277
|
+
"skip_ambiguous",
|
|
278
|
+
f"File '{action.file.path}' is ambiguous: {action.message}",
|
|
279
|
+
batch_id=batch_id,
|
|
280
|
+
path=action.file.path,
|
|
281
|
+
)
|
|
282
|
+
elif action.status == PlanStatus.BLOCKED:
|
|
283
|
+
blocked += 1
|
|
284
|
+
self.history.record_event(
|
|
285
|
+
"skip_blocked",
|
|
286
|
+
f"File '{action.file.path}' is blocked ({action.code.value}): {action.message}",
|
|
287
|
+
batch_id=batch_id,
|
|
288
|
+
path=action.file.path,
|
|
289
|
+
)
|
|
290
|
+
elif action.status == PlanStatus.NO_MATCH:
|
|
291
|
+
skipped += 1
|
|
292
|
+
if self.config.settings.history.log_no_match:
|
|
293
|
+
self.history.record_event(
|
|
294
|
+
"skip_no_match",
|
|
295
|
+
f"File '{action.file.path}' matched no rules",
|
|
296
|
+
batch_id=batch_id,
|
|
297
|
+
path=action.file.path,
|
|
298
|
+
)
|
|
299
|
+
elif action.status == PlanStatus.IGNORED:
|
|
300
|
+
skipped += 1
|
|
301
|
+
|
|
302
|
+
return BatchResult(
|
|
303
|
+
batch_id=batch_id,
|
|
304
|
+
moved=moved,
|
|
305
|
+
skipped=skipped,
|
|
306
|
+
blocked=blocked,
|
|
307
|
+
failed=failed,
|
|
308
|
+
results=tuple(results),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def process_file(
|
|
312
|
+
self, path: Path, *, folder: Path | None = None, command: str = "watch"
|
|
313
|
+
) -> BatchResult:
|
|
314
|
+
"""Single-file processing used by watcher: decide -> plan -> apply as a batch of 1."""
|
|
315
|
+
p = Path(path)
|
|
316
|
+
if not p.exists():
|
|
317
|
+
return BatchResult(
|
|
318
|
+
batch_id=None,
|
|
319
|
+
moved=0,
|
|
320
|
+
skipped=0,
|
|
321
|
+
blocked=0,
|
|
322
|
+
failed=1,
|
|
323
|
+
results=(
|
|
324
|
+
MoveResult(
|
|
325
|
+
ok=False,
|
|
326
|
+
src=p,
|
|
327
|
+
dst=None,
|
|
328
|
+
strategy=None,
|
|
329
|
+
code=ErrorCode.SRC_VANISHED,
|
|
330
|
+
message=f"File {p} does not exist",
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
info = FileInfo.from_path(p)
|
|
336
|
+
plan_folder = folder if folder is not None else p.parent
|
|
337
|
+
plan = build_plan([info], self.engine, self.config, fs_check=True, folder=plan_folder)
|
|
338
|
+
return self.apply(plan, command=command)
|
|
339
|
+
|
|
340
|
+
def undo(
|
|
341
|
+
self,
|
|
342
|
+
*,
|
|
343
|
+
op_id: int | None = None,
|
|
344
|
+
batch_id: int | None = None,
|
|
345
|
+
dry_run: bool = False,
|
|
346
|
+
on_conflict: str = "skip",
|
|
347
|
+
allow_modified: bool = False,
|
|
348
|
+
) -> Sequence[UndoOutcome]:
|
|
349
|
+
"""Delegate undo to the injected UndoService."""
|
|
350
|
+
if self.undo_service is None:
|
|
351
|
+
raise NotImplementedError("UndoService not configured")
|
|
352
|
+
|
|
353
|
+
outcome: Sequence[UndoOutcome] = self.undo_service.undo(
|
|
354
|
+
op_id=op_id,
|
|
355
|
+
batch_id=batch_id,
|
|
356
|
+
dry_run=dry_run,
|
|
357
|
+
on_conflict=on_conflict,
|
|
358
|
+
allow_modified=allow_modified,
|
|
359
|
+
)
|
|
360
|
+
return outcome
|
|
361
|
+
|
|
362
|
+
def reload_config(self) -> None:
|
|
363
|
+
"""Reload configuration from disk and atomically update engine if valid."""
|
|
364
|
+
try:
|
|
365
|
+
new_config = loader.load(self.config.config_path)
|
|
366
|
+
if new_config.is_valid:
|
|
367
|
+
new_engine = build_engine(
|
|
368
|
+
list(new_config.rules),
|
|
369
|
+
new_config.settings,
|
|
370
|
+
watch=list(new_config.watch_entries),
|
|
371
|
+
)
|
|
372
|
+
self.config = new_config
|
|
373
|
+
self.engine = new_engine
|
|
374
|
+
logger.info("Config successfully reloaded from %s", self.config.config_path)
|
|
375
|
+
else:
|
|
376
|
+
logger.error(
|
|
377
|
+
"Config reload rejected due to validation errors: %s",
|
|
378
|
+
", ".join(new_config.errors),
|
|
379
|
+
)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logger.error("Config reload failed with exception: %s", e)
|
|
382
|
+
|
|
383
|
+
def needs_confirmation(self, plan: Plan) -> bool:
|
|
384
|
+
"""Check if plan requires confirmation against confirm_threshold."""
|
|
385
|
+
return needs_confirmation(plan, self.config.settings.confirm_threshold)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = ["OrganizerImpl", "needs_confirmation"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=ClutterXP folder watcher (automatic file sorting)
|
|
3
|
+
Documentation=https://example.invalid/clutterxp
|
|
4
|
+
After=default.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
# `clutterxp service install` replaces the path below with the real executable path.
|
|
9
|
+
ExecStart=%h/.local/bin/clutterxp watch --no-color
|
|
10
|
+
Restart=on-failure
|
|
11
|
+
RestartSec=5
|
|
12
|
+
NoNewPrivileges=yes
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=default.target
|
clutterxp/paths.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Path resolution, directory creation with 0700 permissions, and path expansion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .models import ConfigError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_dir_0700(path: Path) -> Path:
|
|
14
|
+
"""Ensure directory exists with 0700 permissions."""
|
|
15
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
16
|
+
with contextlib.suppress(OSError):
|
|
17
|
+
path.chmod(0o700)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_path() -> Path:
|
|
22
|
+
"""Path to config.yaml (honors CLUTTERXP_CONFIG, default ~/.config/clutterxp/config.yaml)."""
|
|
23
|
+
if "CLUTTERXP_CONFIG" in os.environ:
|
|
24
|
+
return Path(os.environ["CLUTTERXP_CONFIG"])
|
|
25
|
+
return Path.home() / ".config" / "clutterxp" / "config.yaml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_dir() -> Path:
|
|
29
|
+
"""Path to state directory (honors CLUTTERXP_STATE_DIR, default ~/.local/state/clutterxp/).
|
|
30
|
+
|
|
31
|
+
Created with 0700 permissions when accessed.
|
|
32
|
+
"""
|
|
33
|
+
if "CLUTTERXP_STATE_DIR" in os.environ:
|
|
34
|
+
return _ensure_dir_0700(Path(os.environ["CLUTTERXP_STATE_DIR"]))
|
|
35
|
+
return _ensure_dir_0700(Path.home() / ".local" / "state" / "clutterxp")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cache_dir() -> Path:
|
|
39
|
+
"""Path to cache directory (honors CLUTTERXP_CACHE_DIR, default ~/.cache/clutterxp/).
|
|
40
|
+
|
|
41
|
+
Created with 0700 permissions when accessed.
|
|
42
|
+
"""
|
|
43
|
+
if "CLUTTERXP_CACHE_DIR" in os.environ:
|
|
44
|
+
return _ensure_dir_0700(Path(os.environ["CLUTTERXP_CACHE_DIR"]))
|
|
45
|
+
return _ensure_dir_0700(Path.home() / ".cache" / "clutterxp")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def history_db_path() -> Path:
|
|
49
|
+
"""Path to history SQLite database (~/.local/state/clutterxp/history.db)."""
|
|
50
|
+
return state_dir() / "history.db"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def log_path() -> Path:
|
|
54
|
+
"""Path to log file (~/.local/state/clutterxp/clutterxp.log)."""
|
|
55
|
+
return state_dir() / "clutterxp.log"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fs_lock_path() -> Path:
|
|
59
|
+
"""Path to filesystem operation lock (~/.local/state/clutterxp/fs.lock)."""
|
|
60
|
+
return state_dir() / "fs.lock"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def watch_lock_path() -> Path:
|
|
64
|
+
"""Path to watch daemon lock (~/.local/state/clutterxp/watch.lock)."""
|
|
65
|
+
return state_dir() / "watch.lock"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cache_db_path() -> Path:
|
|
69
|
+
"""Path to hash cache database (~/.cache/clutterxp/cache.db)."""
|
|
70
|
+
return cache_dir() / "cache.db"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def expand(path_str: str, *, environ: dict[str, str] | None = None) -> Path:
|
|
74
|
+
"""Expand ~ and $VAR / ${VAR} in path_str.
|
|
75
|
+
|
|
76
|
+
Raises ConfigError on unresolved or invalid environment variables.
|
|
77
|
+
Does NOT touch template placeholders like {year} or {month}.
|
|
78
|
+
"""
|
|
79
|
+
env = os.environ if environ is None else environ
|
|
80
|
+
|
|
81
|
+
# Tilde expansion
|
|
82
|
+
if environ is not None and "HOME" in environ:
|
|
83
|
+
if path_str == "~":
|
|
84
|
+
path_str = environ["HOME"]
|
|
85
|
+
elif path_str.startswith("~/"):
|
|
86
|
+
path_str = environ["HOME"] + path_str[1:]
|
|
87
|
+
elif path_str.startswith("~"):
|
|
88
|
+
path_str = str(Path(path_str).expanduser())
|
|
89
|
+
else:
|
|
90
|
+
path_str = str(Path(path_str).expanduser())
|
|
91
|
+
|
|
92
|
+
# Check for unclosed variable braces
|
|
93
|
+
if re.search(r"\$\{[^}]*$", path_str):
|
|
94
|
+
raise ConfigError(f"Unclosed variable reference in path: '{path_str}'")
|
|
95
|
+
|
|
96
|
+
if re.search(r"\$\{\s*\}", path_str):
|
|
97
|
+
raise ConfigError(f"Empty variable reference in path: '{path_str}'")
|
|
98
|
+
|
|
99
|
+
var_pattern = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
|
|
100
|
+
|
|
101
|
+
def _sub(match: re.Match[str]) -> str:
|
|
102
|
+
name = match.group(1) or match.group(2)
|
|
103
|
+
if name not in env:
|
|
104
|
+
raise ConfigError(f"Unresolved environment variable: ${name}")
|
|
105
|
+
return env[name]
|
|
106
|
+
|
|
107
|
+
result = var_pattern.sub(_sub, path_str)
|
|
108
|
+
|
|
109
|
+
if "${" in result:
|
|
110
|
+
raise ConfigError(f"Unresolved or invalid variable reference in: '{path_str}'")
|
|
111
|
+
|
|
112
|
+
return Path(result)
|