ftxt 0.1.0__tar.gz
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.
- ftxt-0.1.0/PKG-INFO +21 -0
- ftxt-0.1.0/README.md +8 -0
- ftxt-0.1.0/pyproject.toml +24 -0
- ftxt-0.1.0/setup.cfg +4 -0
- ftxt-0.1.0/src/ftxt/__init__.py +1 -0
- ftxt-0.1.0/src/ftxt/cli.py +370 -0
- ftxt-0.1.0/src/ftxt.egg-info/PKG-INFO +21 -0
- ftxt-0.1.0/src/ftxt.egg-info/SOURCES.txt +9 -0
- ftxt-0.1.0/src/ftxt.egg-info/dependency_links.txt +1 -0
- ftxt-0.1.0/src/ftxt.egg-info/entry_points.txt +2 -0
- ftxt-0.1.0/src/ftxt.egg-info/top_level.txt +1 -0
ftxt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ftxt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict codebase-to-text bundler with in-place updates and ACID guarantees.
|
|
5
|
+
Author: germanter
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ftxt
|
|
15
|
+
|
|
16
|
+
Strict, minimal CLI codebase-to-text packaging tool.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install ftxt
|
ftxt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ftxt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Strict codebase-to-text bundler with in-place updates and ACID guarantees."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "germanter" }]
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Software Development :: Build Tools",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
ftxt = "ftxt.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
ftxt-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shlex
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ROOT_DIR = Path.cwd().resolve()
|
|
7
|
+
active_folder: Path = ROOT_DIR
|
|
8
|
+
active_file: Path | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_cli_input(raw: str) -> list[str]:
|
|
12
|
+
raw_tokens = shlex.split(raw, posix=False)
|
|
13
|
+
cleaned = []
|
|
14
|
+
for t in raw_tokens:
|
|
15
|
+
if len(t) >= 2 and ((t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'"))):
|
|
16
|
+
cleaned.append(t[1:-1])
|
|
17
|
+
else:
|
|
18
|
+
cleaned.append(t)
|
|
19
|
+
return cleaned
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_folder_target(name: str) -> Path:
|
|
23
|
+
normalized = name.replace("\\", "/")
|
|
24
|
+
p = Path(normalized)
|
|
25
|
+
if "/" in normalized:
|
|
26
|
+
return (ROOT_DIR / p).resolve()
|
|
27
|
+
return (active_folder / p).resolve()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_target_file(name: str, must_be_txt: bool = True) -> Path:
|
|
31
|
+
normalized = name.replace("\\", "/")
|
|
32
|
+
p = Path(normalized)
|
|
33
|
+
|
|
34
|
+
if must_be_txt:
|
|
35
|
+
if p.suffix and p.suffix != ".txt":
|
|
36
|
+
raise ValueError(f"target is not a .txt file: {name}")
|
|
37
|
+
if not p.suffix:
|
|
38
|
+
normalized += ".txt"
|
|
39
|
+
p = Path(normalized)
|
|
40
|
+
|
|
41
|
+
if "/" in normalized:
|
|
42
|
+
return (ROOT_DIR / p).resolve()
|
|
43
|
+
return (active_folder / p).resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_next_available_dir(base_dir: Path) -> Path:
|
|
47
|
+
if not base_dir.exists():
|
|
48
|
+
return base_dir
|
|
49
|
+
idx = 1
|
|
50
|
+
while True:
|
|
51
|
+
candidate = base_dir.parent / f"{base_dir.name}_{idx}"
|
|
52
|
+
if not candidate.exists():
|
|
53
|
+
return candidate
|
|
54
|
+
idx += 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_next_available_file(base_file: Path) -> Path:
|
|
58
|
+
if not base_file.exists():
|
|
59
|
+
return base_file
|
|
60
|
+
stem = base_file.stem
|
|
61
|
+
idx = 1
|
|
62
|
+
while True:
|
|
63
|
+
candidate = base_file.parent / f"{stem}_{idx}.txt"
|
|
64
|
+
if not candidate.exists():
|
|
65
|
+
return candidate
|
|
66
|
+
idx += 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_path_header(line: str) -> bool:
|
|
70
|
+
clean = line.strip().replace("\\", "/")
|
|
71
|
+
if not clean or any(c in clean for c in ' \t":{}()<>;$!'):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
target_path = (ROOT_DIR / clean).resolve()
|
|
76
|
+
if target_path.is_file() and ROOT_DIR in target_path.parents:
|
|
77
|
+
return True
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
parts = clean.split("/")
|
|
82
|
+
if len(parts) > 1 and "." in parts[-1] and not parts[-1].startswith("."):
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_out_file(target: Path) -> list[tuple[str, str]]:
|
|
89
|
+
if not target.exists():
|
|
90
|
+
raise FileNotFoundError(f"target file not found: {target.name}")
|
|
91
|
+
|
|
92
|
+
raw_text = target.read_text(encoding="utf-8")
|
|
93
|
+
if not raw_text.strip():
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
lines = raw_text.splitlines()
|
|
97
|
+
entries: list[tuple[str, list[str]]] = []
|
|
98
|
+
current_path: str | None = None
|
|
99
|
+
current_lines: list[str] = []
|
|
100
|
+
|
|
101
|
+
for line in lines:
|
|
102
|
+
trimmed = line.strip()
|
|
103
|
+
if is_path_header(trimmed):
|
|
104
|
+
if current_path is not None:
|
|
105
|
+
entries.append((current_path, current_lines))
|
|
106
|
+
current_path = trimmed.replace("\\", "/")
|
|
107
|
+
current_lines = []
|
|
108
|
+
else:
|
|
109
|
+
if current_path is not None:
|
|
110
|
+
current_lines.append(line)
|
|
111
|
+
|
|
112
|
+
if current_path is not None:
|
|
113
|
+
entries.append((current_path, current_lines))
|
|
114
|
+
|
|
115
|
+
result: list[tuple[str, str]] = []
|
|
116
|
+
for path_str, body_lines in entries:
|
|
117
|
+
body = "\n".join(body_lines).rstrip("\r\n")
|
|
118
|
+
result.append((path_str, body))
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_source_files(source_str: str) -> list[Path]:
|
|
124
|
+
normalized = source_str.replace("\\", "/")
|
|
125
|
+
p = Path(normalized)
|
|
126
|
+
resolved = p if p.is_absolute() else (ROOT_DIR / p).resolve()
|
|
127
|
+
|
|
128
|
+
if not resolved.exists():
|
|
129
|
+
raise FileNotFoundError(f"path does not exist: {source_str}")
|
|
130
|
+
|
|
131
|
+
if resolved.is_file():
|
|
132
|
+
return [resolved]
|
|
133
|
+
|
|
134
|
+
if resolved.is_dir():
|
|
135
|
+
files = sorted([f for f in resolved.rglob("*") if f.is_file()])
|
|
136
|
+
if not files:
|
|
137
|
+
raise FileNotFoundError(f"directory contains no files: {source_str}")
|
|
138
|
+
return files
|
|
139
|
+
|
|
140
|
+
raise ValueError(f"unsupported path type: {source_str}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def commit_write(target: Path, source_args: list[str]) -> None:
|
|
144
|
+
global active_file
|
|
145
|
+
|
|
146
|
+
if not target.parent.exists():
|
|
147
|
+
raise FileNotFoundError(f"destination directory does not exist: {target.parent}")
|
|
148
|
+
|
|
149
|
+
target_files: list[Path] = []
|
|
150
|
+
for src in source_args:
|
|
151
|
+
target_files.extend(resolve_source_files(src))
|
|
152
|
+
|
|
153
|
+
incoming_payloads: list[tuple[str, str]] = []
|
|
154
|
+
for f in target_files:
|
|
155
|
+
try:
|
|
156
|
+
content = f.read_text(encoding="utf-8")
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise RuntimeError(f"failed to read '{f}': {exc}") from exc
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
rel_path = f.relative_to(ROOT_DIR).as_posix()
|
|
162
|
+
except ValueError:
|
|
163
|
+
rel_path = f.as_posix()
|
|
164
|
+
|
|
165
|
+
incoming_payloads.append((rel_path, content.rstrip("\r\n")))
|
|
166
|
+
|
|
167
|
+
existing_entries: list[tuple[str, str]] = []
|
|
168
|
+
if target.exists() and target.stat().st_size > 0:
|
|
169
|
+
existing_entries = parse_out_file(target)
|
|
170
|
+
|
|
171
|
+
entry_index_map = {path: idx for idx, (path, _) in enumerate(existing_entries)}
|
|
172
|
+
|
|
173
|
+
for rel_path, content in incoming_payloads:
|
|
174
|
+
if rel_path in entry_index_map:
|
|
175
|
+
idx = entry_index_map[rel_path]
|
|
176
|
+
existing_entries[idx] = (rel_path, content)
|
|
177
|
+
else:
|
|
178
|
+
entry_index_map[rel_path] = len(existing_entries)
|
|
179
|
+
existing_entries.append((rel_path, content))
|
|
180
|
+
|
|
181
|
+
with open(target, "w", encoding="utf-8") as out:
|
|
182
|
+
for rel_path, content in existing_entries:
|
|
183
|
+
out.write(f"{rel_path}\n{content}\n\n")
|
|
184
|
+
|
|
185
|
+
active_file = target
|
|
186
|
+
print("roger")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_createfolder(args: list[str]) -> None:
|
|
190
|
+
if len(args) != 1:
|
|
191
|
+
raise ValueError("createfolder requires exactly 1 argument")
|
|
192
|
+
|
|
193
|
+
target = resolve_folder_target(args[0])
|
|
194
|
+
final_dir = get_next_available_dir(target)
|
|
195
|
+
final_dir.mkdir(parents=True, exist_ok=False)
|
|
196
|
+
print("roger")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_selectfolder(args: list[str]) -> None:
|
|
200
|
+
global active_folder
|
|
201
|
+
|
|
202
|
+
if len(args) != 1:
|
|
203
|
+
raise ValueError("selectfolder requires exactly 1 argument")
|
|
204
|
+
|
|
205
|
+
target = resolve_folder_target(args[0])
|
|
206
|
+
if not target.is_dir():
|
|
207
|
+
raise FileNotFoundError(f"directory not found: {args[0]}")
|
|
208
|
+
|
|
209
|
+
active_folder = target
|
|
210
|
+
print("roger")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_openfile(args: list[str]) -> None:
|
|
214
|
+
global active_file
|
|
215
|
+
|
|
216
|
+
if len(args) != 1:
|
|
217
|
+
raise ValueError("openfile requires exactly 1 argument")
|
|
218
|
+
|
|
219
|
+
target = resolve_target_file(args[0], must_be_txt=True)
|
|
220
|
+
if not target.parent.exists():
|
|
221
|
+
raise FileNotFoundError(f"parent directory does not exist: {target.parent}")
|
|
222
|
+
|
|
223
|
+
final_file = get_next_available_file(target)
|
|
224
|
+
final_file.touch(exist_ok=False)
|
|
225
|
+
|
|
226
|
+
active_file = final_file
|
|
227
|
+
print("roger")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_delfile(args: list[str]) -> None:
|
|
231
|
+
global active_file
|
|
232
|
+
|
|
233
|
+
if len(args) != 1:
|
|
234
|
+
raise ValueError("delfile requires exactly 1 argument")
|
|
235
|
+
|
|
236
|
+
target = resolve_target_file(args[0], must_be_txt=True)
|
|
237
|
+
|
|
238
|
+
if not target.exists():
|
|
239
|
+
raise FileNotFoundError(f"file not found: {args[0]}")
|
|
240
|
+
if target.is_dir():
|
|
241
|
+
raise ValueError(f"refused: target is a directory: {args[0]}")
|
|
242
|
+
|
|
243
|
+
target.unlink()
|
|
244
|
+
if active_file == target:
|
|
245
|
+
active_file = None
|
|
246
|
+
|
|
247
|
+
print("roger")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_inspect(args: list[str]) -> None:
|
|
251
|
+
global active_file
|
|
252
|
+
|
|
253
|
+
if len(args) > 1:
|
|
254
|
+
raise ValueError("inspect accepts at most 1 argument")
|
|
255
|
+
|
|
256
|
+
if len(args) == 1:
|
|
257
|
+
target = resolve_target_file(args[0], must_be_txt=True)
|
|
258
|
+
else:
|
|
259
|
+
if active_file is None:
|
|
260
|
+
raise RuntimeError("no active file. use 'inspect <target>'")
|
|
261
|
+
target = active_file
|
|
262
|
+
|
|
263
|
+
entries = parse_out_file(target)
|
|
264
|
+
if not entries:
|
|
265
|
+
raise ValueError(f"file '{target.name}' contains no records")
|
|
266
|
+
|
|
267
|
+
active_file = target
|
|
268
|
+
for idx, (path_str, _) in enumerate(entries, 1):
|
|
269
|
+
print(f"{idx}. {path_str}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_writo(args: list[str]) -> None:
|
|
273
|
+
global active_file
|
|
274
|
+
|
|
275
|
+
if not args:
|
|
276
|
+
raise ValueError("writo requires target file")
|
|
277
|
+
|
|
278
|
+
target = resolve_target_file(args[0], must_be_txt=True)
|
|
279
|
+
if not target.parent.exists():
|
|
280
|
+
raise FileNotFoundError(f"parent directory does not exist: {target.parent}")
|
|
281
|
+
|
|
282
|
+
if not target.exists():
|
|
283
|
+
target.touch()
|
|
284
|
+
|
|
285
|
+
active_file = target
|
|
286
|
+
|
|
287
|
+
if len(args) == 1:
|
|
288
|
+
print("roger")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
commit_write(target, args[1:])
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_write(args: list[str]) -> None:
|
|
295
|
+
global active_file
|
|
296
|
+
|
|
297
|
+
if not args:
|
|
298
|
+
raise ValueError("missing arguments for write")
|
|
299
|
+
|
|
300
|
+
if args[0].lower() == "to":
|
|
301
|
+
cmd_writo(args[1:])
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if active_file is None:
|
|
305
|
+
raise RuntimeError("no active file. use 'writo <target>' or 'openfile'")
|
|
306
|
+
|
|
307
|
+
commit_write(active_file, args)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def cmd_help() -> None:
|
|
311
|
+
help_text = (
|
|
312
|
+
"commands:\n"
|
|
313
|
+
" createfolder <name> Create directory with auto-indexing (alias: mkdir)\n"
|
|
314
|
+
" selectfolder <name> Select active directory (alias: cd)\n"
|
|
315
|
+
" openfile <name> Create indexed .txt file\n"
|
|
316
|
+
" delfile <target> Delete .txt file (directories forbidden)\n"
|
|
317
|
+
" writo <target> [paths...] Select .txt file or write/update files inplace\n"
|
|
318
|
+
" write to <target> [paths..] Equivalent to writo\n"
|
|
319
|
+
" write <path...> Write/update files into current active .txt\n"
|
|
320
|
+
" inspect [target] List files contained inside target .txt\n"
|
|
321
|
+
" help Show manual\n"
|
|
322
|
+
" exit Terminate session (alias: quit)"
|
|
323
|
+
)
|
|
324
|
+
print(help_text)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main() -> None:
|
|
328
|
+
while True:
|
|
329
|
+
try:
|
|
330
|
+
raw = input("ftxt> ").strip()
|
|
331
|
+
if not raw:
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
tokens = parse_cli_input(raw)
|
|
335
|
+
if not tokens:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
cmd = tokens[0].lower()
|
|
339
|
+
args = tokens[1:]
|
|
340
|
+
|
|
341
|
+
if cmd in ("exit", "quit"):
|
|
342
|
+
sys.exit(0)
|
|
343
|
+
elif cmd in ("createfolder", "mkdir"):
|
|
344
|
+
cmd_createfolder(args)
|
|
345
|
+
elif cmd in ("selectfolder", "cd"):
|
|
346
|
+
cmd_selectfolder(args)
|
|
347
|
+
elif cmd in ("openfile", "open"):
|
|
348
|
+
cmd_openfile(args)
|
|
349
|
+
elif cmd == "delfile":
|
|
350
|
+
cmd_delfile(args)
|
|
351
|
+
elif cmd == "writo":
|
|
352
|
+
cmd_writo(args)
|
|
353
|
+
elif cmd == "write":
|
|
354
|
+
cmd_write(args)
|
|
355
|
+
elif cmd == "inspect":
|
|
356
|
+
cmd_inspect(args)
|
|
357
|
+
elif cmd == "help":
|
|
358
|
+
cmd_help()
|
|
359
|
+
else:
|
|
360
|
+
raise ValueError(f"unknown command: {cmd}")
|
|
361
|
+
|
|
362
|
+
except (ValueError, FileNotFoundError, RuntimeError) as err:
|
|
363
|
+
print(f"fail: {err}")
|
|
364
|
+
except (KeyboardInterrupt, EOFError):
|
|
365
|
+
print("\nroger")
|
|
366
|
+
sys.exit(0)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ftxt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict codebase-to-text bundler with in-place updates and ACID guarantees.
|
|
5
|
+
Author: germanter
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ftxt
|
|
15
|
+
|
|
16
|
+
Strict, minimal CLI codebase-to-text packaging tool.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install ftxt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ftxt
|