dirforge-cli 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.
- dirforge_cli-0.1.0/MANIFEST.in +5 -0
- dirforge_cli-0.1.0/PKG-INFO +120 -0
- dirforge_cli-0.1.0/README.md +96 -0
- dirforge_cli-0.1.0/dirforge.py +397 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/PKG-INFO +120 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/SOURCES.txt +13 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/dependency_links.txt +1 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/entry_points.txt +2 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/requires.txt +4 -0
- dirforge_cli-0.1.0/dirforge_cli.egg-info/top_level.txt +1 -0
- dirforge_cli-0.1.0/example-structure.txt +25 -0
- dirforge_cli-0.1.0/pyproject.toml +45 -0
- dirforge_cli-0.1.0/setup.cfg +4 -0
- dirforge_cli-0.1.0/tests/__init__.py +1 -0
- dirforge_cli-0.1.0/tests/test_dirforge.py +85 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirforge-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create project directory scaffolds from visual tree text files.
|
|
5
|
+
Keywords: cli,scaffold,project-tree,directories,filesystem
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
17
|
+
Classifier: Topic :: System :: Filesystems
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
23
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# DirForge
|
|
26
|
+
|
|
27
|
+
DirForge is a dependency-free Python CLI that turns a visual project tree in a
|
|
28
|
+
UTF-8 text file into real directories and empty files.
|
|
29
|
+
|
|
30
|
+
> PyPI package name: `dirforge-cli`
|
|
31
|
+
> Installed command: `dirforge`
|
|
32
|
+
|
|
33
|
+
The distribution uses `dirforge-cli` because `dirforge` is already registered
|
|
34
|
+
to another project on PyPI.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install dirforge-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
DirForge requires Python 3.10 or newer and has no runtime dependencies.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Create a text file such as `structure.txt`:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
project/
|
|
50
|
+
├── src/
|
|
51
|
+
│ └── app.py
|
|
52
|
+
├── LICENSE
|
|
53
|
+
├── Makefile
|
|
54
|
+
└── .gitignore
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then create the scaffold:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
dirforge structure.txt
|
|
61
|
+
dirforge structure.txt --output ./projects
|
|
62
|
+
dirforge structure.txt -o ./projects --dry-run --verbose
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The repository script remains directly executable too:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python dirforge.py example-structure.txt --dry-run
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tree Syntax
|
|
72
|
+
|
|
73
|
+
Entries ending in `/` are directories. Every other entry is a file, including
|
|
74
|
+
extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
|
|
75
|
+
separator lines containing only `│` are ignored.
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
- `-o, --output`: Choose the directory that will contain the scaffold root.
|
|
80
|
+
- `--dry-run`: Show planned changes without modifying the filesystem.
|
|
81
|
+
- `--force`: Truncate existing files before recreating them.
|
|
82
|
+
- `-v, --verbose`: Print every create, skip, or overwrite operation.
|
|
83
|
+
- `--version`: Print the installed version.
|
|
84
|
+
|
|
85
|
+
Existing directories are reused and existing files are preserved by default.
|
|
86
|
+
DirForge rejects path traversal, absolute paths, separators inside node names,
|
|
87
|
+
malformed indentation, children under files, and file/directory conflicts.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
Run the test suite with only the standard library:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m unittest discover -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Install the packaging tools and build both distribution formats:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python -m pip install -e '.[dev]'
|
|
101
|
+
python -m build
|
|
102
|
+
python -m twine check dist/*
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Test the release on TestPyPI before publishing it publicly:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m twine upload --repository testpypi dist/*
|
|
109
|
+
python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
After verifying the installed `dirforge` command, upload the same artifacts to
|
|
113
|
+
PyPI:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python -m twine upload dist/*
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
PyPI does not allow replacing an uploaded version. Increment `__version__` in
|
|
120
|
+
`dirforge.py` before each new release; the build metadata reads it automatically.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# DirForge
|
|
2
|
+
|
|
3
|
+
DirForge is a dependency-free Python CLI that turns a visual project tree in a
|
|
4
|
+
UTF-8 text file into real directories and empty files.
|
|
5
|
+
|
|
6
|
+
> PyPI package name: `dirforge-cli`
|
|
7
|
+
> Installed command: `dirforge`
|
|
8
|
+
|
|
9
|
+
The distribution uses `dirforge-cli` because `dirforge` is already registered
|
|
10
|
+
to another project on PyPI.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python -m pip install dirforge-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
DirForge requires Python 3.10 or newer and has no runtime dependencies.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Create a text file such as `structure.txt`:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
project/
|
|
26
|
+
├── src/
|
|
27
|
+
│ └── app.py
|
|
28
|
+
├── LICENSE
|
|
29
|
+
├── Makefile
|
|
30
|
+
└── .gitignore
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then create the scaffold:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
dirforge structure.txt
|
|
37
|
+
dirforge structure.txt --output ./projects
|
|
38
|
+
dirforge structure.txt -o ./projects --dry-run --verbose
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The repository script remains directly executable too:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python dirforge.py example-structure.txt --dry-run
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tree Syntax
|
|
48
|
+
|
|
49
|
+
Entries ending in `/` are directories. Every other entry is a file, including
|
|
50
|
+
extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
|
|
51
|
+
separator lines containing only `│` are ignored.
|
|
52
|
+
|
|
53
|
+
## Options
|
|
54
|
+
|
|
55
|
+
- `-o, --output`: Choose the directory that will contain the scaffold root.
|
|
56
|
+
- `--dry-run`: Show planned changes without modifying the filesystem.
|
|
57
|
+
- `--force`: Truncate existing files before recreating them.
|
|
58
|
+
- `-v, --verbose`: Print every create, skip, or overwrite operation.
|
|
59
|
+
- `--version`: Print the installed version.
|
|
60
|
+
|
|
61
|
+
Existing directories are reused and existing files are preserved by default.
|
|
62
|
+
DirForge rejects path traversal, absolute paths, separators inside node names,
|
|
63
|
+
malformed indentation, children under files, and file/directory conflicts.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
Run the test suite with only the standard library:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m unittest discover -v
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Install the packaging tools and build both distribution formats:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m pip install -e '.[dev]'
|
|
77
|
+
python -m build
|
|
78
|
+
python -m twine check dist/*
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Test the release on TestPyPI before publishing it publicly:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m twine upload --repository testpypi dist/*
|
|
85
|
+
python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
After verifying the installed `dirforge` command, upload the same artifacts to
|
|
89
|
+
PyPI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python -m twine upload dist/*
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
PyPI does not allow replacing an uploaded version. Increment `__version__` in
|
|
96
|
+
`dirforge.py` before each new release; the build metadata reads it automatically.
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a filesystem scaffold from a visual project tree."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
INVALID_INPUT = 2
|
|
14
|
+
RUNTIME_FAILURE = 1
|
|
15
|
+
|
|
16
|
+
BRANCH_RE = re.compile(r"^(?P<prefix>[ │]*)(?P<branch>[├└]──)\s*(?P<name>.+?)\s*$")
|
|
17
|
+
WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DirForgeError(Exception):
|
|
23
|
+
"""Base class for expected, user-facing errors."""
|
|
24
|
+
|
|
25
|
+
exit_code = RUNTIME_FAILURE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputError(DirForgeError):
|
|
29
|
+
"""Raised when CLI input or tree syntax is invalid."""
|
|
30
|
+
|
|
31
|
+
exit_code = INVALID_INPUT
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TreeNode:
|
|
36
|
+
name: str
|
|
37
|
+
is_directory: bool
|
|
38
|
+
line_number: int
|
|
39
|
+
children: list["TreeNode"] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ScaffoldStats:
|
|
44
|
+
dirs_created: int = 0
|
|
45
|
+
files_created: int = 0
|
|
46
|
+
entries_skipped: int = 0
|
|
47
|
+
dirs_to_create: int = 0
|
|
48
|
+
files_to_create: int = 0
|
|
49
|
+
existing_entries: int = 0
|
|
50
|
+
files_overwritten: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
description="Create directories and empty files from a visual project tree stored in a .txt file.",
|
|
56
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
57
|
+
epilog="""Examples:
|
|
58
|
+
dirforge structure.txt
|
|
59
|
+
dirforge structure.txt --output ./projects
|
|
60
|
+
python dirforge.py structure.txt
|
|
61
|
+
dirforge structure.txt -o ./projects --dry-run --verbose
|
|
62
|
+
|
|
63
|
+
Tree syntax:
|
|
64
|
+
Names ending in / are directories. All other names are files.
|
|
65
|
+
Existing files are skipped by default so their contents are preserved.
|
|
66
|
+
""",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("tree_file", help="Path to a UTF-8 .txt file containing the project tree.")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-o",
|
|
71
|
+
"--output",
|
|
72
|
+
default=".",
|
|
73
|
+
help="Directory where the scaffold root should be created. Defaults to the current directory.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--dry-run",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="Parse and report planned changes without creating files or directories.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--force",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Truncate and recreate existing files. Warning: this can overwrite file contents.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"-v",
|
|
87
|
+
"--verbose",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Print each create/skip operation.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--version",
|
|
93
|
+
action="version",
|
|
94
|
+
version=f"%(prog)s {__version__}",
|
|
95
|
+
)
|
|
96
|
+
return parser.parse_args(argv)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_tree_file(path_text: str) -> list[str]:
|
|
100
|
+
path = Path(path_text)
|
|
101
|
+
|
|
102
|
+
if path.suffix.lower() != ".txt":
|
|
103
|
+
raise InputError("Input file must have a .txt extension.")
|
|
104
|
+
if not path.exists():
|
|
105
|
+
raise InputError(f"Input file does not exist: {path}")
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
raise InputError(f"Input path is not a regular file: {path}")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
text = path.read_text(encoding="utf-8")
|
|
111
|
+
except UnicodeDecodeError as exc:
|
|
112
|
+
raise InputError(f"Input file is not valid UTF-8: {path}") from exc
|
|
113
|
+
except PermissionError as exc:
|
|
114
|
+
raise InputError(f"Input file cannot be read due to permissions: {path}") from exc
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
raise InputError(f"Input file could not be read: {path}") from exc
|
|
117
|
+
|
|
118
|
+
if not text.strip():
|
|
119
|
+
raise InputError("Input file is empty.")
|
|
120
|
+
|
|
121
|
+
return text.splitlines()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_visual_separator(line: str) -> bool:
|
|
125
|
+
stripped = line.strip()
|
|
126
|
+
return bool(stripped) and all(char in {"│", " "} for char in line)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def normalize_node_name(raw_name: str, line_number: int) -> tuple[str, bool]:
|
|
130
|
+
name = raw_name.strip()
|
|
131
|
+
if not name:
|
|
132
|
+
raise InputError(f"Line {line_number}: entry name is empty.")
|
|
133
|
+
|
|
134
|
+
is_directory = name.endswith("/")
|
|
135
|
+
clean_name = name.rstrip("/") if is_directory else name
|
|
136
|
+
validate_node_name(clean_name, line_number)
|
|
137
|
+
return clean_name, is_directory
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_node_name(name: str, line_number: int) -> None:
|
|
141
|
+
if not name:
|
|
142
|
+
raise InputError(f"Line {line_number}: entry name is empty.")
|
|
143
|
+
if name in {".", ".."}:
|
|
144
|
+
raise InputError(f"Line {line_number}: unsafe path name is not allowed: {name}")
|
|
145
|
+
if Path(name).is_absolute() or WINDOWS_ABSOLUTE_RE.match(name):
|
|
146
|
+
raise InputError(f"Line {line_number}: absolute paths are not allowed: {name}")
|
|
147
|
+
if "/" in name or "\\" in name:
|
|
148
|
+
raise InputError(f"Line {line_number}: path separators are not allowed inside an entry name: {name}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def indentation_depth(prefix: str, line_number: int) -> int:
|
|
152
|
+
if len(prefix) % 4 != 0:
|
|
153
|
+
raise InputError(
|
|
154
|
+
f"Invalid tree structure at line {line_number}. "
|
|
155
|
+
"Indentation must use four-character units."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for index in range(0, len(prefix), 4):
|
|
159
|
+
unit = prefix[index : index + 4]
|
|
160
|
+
if unit not in {"│ ", " "}:
|
|
161
|
+
raise InputError(
|
|
162
|
+
f"Invalid tree structure at line {line_number}. "
|
|
163
|
+
"Indentation units must be either '│ ' or four spaces."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return len(prefix) // 4
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_tree(lines: list[str]) -> TreeNode:
|
|
170
|
+
root: TreeNode | None = None
|
|
171
|
+
stack: list[tuple[int, TreeNode]] = []
|
|
172
|
+
previous_depth = 0
|
|
173
|
+
|
|
174
|
+
for line_number, raw_line in enumerate(lines, start=1):
|
|
175
|
+
line = raw_line.rstrip()
|
|
176
|
+
if not line.strip() or is_visual_separator(line):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
match = BRANCH_RE.match(line)
|
|
180
|
+
|
|
181
|
+
if root is None:
|
|
182
|
+
if match:
|
|
183
|
+
raise InputError("The first meaningful line must be the root directory without a branch marker.")
|
|
184
|
+
|
|
185
|
+
name, is_directory = normalize_node_name(line, line_number)
|
|
186
|
+
if not is_directory:
|
|
187
|
+
raise InputError(f"Line {line_number}: root entry must be a directory ending in '/'.")
|
|
188
|
+
|
|
189
|
+
root = TreeNode(name=name, is_directory=True, line_number=line_number)
|
|
190
|
+
stack = [(0, root)]
|
|
191
|
+
previous_depth = 0
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if not match:
|
|
195
|
+
raise InputError(f"Line {line_number}: invalid tree connector syntax.")
|
|
196
|
+
|
|
197
|
+
depth = indentation_depth(match.group("prefix"), line_number) + 1
|
|
198
|
+
name, is_directory = normalize_node_name(match.group("name"), line_number)
|
|
199
|
+
|
|
200
|
+
if depth > previous_depth + 1:
|
|
201
|
+
raise InputError(
|
|
202
|
+
f"Invalid tree structure at line {line_number}.\n"
|
|
203
|
+
f"Indentation jumped from level {previous_depth} to level {depth}."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
while stack and stack[-1][0] >= depth:
|
|
207
|
+
stack.pop()
|
|
208
|
+
|
|
209
|
+
if not stack or stack[-1][0] != depth - 1:
|
|
210
|
+
raise InputError(f"Line {line_number}: entry cannot be attached to a valid parent.")
|
|
211
|
+
|
|
212
|
+
parent = stack[-1][1]
|
|
213
|
+
if not parent.is_directory:
|
|
214
|
+
raise InputError(
|
|
215
|
+
"A file cannot contain child entries.\n"
|
|
216
|
+
f"Line {line_number}: {name}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
node = TreeNode(name=name, is_directory=is_directory, line_number=line_number)
|
|
220
|
+
parent.children.append(node)
|
|
221
|
+
stack.append((depth, node))
|
|
222
|
+
previous_depth = depth
|
|
223
|
+
|
|
224
|
+
if root is None:
|
|
225
|
+
raise InputError("Tree contains no valid root entry.")
|
|
226
|
+
|
|
227
|
+
return root
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def iter_nodes(node: TreeNode, base: Path) -> list[tuple[TreeNode, Path]]:
|
|
231
|
+
current_path = base / node.name
|
|
232
|
+
entries = [(node, current_path)]
|
|
233
|
+
for child in node.children:
|
|
234
|
+
entries.extend(iter_nodes(child, current_path))
|
|
235
|
+
return entries
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ensure_inside_output(path: Path, output_root: Path) -> None:
|
|
239
|
+
try:
|
|
240
|
+
path.resolve().relative_to(output_root.resolve())
|
|
241
|
+
except ValueError as exc:
|
|
242
|
+
raise InputError(f"Generated path escapes output directory: {path}") from exc
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def create_scaffold(
|
|
246
|
+
root: TreeNode,
|
|
247
|
+
output_dir: Path,
|
|
248
|
+
*,
|
|
249
|
+
dry_run: bool = False,
|
|
250
|
+
force: bool = False,
|
|
251
|
+
verbose: bool = False,
|
|
252
|
+
) -> tuple[Path, ScaffoldStats]:
|
|
253
|
+
stats = ScaffoldStats()
|
|
254
|
+
output_root = output_dir.expanduser()
|
|
255
|
+
project_root = output_root / root.name
|
|
256
|
+
|
|
257
|
+
if force and not dry_run:
|
|
258
|
+
print("Warning: --force is enabled. Existing files may be truncated.", file=sys.stderr)
|
|
259
|
+
|
|
260
|
+
for node, target_path in iter_nodes(root, output_root):
|
|
261
|
+
ensure_inside_output(target_path, output_root)
|
|
262
|
+
exists = target_path.exists()
|
|
263
|
+
|
|
264
|
+
if node.is_directory:
|
|
265
|
+
handle_directory(target_path, exists, dry_run, verbose, stats)
|
|
266
|
+
else:
|
|
267
|
+
handle_file(target_path, exists, dry_run, force, verbose, stats)
|
|
268
|
+
|
|
269
|
+
return project_root, stats
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def handle_directory(
|
|
273
|
+
target_path: Path,
|
|
274
|
+
exists: bool,
|
|
275
|
+
dry_run: bool,
|
|
276
|
+
verbose: bool,
|
|
277
|
+
stats: ScaffoldStats,
|
|
278
|
+
) -> None:
|
|
279
|
+
display_path = format_dir(target_path)
|
|
280
|
+
|
|
281
|
+
if exists:
|
|
282
|
+
if not target_path.is_dir():
|
|
283
|
+
raise DirForgeError(f"Cannot create directory '{target_path}' because a file exists at that path.")
|
|
284
|
+
stats.existing_entries += 1 if dry_run else 0
|
|
285
|
+
stats.entries_skipped += 0 if dry_run else 1
|
|
286
|
+
if verbose or dry_run:
|
|
287
|
+
print(("EXIST DIR " if dry_run else "SKIP DIR ") + display_path)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if dry_run:
|
|
291
|
+
stats.dirs_to_create += 1
|
|
292
|
+
print("[DIR] " + display_path)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
raise DirForgeError(f"Cannot create directory '{target_path}': {exc}") from exc
|
|
299
|
+
|
|
300
|
+
stats.dirs_created += 1
|
|
301
|
+
if verbose:
|
|
302
|
+
print("CREATE DIR " + display_path)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def handle_file(
|
|
306
|
+
target_path: Path,
|
|
307
|
+
exists: bool,
|
|
308
|
+
dry_run: bool,
|
|
309
|
+
force: bool,
|
|
310
|
+
verbose: bool,
|
|
311
|
+
stats: ScaffoldStats,
|
|
312
|
+
) -> None:
|
|
313
|
+
if exists:
|
|
314
|
+
if target_path.is_dir():
|
|
315
|
+
raise DirForgeError(f"Cannot create file '{target_path}' because a directory exists at that path.")
|
|
316
|
+
if dry_run:
|
|
317
|
+
stats.existing_entries += 1
|
|
318
|
+
print("EXIST FILE " + str(target_path))
|
|
319
|
+
return
|
|
320
|
+
if force:
|
|
321
|
+
try:
|
|
322
|
+
target_path.write_text("", encoding="utf-8")
|
|
323
|
+
except OSError as exc:
|
|
324
|
+
raise DirForgeError(f"Cannot overwrite file '{target_path}': {exc}") from exc
|
|
325
|
+
stats.files_overwritten += 1
|
|
326
|
+
if verbose:
|
|
327
|
+
print("OVERWRITE FILE " + str(target_path))
|
|
328
|
+
return
|
|
329
|
+
stats.entries_skipped += 1
|
|
330
|
+
if verbose:
|
|
331
|
+
print("SKIP FILE " + str(target_path))
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if dry_run:
|
|
335
|
+
stats.files_to_create += 1
|
|
336
|
+
print("[FILE] " + str(target_path))
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
target_path.touch(exist_ok=False)
|
|
342
|
+
except OSError as exc:
|
|
343
|
+
raise DirForgeError(f"Cannot create file '{target_path}': {exc}") from exc
|
|
344
|
+
|
|
345
|
+
stats.files_created += 1
|
|
346
|
+
if verbose:
|
|
347
|
+
print("CREATE FILE " + str(target_path))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def format_dir(path: Path) -> str:
|
|
351
|
+
return str(path) + "/"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def print_summary(project_root: Path, stats: ScaffoldStats, *, dry_run: bool, force: bool) -> None:
|
|
355
|
+
if dry_run:
|
|
356
|
+
print("\nDry run completed successfully.\n")
|
|
357
|
+
print("Root:")
|
|
358
|
+
print(f" {project_root.resolve()}")
|
|
359
|
+
print()
|
|
360
|
+
print(f"Directories to create: {stats.dirs_to_create}")
|
|
361
|
+
print(f"Files to create: {stats.files_to_create}")
|
|
362
|
+
print(f"Existing entries: {stats.existing_entries}")
|
|
363
|
+
print("\nNo filesystem changes were made.")
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
print("\nScaffold created successfully.\n")
|
|
367
|
+
print("Root:")
|
|
368
|
+
print(f" {project_root.resolve()}")
|
|
369
|
+
print()
|
|
370
|
+
print(f"Directories created: {stats.dirs_created}")
|
|
371
|
+
print(f"Files created: {stats.files_created}")
|
|
372
|
+
print(f"Entries skipped: {stats.entries_skipped}")
|
|
373
|
+
if force:
|
|
374
|
+
print(f"Files overwritten: {stats.files_overwritten}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def main(argv: list[str] | None = None) -> int:
|
|
378
|
+
try:
|
|
379
|
+
args = parse_arguments(argv)
|
|
380
|
+
lines = read_tree_file(args.tree_file)
|
|
381
|
+
tree = parse_tree(lines)
|
|
382
|
+
project_root, stats = create_scaffold(
|
|
383
|
+
tree,
|
|
384
|
+
Path(args.output),
|
|
385
|
+
dry_run=args.dry_run,
|
|
386
|
+
force=args.force,
|
|
387
|
+
verbose=args.verbose,
|
|
388
|
+
)
|
|
389
|
+
print_summary(project_root, stats, dry_run=args.dry_run, force=args.force)
|
|
390
|
+
return 0
|
|
391
|
+
except DirForgeError as exc:
|
|
392
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
393
|
+
return exc.exit_code
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == "__main__":
|
|
397
|
+
sys.exit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirforge-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create project directory scaffolds from visual tree text files.
|
|
5
|
+
Keywords: cli,scaffold,project-tree,directories,filesystem
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
17
|
+
Classifier: Topic :: System :: Filesystems
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
23
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# DirForge
|
|
26
|
+
|
|
27
|
+
DirForge is a dependency-free Python CLI that turns a visual project tree in a
|
|
28
|
+
UTF-8 text file into real directories and empty files.
|
|
29
|
+
|
|
30
|
+
> PyPI package name: `dirforge-cli`
|
|
31
|
+
> Installed command: `dirforge`
|
|
32
|
+
|
|
33
|
+
The distribution uses `dirforge-cli` because `dirforge` is already registered
|
|
34
|
+
to another project on PyPI.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install dirforge-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
DirForge requires Python 3.10 or newer and has no runtime dependencies.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Create a text file such as `structure.txt`:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
project/
|
|
50
|
+
├── src/
|
|
51
|
+
│ └── app.py
|
|
52
|
+
├── LICENSE
|
|
53
|
+
├── Makefile
|
|
54
|
+
└── .gitignore
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then create the scaffold:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
dirforge structure.txt
|
|
61
|
+
dirforge structure.txt --output ./projects
|
|
62
|
+
dirforge structure.txt -o ./projects --dry-run --verbose
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The repository script remains directly executable too:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python dirforge.py example-structure.txt --dry-run
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tree Syntax
|
|
72
|
+
|
|
73
|
+
Entries ending in `/` are directories. Every other entry is a file, including
|
|
74
|
+
extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
|
|
75
|
+
separator lines containing only `│` are ignored.
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
- `-o, --output`: Choose the directory that will contain the scaffold root.
|
|
80
|
+
- `--dry-run`: Show planned changes without modifying the filesystem.
|
|
81
|
+
- `--force`: Truncate existing files before recreating them.
|
|
82
|
+
- `-v, --verbose`: Print every create, skip, or overwrite operation.
|
|
83
|
+
- `--version`: Print the installed version.
|
|
84
|
+
|
|
85
|
+
Existing directories are reused and existing files are preserved by default.
|
|
86
|
+
DirForge rejects path traversal, absolute paths, separators inside node names,
|
|
87
|
+
malformed indentation, children under files, and file/directory conflicts.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
Run the test suite with only the standard library:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m unittest discover -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Install the packaging tools and build both distribution formats:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python -m pip install -e '.[dev]'
|
|
101
|
+
python -m build
|
|
102
|
+
python -m twine check dist/*
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Test the release on TestPyPI before publishing it publicly:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m twine upload --repository testpypi dist/*
|
|
109
|
+
python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
After verifying the installed `dirforge` command, upload the same artifacts to
|
|
113
|
+
PyPI:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python -m twine upload dist/*
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
PyPI does not allow replacing an uploaded version. Increment `__version__` in
|
|
120
|
+
`dirforge.py` before each new release; the build metadata reads it automatically.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
dirforge.py
|
|
4
|
+
example-structure.txt
|
|
5
|
+
pyproject.toml
|
|
6
|
+
dirforge_cli.egg-info/PKG-INFO
|
|
7
|
+
dirforge_cli.egg-info/SOURCES.txt
|
|
8
|
+
dirforge_cli.egg-info/dependency_links.txt
|
|
9
|
+
dirforge_cli.egg-info/entry_points.txt
|
|
10
|
+
dirforge_cli.egg-info/requires.txt
|
|
11
|
+
dirforge_cli.egg-info/top_level.txt
|
|
12
|
+
tests/__init__.py
|
|
13
|
+
tests/test_dirforge.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dirforge
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
meta-ads-analytics/
|
|
2
|
+
│
|
|
3
|
+
├── composer.json
|
|
4
|
+
├── README.md
|
|
5
|
+
├── LICENSE
|
|
6
|
+
├── .gitignore
|
|
7
|
+
│
|
|
8
|
+
├── src/
|
|
9
|
+
│ ├── MetaAds.php
|
|
10
|
+
│ │
|
|
11
|
+
│ ├── Config/
|
|
12
|
+
│ │ ├── MetaConfig.php
|
|
13
|
+
│ │ └── ConfigurationValidator.php
|
|
14
|
+
│ │
|
|
15
|
+
│ └── Client/
|
|
16
|
+
│ ├── MetaClient.php
|
|
17
|
+
│ └── MetaClientInterface.php
|
|
18
|
+
│
|
|
19
|
+
├── tests/
|
|
20
|
+
│ ├── Unit/
|
|
21
|
+
│ └── bootstrap.php
|
|
22
|
+
│
|
|
23
|
+
└── docs/
|
|
24
|
+
├── configuration.md
|
|
25
|
+
└── architecture.md
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dirforge-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Create project directory scaffolds from visual tree text files."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = ["cli", "scaffold", "project-tree", "directories", "filesystem"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Code Generators",
|
|
24
|
+
"Topic :: System :: Filesystems",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"build>=1.2",
|
|
32
|
+
"twine>=5",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
dirforge = "dirforge:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
py-modules = ["dirforge"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.dynamic]
|
|
42
|
+
version = {attr = "dirforge.__version__"}
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.data-files]
|
|
45
|
+
"share/doc/dirforge-cli" = ["example-structure.txt"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DirForge test suite."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import dirforge
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
VALID_TREE = """\
|
|
13
|
+
sample-project/
|
|
14
|
+
├── README.md
|
|
15
|
+
├── src/
|
|
16
|
+
│ └── main.py
|
|
17
|
+
└── LICENSE
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ParseTreeTests(unittest.TestCase):
|
|
22
|
+
def test_parses_directories_and_extensionless_files(self) -> None:
|
|
23
|
+
root = dirforge.parse_tree(VALID_TREE.splitlines())
|
|
24
|
+
|
|
25
|
+
self.assertEqual(root.name, "sample-project")
|
|
26
|
+
self.assertTrue(root.is_directory)
|
|
27
|
+
self.assertEqual([child.name for child in root.children], ["README.md", "src", "LICENSE"])
|
|
28
|
+
self.assertFalse(root.children[-1].is_directory)
|
|
29
|
+
self.assertEqual(root.children[1].children[0].name, "main.py")
|
|
30
|
+
|
|
31
|
+
def test_rejects_path_traversal(self) -> None:
|
|
32
|
+
with self.assertRaisesRegex(dirforge.InputError, "unsafe path name"):
|
|
33
|
+
dirforge.parse_tree(["project/", "└── ../"])
|
|
34
|
+
|
|
35
|
+
def test_rejects_children_beneath_files(self) -> None:
|
|
36
|
+
tree = ["project/", "└── README.md", " └── child.txt"]
|
|
37
|
+
|
|
38
|
+
with self.assertRaisesRegex(dirforge.InputError, "file cannot contain"):
|
|
39
|
+
dirforge.parse_tree(tree)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ScaffoldTests(unittest.TestCase):
|
|
43
|
+
def test_creates_tree_and_preserves_existing_files(self) -> None:
|
|
44
|
+
root = dirforge.parse_tree(VALID_TREE.splitlines())
|
|
45
|
+
|
|
46
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
47
|
+
output = Path(temp_dir)
|
|
48
|
+
project_root, first_stats = dirforge.create_scaffold(root, output)
|
|
49
|
+
readme = project_root / "README.md"
|
|
50
|
+
readme.write_text("keep me", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
_, second_stats = dirforge.create_scaffold(root, output)
|
|
53
|
+
|
|
54
|
+
self.assertEqual(first_stats.dirs_created, 2)
|
|
55
|
+
self.assertEqual(first_stats.files_created, 3)
|
|
56
|
+
self.assertEqual(readme.read_text(encoding="utf-8"), "keep me")
|
|
57
|
+
self.assertEqual(second_stats.entries_skipped, 5)
|
|
58
|
+
|
|
59
|
+
def test_dry_run_does_not_write_to_disk(self) -> None:
|
|
60
|
+
root = dirforge.parse_tree(VALID_TREE.splitlines())
|
|
61
|
+
|
|
62
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
63
|
+
output = Path(temp_dir)
|
|
64
|
+
_, stats = dirforge.create_scaffold(root, output, dry_run=True)
|
|
65
|
+
|
|
66
|
+
self.assertFalse((output / "sample-project").exists())
|
|
67
|
+
self.assertEqual(stats.dirs_to_create, 2)
|
|
68
|
+
self.assertEqual(stats.files_to_create, 3)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CommandLineTests(unittest.TestCase):
|
|
72
|
+
def test_version_flag(self) -> None:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
[sys.executable, str(Path(dirforge.__file__)), "--version"],
|
|
75
|
+
check=False,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self.assertEqual(result.returncode, 0)
|
|
81
|
+
self.assertIn(dirforge.__version__, result.stdout)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
unittest.main()
|