fsg 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.
fsg/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from fsg.trees import GitProjectTree, PlainProjectTree, ProjectTree
2
+
3
+ __all__ = ["GitProjectTree", "PlainProjectTree", "ProjectTree"]
fsg/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from fsg.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
fsg/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ import argparse
2
+ import io
3
+ import subprocess
4
+ import sys
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+
8
+ from fsg.config import Config
9
+ from fsg.hooks import GitHook, Hook
10
+ from fsg.readme_section import ReadmeSection
11
+ from fsg.trees import ProjectTree
12
+
13
+
14
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
15
+ """Parses the command line arguments and returns the resulting namespace.
16
+
17
+ Boolean options are None when not given.
18
+ Exits with status 2 when --depth is smaller than 1.
19
+
20
+ Args:
21
+ argv: Argument list to parse instead of the command line arguments.
22
+ """
23
+
24
+ parser = argparse.ArgumentParser(
25
+ prog="fsg",
26
+ description="Generates clean directory trees that respect .gitignore and keep your docs up to date.",
27
+ epilog=(
28
+ "With --update or --check, the path and options not given on the command line are read from "
29
+ ".fsg.toml or [tool.fsg] in pyproject.toml, searched from the directory of FILE upwards."
30
+ ),
31
+ )
32
+ parser.add_argument(
33
+ "path",
34
+ nargs="?",
35
+ type=Path,
36
+ help="project directory to render (default: the configured path or the current directory)",
37
+ )
38
+ parser.add_argument(
39
+ "-d",
40
+ "--depth",
41
+ type=int,
42
+ metavar="N",
43
+ help="render only the first N levels below the root",
44
+ )
45
+ parser.add_argument(
46
+ "--dirs-only",
47
+ action=argparse.BooleanOptionalAction,
48
+ help="render directories only",
49
+ )
50
+ parser.add_argument(
51
+ "-e",
52
+ "--exclude",
53
+ action="append",
54
+ default=[],
55
+ metavar="PATTERN",
56
+ help="leave out entries whose name or relative path matches the glob pattern (repeatable)",
57
+ )
58
+ parser.add_argument(
59
+ "--fence",
60
+ action=argparse.BooleanOptionalAction,
61
+ help="wrap the tree in a Markdown code fence (default: on)",
62
+ )
63
+ parser.add_argument(
64
+ "--ascii",
65
+ action=argparse.BooleanOptionalAction,
66
+ dest="use_ascii",
67
+ help="draw the tree with ASCII characters instead of box-drawing characters",
68
+ )
69
+ mode = parser.add_mutually_exclusive_group()
70
+ mode.add_argument(
71
+ "-u",
72
+ "--update",
73
+ type=Path,
74
+ metavar="FILE",
75
+ help="write the tree between the <!-- fsg:start --> and <!-- fsg:end --> markers of FILE",
76
+ )
77
+ mode.add_argument(
78
+ "-c",
79
+ "--check",
80
+ type=Path,
81
+ metavar="FILE",
82
+ help="exit with status 1 when the tree between the markers of FILE is out of date",
83
+ )
84
+ mode.add_argument(
85
+ "--setup-hook",
86
+ type=Path,
87
+ metavar="FILE",
88
+ help="add 'fsg --update FILE' to .pre-commit-config.yaml if present, otherwise to the git pre-commit hook",
89
+ )
90
+ parser.add_argument(
91
+ "-V",
92
+ "--version",
93
+ action="version",
94
+ version=f"%(prog)s {version('fsg')}",
95
+ )
96
+ args = parser.parse_args(argv)
97
+ if args.depth is not None and args.depth < 1:
98
+ parser.error("--depth must be at least 1")
99
+ return args
100
+
101
+
102
+ def main(argv: list[str] | None = None) -> int:
103
+ """Runs the mode selected by the arguments and returns the exit status.
104
+
105
+ Without a mode the tree is rendered from the arguments alone and written UTF-8 encoded to stdout.
106
+ With --update or --check the config found from the directory of the target file fills in the path
107
+ and every option not given. Returns 0 on success, 1 when the path is not a directory or --check finds
108
+ an outdated tree, and 2 when a file cannot be read, lacks the markers, the config is invalid or the
109
+ hook cannot be set up.
110
+
111
+ Args:
112
+ argv: Argument list to use instead of the command line arguments.
113
+ """
114
+
115
+ args = parse_args(argv)
116
+ for stream in (sys.stdout, sys.stderr):
117
+ if isinstance(stream, io.TextIOWrapper):
118
+ stream.reconfigure(encoding="utf-8")
119
+ if args.setup_hook:
120
+ return _setup_hook(args.setup_hook)
121
+ target = args.update or args.check
122
+ config = Config()
123
+ if target is not None:
124
+ try:
125
+ config = Config.find(target.resolve().parent)
126
+ except (OSError, ValueError) as error:
127
+ return _fail(str(error), 2)
128
+ root = args.path or config.path or Path(".")
129
+ if not root.is_dir():
130
+ return _fail(f"{root}: not a directory", 1)
131
+ output = ProjectTree.for_path(root, **config.merge(args)).render()
132
+ if target is None:
133
+ print(output)
134
+ return 0
135
+ return _sync(target, output, check=bool(args.check))
136
+
137
+
138
+ def _sync(target: Path, output: str, check: bool) -> int:
139
+ """Compares or writes the tree in the marked section of the target and prints one status line.
140
+
141
+ Args:
142
+ target: File holding the markers.
143
+ output: Rendered tree.
144
+ check: Only compares when True, otherwise writes the tree.
145
+ """
146
+
147
+ try:
148
+ section = ReadmeSection(target)
149
+ except FileNotFoundError:
150
+ return _fail(f"{target}: file not found", 2)
151
+ except UnicodeDecodeError:
152
+ return _fail(f"{target}: not a UTF-8 text file", 2)
153
+ except (OSError, ValueError) as error:
154
+ return _fail(f"{target}: {getattr(error, 'strerror', None) or error}", 2)
155
+ if check:
156
+ if section.is_current(output):
157
+ print(f"{target}: tree is up to date")
158
+ return 0
159
+ print(f"{target}: tree is out of date, run fsg --update {target}", file=sys.stderr)
160
+ return 1
161
+ if section.update(output):
162
+ print(f"{target}: tree updated")
163
+ else:
164
+ print(f"{target}: tree already up to date")
165
+ return 0
166
+
167
+
168
+ def _setup_hook(target: Path) -> int:
169
+ """Adds the update step for the target to the hooks of the repository around the current directory and prints the result.
170
+
171
+ Args:
172
+ target: File holding the markers, relative to the current directory.
173
+ """
174
+
175
+ repository = Hook.repository_root(Path.cwd())
176
+ if repository is None:
177
+ return _fail("not inside a git repository", 2)
178
+ try:
179
+ relative = target.resolve().relative_to(repository.resolve()).as_posix()
180
+ except ValueError:
181
+ return _fail(f"{target}: not inside the repository {repository}", 2)
182
+ hook = Hook.for_repository(repository, relative)
183
+ try:
184
+ location = hook.location()
185
+ added = hook.install()
186
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
187
+ return _fail(str(error), 2)
188
+ root = repository.resolve()
189
+ location = location.resolve()
190
+ shown = (location.relative_to(root) if location.is_relative_to(root) else location).as_posix()
191
+ if not added:
192
+ print(f"{shown}: fsg hook already present")
193
+ elif isinstance(hook, GitHook):
194
+ print(f"{shown}: fsg hook added")
195
+ else:
196
+ print(f"{shown}: fsg hook added, run 'pre-commit install' if not done yet")
197
+ return 0
198
+
199
+
200
+ def _fail(message: str, status: int) -> int:
201
+ """Prints the message prefixed with fsg: to stderr and returns the status.
202
+
203
+ Args:
204
+ message: Error description.
205
+ status: Exit status to return.
206
+ """
207
+
208
+ print(f"fsg: {message}", file=sys.stderr)
209
+ return status
fsg/config.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ CONFIG_FILE = ".fsg.toml"
9
+ PYPROJECT_FILE = "pyproject.toml"
10
+
11
+
12
+ class Config:
13
+ """Holds the render options read from a .fsg.toml file or the [tool.fsg] table of a pyproject.toml."""
14
+
15
+ def __init__(self, values: dict[str, Any] | None = None, source: Path | None = None) -> None:
16
+ """Validates and stores the option values together with the file they were read from.
17
+
18
+ Args:
19
+ values: Option table with the keys path, depth, dirs-only, exclude, fence and ascii.
20
+ source: File the values were read from, or None when no config file was found.
21
+
22
+ Raises:
23
+ ValueError: A key is unknown or a value has the wrong type.
24
+ """
25
+
26
+ self.source = source
27
+ self.path: Path | None = None
28
+ self.depth: int | None = None
29
+ self.dirs_only: bool | None = None
30
+ self.exclude: list[str] = []
31
+ self.fence: bool | None = None
32
+ self.use_ascii: bool | None = None
33
+ for key, value in (values or {}).items():
34
+ self._set(key, value)
35
+
36
+ @classmethod
37
+ def find(cls, start: Path) -> Config:
38
+ """Searches the start directory and its parents for a .fsg.toml or a pyproject.toml with a [tool.fsg] table.
39
+
40
+ The first directory containing either file wins; inside one directory .fsg.toml takes precedence.
41
+ Returns an empty Config when nothing is found.
42
+
43
+ Args:
44
+ start: Directory the search begins in.
45
+
46
+ Raises:
47
+ ValueError: A config file is not valid TOML or holds invalid options.
48
+ """
49
+
50
+ start = Path(start).resolve()
51
+ for directory in (start, *start.parents):
52
+ config_file = directory / CONFIG_FILE
53
+ if config_file.is_file():
54
+ return cls(cls._load(config_file), config_file)
55
+ pyproject = directory / PYPROJECT_FILE
56
+ if pyproject.is_file():
57
+ table = cls._load(pyproject).get("tool", {}).get("fsg")
58
+ if table is not None:
59
+ return cls(table, pyproject)
60
+ return cls()
61
+
62
+ def merge(self, args: argparse.Namespace) -> dict[str, Any]:
63
+ """Returns the ProjectTree options, taking each value from the arguments when given, else from the config, else the default.
64
+
65
+ Exclude patterns from the config and the arguments are combined.
66
+
67
+ Args:
68
+ args: Parsed command line arguments holding depth, dirs_only, exclude, fence and use_ascii.
69
+ """
70
+
71
+ return {
72
+ "depth": args.depth if args.depth is not None else self.depth,
73
+ "dirs_only": self._pick(args.dirs_only, self.dirs_only, False),
74
+ "exclude": [*self.exclude, *args.exclude],
75
+ "fence": self._pick(args.fence, self.fence, True),
76
+ "use_ascii": self._pick(args.use_ascii, self.use_ascii, False),
77
+ }
78
+
79
+ @staticmethod
80
+ def _pick(argument: bool | None, configured: bool | None, default: bool) -> bool:
81
+ """Returns the argument value when set, otherwise the configured value when set, otherwise the default.
82
+
83
+ Args:
84
+ argument: Value from the command line, or None.
85
+ configured: Value from the config file, or None.
86
+ default: Value used when neither is set.
87
+ """
88
+
89
+ if argument is not None:
90
+ return argument
91
+ if configured is not None:
92
+ return configured
93
+ return default
94
+
95
+ @staticmethod
96
+ def _load(path: Path) -> dict[str, Any]:
97
+ """Parses a TOML file and returns its top-level table.
98
+
99
+ Args:
100
+ path: File to parse.
101
+
102
+ Raises:
103
+ ValueError: The file is not valid TOML.
104
+ """
105
+
106
+ try:
107
+ with path.open("rb") as file:
108
+ return tomllib.load(file)
109
+ except tomllib.TOMLDecodeError as error:
110
+ raise ValueError(f"{path}: invalid TOML: {error}") from None
111
+
112
+ def _set(self, key: str, value: Any) -> None:
113
+ """Checks the type of one option value and stores it on the matching attribute.
114
+
115
+ A path is resolved against the directory of the config file.
116
+
117
+ Args:
118
+ key: Option name as written in the config file.
119
+ value: Parsed TOML value.
120
+
121
+ Raises:
122
+ ValueError: The key is unknown or the value has the wrong type.
123
+ """
124
+
125
+ if key == "path":
126
+ if not isinstance(value, str):
127
+ raise ValueError(f"{self.source}: path must be a string")
128
+ self.path = self.source.parent / value if self.source else Path(value)
129
+ elif key == "depth":
130
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
131
+ raise ValueError(f"{self.source}: depth must be an integer of at least 1")
132
+ self.depth = value
133
+ elif key in ("dirs-only", "fence", "ascii"):
134
+ if not isinstance(value, bool):
135
+ raise ValueError(f"{self.source}: {key} must be true or false")
136
+ setattr(self, {"dirs-only": "dirs_only", "ascii": "use_ascii"}.get(key, key), value)
137
+ elif key == "exclude":
138
+ if not isinstance(value, list) or not all(isinstance(pattern, str) for pattern in value):
139
+ raise ValueError(f"{self.source}: exclude must be a list of strings")
140
+ self.exclude = list(value)
141
+ else:
142
+ raise ValueError(f"{self.source}: unknown option '{key}'")
fsg/constants.py ADDED
@@ -0,0 +1,13 @@
1
+ UNICODE_GLYPHS = {"branch": "├── ", "last": "└── ", "pipe": "│ ", "space": " "}
2
+ ASCII_GLYPHS = {"branch": "|-- ", "last": "`-- ", "pipe": "| ", "space": " "}
3
+
4
+ IGNORED = {
5
+ ".git", ".svn", ".hg",
6
+ ".idea", ".vscode",
7
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
8
+ ".venv", "venv", "env",
9
+ "node_modules", "target", "dist", "build", "out",
10
+ ".godot", ".import",
11
+ ".DS_Store", "Thumbs.db",
12
+ "uv.lock",
13
+ }
fsg/hooks/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from fsg.hooks.git_hook import GitHook
2
+ from fsg.hooks.hook import Hook
3
+ from fsg.hooks.pre_commit_hook import PreCommitHook
4
+
5
+ __all__ = ["GitHook", "Hook", "PreCommitHook"]
fsg/hooks/git_hook.py ADDED
@@ -0,0 +1,69 @@
1
+ import shlex
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from fsg.hooks.hook import Hook
6
+
7
+ MARKER = "# fsg: keep the directory tree up to date"
8
+
9
+
10
+ class GitHook(Hook):
11
+ """Writes the fsg step into the native pre-commit hook script of the repository."""
12
+
13
+ def location(self) -> Path:
14
+ """Returns the pre-commit script inside the hooks directory git reports, honouring core.hooksPath."""
15
+
16
+ result = subprocess.run(
17
+ ["git", "rev-parse", "--git-path", "hooks"],
18
+ cwd=self.repository,
19
+ capture_output=True,
20
+ text=True,
21
+ encoding="utf-8",
22
+ check=True,
23
+ )
24
+ hooks = Path(result.stdout.strip())
25
+ if not hooks.is_absolute():
26
+ hooks = self.repository / hooks
27
+ return hooks / "pre-commit"
28
+
29
+ def install(self) -> bool:
30
+ """Creates the executable script or adds the fsg step to the existing one.
31
+
32
+ In an existing script the step goes before a final exit line, otherwise at the end.
33
+ Returns False without writing when the script already contains the fsg step.
34
+ """
35
+
36
+ path = self.location()
37
+ if not path.exists():
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text("#!/bin/sh\n" + self._step(), encoding="utf-8", newline="\n")
40
+ path.chmod(0o755)
41
+ return True
42
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
43
+ if any(line.strip() == MARKER for line in lines):
44
+ return False
45
+ position = len(lines)
46
+ content = [index for index, line in enumerate(lines) if line.strip() and not line.lstrip().startswith("#")]
47
+ if content and lines[content[-1]].split()[0] == "exit":
48
+ position = content[-1]
49
+ if lines and not lines[-1].endswith("\n"):
50
+ lines[-1] += "\n"
51
+ lines.insert(position, self._step())
52
+ path.write_text("".join(lines), encoding="utf-8", newline="\n")
53
+ return True
54
+
55
+ def _step(self) -> str:
56
+ """Returns the shell block that updates the target and aborts the commit when the tree was outdated, or shows the error when the check fails otherwise."""
57
+
58
+ target = shlex.quote(self.target)
59
+ return (
60
+ f"{MARKER}\n"
61
+ "if command -v fsg >/dev/null 2>&1; then\n"
62
+ f" fsg --check {target} >/dev/null 2>&1\n"
63
+ " case $? in\n"
64
+ " 0) ;;\n"
65
+ f" 1) fsg --update {target}; echo \"fsg: add {self.target} and commit again\"; exit 1 ;;\n"
66
+ f" *) fsg --check {target}; exit 1 ;;\n"
67
+ " esac\n"
68
+ "fi\n"
69
+ )
fsg/hooks/hook.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ PRE_COMMIT_CONFIG = ".pre-commit-config.yaml"
7
+
8
+
9
+ class Hook:
10
+ """Adds the fsg update step for one target file to the commit hooks of a git repository."""
11
+
12
+ def __init__(self, repository: Path, target: str) -> None:
13
+ """Stores the repository root and the target file path relative to it.
14
+
15
+ Args:
16
+ repository: Root directory of the git working tree.
17
+ target: Path of the file holding the markers, relative to the repository root, using forward slashes.
18
+ """
19
+
20
+ self.repository = Path(repository)
21
+ self.target = target
22
+
23
+ @staticmethod
24
+ def repository_root(start: Path) -> Path | None:
25
+ """Returns the root of the git working tree containing the start directory, or None outside a repository.
26
+
27
+ Args:
28
+ start: Directory to start from.
29
+ """
30
+
31
+ try:
32
+ result = subprocess.run(
33
+ ["git", "rev-parse", "--show-toplevel"],
34
+ cwd=start,
35
+ capture_output=True,
36
+ text=True,
37
+ encoding="utf-8",
38
+ check=False,
39
+ )
40
+ except (FileNotFoundError, NotADirectoryError):
41
+ return None
42
+ if result.returncode != 0:
43
+ return None
44
+ return Path(result.stdout.strip())
45
+
46
+ @classmethod
47
+ def for_repository(cls, repository: Path, target: str) -> Hook:
48
+ """Returns a PreCommitHook when the repository root holds a .pre-commit-config.yaml, otherwise a GitHook.
49
+
50
+ Args:
51
+ repository: Root directory of the git working tree.
52
+ target: Path of the file holding the markers, relative to the repository root, using forward slashes.
53
+ """
54
+
55
+ from fsg.hooks.git_hook import GitHook
56
+ from fsg.hooks.pre_commit_hook import PreCommitHook
57
+
58
+ if (Path(repository) / PRE_COMMIT_CONFIG).is_file():
59
+ return PreCommitHook(repository, target)
60
+ return GitHook(repository, target)
61
+
62
+ def location(self) -> Path:
63
+ """Returns the file the hook is written to."""
64
+
65
+ raise NotImplementedError
66
+
67
+ def install(self) -> bool:
68
+ """Adds the fsg step to the hook file and returns False when it is already present."""
69
+
70
+ raise NotImplementedError
@@ -0,0 +1,74 @@
1
+ import json
2
+ import re
3
+ from importlib.metadata import version
4
+ from pathlib import Path
5
+
6
+ from fsg.hooks.hook import PRE_COMMIT_CONFIG, Hook
7
+
8
+ REPOSITORY_URL = "https://github.com/KarnesTH/fsg"
9
+
10
+
11
+ class PreCommitHook(Hook):
12
+ """Appends the fsg repository entry to the repos list of an existing .pre-commit-config.yaml."""
13
+
14
+ def location(self) -> Path:
15
+ """Returns the .pre-commit-config.yaml in the repository root."""
16
+
17
+ return self.repository / PRE_COMMIT_CONFIG
18
+
19
+ def install(self) -> bool:
20
+ """Inserts the fsg entry after the last line of the repos block, keeping every other line unchanged.
21
+
22
+ The entry uses the indentation of the first existing list item and the line endings of the file.
23
+ Returns False without writing when the file already references the fsg repository.
24
+
25
+ Raises:
26
+ ValueError: The file has no block-style repos key at the top level.
27
+ """
28
+
29
+ path = self.location()
30
+ with path.open(encoding="utf-8", newline="") as file:
31
+ text = file.read()
32
+ if "karnesth/fsg" in text.lower():
33
+ return False
34
+ newline = "\r\n" if "\r\n" in text else "\n"
35
+ lines = text.splitlines(keepends=True)
36
+ start = next((index for index, line in enumerate(lines) if line.rstrip() == "repos:"), None)
37
+ if start is None:
38
+ raise ValueError(f"{path}: no 'repos:' block found, add the fsg hook manually")
39
+ last = start
40
+ indent = None
41
+ for index in range(start + 1, len(lines)):
42
+ line = lines[index]
43
+ stripped = line.strip()
44
+ if not stripped or stripped.startswith("#"):
45
+ continue
46
+ if not line[0].isspace() and not stripped.startswith("-"):
47
+ break
48
+ if indent is None and stripped.startswith("-"):
49
+ indent = line[: len(line) - len(line.lstrip())]
50
+ last = index
51
+ if not lines[last].endswith(("\n", "\r")):
52
+ lines[last] += newline
53
+ lines.insert(last + 1, self._entry(indent if indent is not None else " ", newline))
54
+ with path.open("w", encoding="utf-8", newline="") as file:
55
+ file.write("".join(lines))
56
+ return True
57
+
58
+ def _entry(self, indent: str, newline: str) -> str:
59
+ """Returns the repos list item for fsg, pinned to the installed version.
60
+
61
+ Args:
62
+ indent: Whitespace placed before the list dash.
63
+ newline: Line break sequence of the file.
64
+ """
65
+
66
+ target = self.target if re.fullmatch(r"[\w./-]+", self.target) else json.dumps(self.target)
67
+ lines = [
68
+ f"{indent}- repo: {REPOSITORY_URL}",
69
+ f"{indent} rev: v{version('fsg')}",
70
+ f"{indent} hooks:",
71
+ f"{indent} - id: fsg",
72
+ f"{indent} args: [--update, {target}]",
73
+ ]
74
+ return newline.join(lines) + newline
fsg/readme_section.py ADDED
@@ -0,0 +1,89 @@
1
+ from pathlib import Path
2
+
3
+ START_MARKER = "<!-- fsg:start -->"
4
+ END_MARKER = "<!-- fsg:end -->"
5
+
6
+
7
+ class ReadmeSection:
8
+ """Reads, compares and replaces the block between the fsg markers of a text file."""
9
+
10
+ def __init__(self, path: Path) -> None:
11
+ """Reads the file without newline translation and locates the block between the start and end marker.
12
+
13
+ The start marker counts only on a line of its own outside a fenced code block;
14
+ the end marker is the first one following it.
15
+
16
+ Args:
17
+ path: Path of the file holding the markers.
18
+
19
+ Raises:
20
+ FileNotFoundError: The file does not exist.
21
+ ValueError: A marker is missing or the end marker precedes the start marker.
22
+ """
23
+
24
+ self.path = Path(path)
25
+ with self.path.open(encoding="utf-8", newline="") as file:
26
+ self.text = file.read()
27
+ self.newline = "\r\n" if "\r\n" in self.text else "\n"
28
+ start = self._find_start()
29
+ end = self.text.find(END_MARKER, start + len(START_MARKER)) if start != -1 else -1
30
+ if start == -1 or end == -1:
31
+ raise ValueError(f"missing {START_MARKER} / {END_MARKER} markers")
32
+ self.inner_start = start + len(START_MARKER)
33
+ self.inner_end = end
34
+
35
+ def content(self) -> str:
36
+ """Returns the text between the markers."""
37
+
38
+ return self.text[self.inner_start:self.inner_end]
39
+
40
+ def is_current(self, tree: str) -> bool:
41
+ """Returns whether the block between the markers equals the tree on its own lines.
42
+
43
+ Args:
44
+ tree: Rendered tree, using \\n line breaks.
45
+ """
46
+
47
+ return self.content() == self._block(tree)
48
+
49
+ def update(self, tree: str) -> bool:
50
+ """Writes the tree between the markers when it differs, keeping the rest of the file, and returns whether the file was changed.
51
+
52
+ Args:
53
+ tree: Rendered tree, using \\n line breaks.
54
+ """
55
+
56
+ block = self._block(tree)
57
+ if self.content() == block:
58
+ return False
59
+ self.text = self.text[:self.inner_start] + block + self.text[self.inner_end:]
60
+ self.inner_end = self.inner_start + len(block)
61
+ with self.path.open("w", encoding="utf-8", newline="") as file:
62
+ file.write(self.text)
63
+ return True
64
+
65
+ def _find_start(self) -> int:
66
+ """Returns the offset of the first start marker standing alone on a line outside a fenced code block, or -1."""
67
+
68
+ offset = 0
69
+ fence = ""
70
+ for line in self.text.splitlines(keepends=True):
71
+ stripped = line.strip()
72
+ if fence:
73
+ if stripped.startswith(fence):
74
+ fence = ""
75
+ elif stripped.startswith(("```", "~~~")):
76
+ fence = stripped[:3]
77
+ elif stripped == START_MARKER:
78
+ return offset + line.index(START_MARKER)
79
+ offset += len(line)
80
+ return -1
81
+
82
+ def _block(self, tree: str) -> str:
83
+ """Returns the tree surrounded by line breaks, using the newline style of the file.
84
+
85
+ Args:
86
+ tree: Rendered tree, using \\n line breaks.
87
+ """
88
+
89
+ return self.newline + tree.replace("\n", self.newline) + self.newline
fsg/trees/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from fsg.trees.git_project_tree import GitProjectTree
2
+ from fsg.trees.plain_project_tree import PlainProjectTree
3
+ from fsg.trees.project_tree import ProjectTree
4
+
5
+ __all__ = ["GitProjectTree", "PlainProjectTree", "ProjectTree"]
@@ -0,0 +1,44 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from fsg.trees.project_tree import ProjectTree
5
+
6
+
7
+ class GitProjectTree(ProjectTree):
8
+ """Collects the paths git reports as tracked or untracked but not ignored."""
9
+
10
+ @staticmethod
11
+ def is_repository(root: Path) -> bool:
12
+ """Returns whether the given path lies inside the working tree of a git repository.
13
+
14
+ Args:
15
+ root: Path to check.
16
+ """
17
+
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", "rev-parse", "--is-inside-work-tree"],
21
+ cwd=root,
22
+ capture_output=True,
23
+ text=True,
24
+ encoding="utf-8",
25
+ check=False,
26
+ )
27
+ except (FileNotFoundError, NotADirectoryError):
28
+ return False
29
+ return result.returncode == 0 and result.stdout.strip() == "true"
30
+
31
+ def collect(self) -> list[list[str]]:
32
+ """Returns the paths listed by git ls-files, split into their path parts."""
33
+
34
+ result = subprocess.run(
35
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
36
+ cwd=self.root,
37
+ capture_output=True,
38
+ text=True,
39
+ encoding="utf-8",
40
+ check=False,
41
+ )
42
+ if result.returncode != 0:
43
+ return []
44
+ return [entry.split("/") for entry in result.stdout.split("\0") if entry]
@@ -0,0 +1,34 @@
1
+ from collections.abc import Iterator
2
+ from pathlib import Path
3
+
4
+ from fsg.constants import IGNORED
5
+ from fsg.trees.project_tree import ProjectTree
6
+
7
+
8
+ class PlainProjectTree(ProjectTree):
9
+ """Collects the paths by walking the directory, skipping well-known generated entries."""
10
+
11
+ def collect(self) -> list[list[str]]:
12
+ """Returns the paths found below the root, split into their path parts."""
13
+
14
+ return list(self._walk(self.root, []))
15
+
16
+ def _walk(self, directory: Path, parts: list[str]) -> Iterator[list[str]]:
17
+ """Yields the path parts of every entry below a directory, recursing into subdirectories.
18
+
19
+ Args:
20
+ directory: Directory to list.
21
+ parts: Path parts of that directory relative to the root.
22
+ """
23
+
24
+ try:
25
+ children = list(directory.iterdir())
26
+ except OSError:
27
+ return
28
+ for child in sorted(children, key=lambda path: path.name.lower()):
29
+ if child.name in IGNORED:
30
+ continue
31
+ if child.is_dir() and not child.is_symlink():
32
+ yield from self._walk(child, parts + [child.name])
33
+ else:
34
+ yield parts + [child.name]
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator
4
+ from fnmatch import fnmatchcase
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fsg.constants import ASCII_GLYPHS, IGNORED, UNICODE_GLYPHS
9
+
10
+
11
+ class ProjectTree:
12
+ """Collects the relevant paths of a project directory and renders them as a tree."""
13
+
14
+ def __init__(
15
+ self,
16
+ root: Path,
17
+ *,
18
+ use_ascii: bool = False,
19
+ depth: int | None = None,
20
+ dirs_only: bool = False,
21
+ exclude: Iterable[str] = (),
22
+ fence: bool = True,
23
+ ) -> None:
24
+ """Resolves and stores the root directory of the project together with the render options.
25
+
26
+ Args:
27
+ root: Path of the directory to render.
28
+ use_ascii: Draws the branches with ASCII characters instead of box-drawing characters.
29
+ depth: Number of levels below the root to render, or None for all levels.
30
+ dirs_only: Renders directories only and leaves out files.
31
+ exclude: Glob patterns matched case-sensitively against every path part and every relative path prefix.
32
+ fence: Wraps the rendered tree in a Markdown code fence.
33
+ """
34
+
35
+ self.root = Path(root).resolve()
36
+ self.glyphs = ASCII_GLYPHS if use_ascii else UNICODE_GLYPHS
37
+ self.depth = depth
38
+ self.dirs_only = dirs_only
39
+ self.exclude = tuple(exclude)
40
+ self.fence = fence
41
+
42
+ @classmethod
43
+ def for_path(cls, root: Path, **options: Any) -> ProjectTree:
44
+ """Returns a GitProjectTree when the path lies inside a working git repository, otherwise a PlainProjectTree.
45
+
46
+ Args:
47
+ root: Path of the directory to render.
48
+ **options: Render options passed on to the constructor.
49
+ """
50
+
51
+ from fsg.trees.git_project_tree import GitProjectTree
52
+ from fsg.trees.plain_project_tree import PlainProjectTree
53
+
54
+ if GitProjectTree.is_repository(root):
55
+ return GitProjectTree(root, **options)
56
+ return PlainProjectTree(root, **options)
57
+
58
+ def collect(self) -> list[list[str]]:
59
+ """Returns the relative paths to render, as lists of path parts."""
60
+
61
+ raise NotImplementedError
62
+
63
+ def render(self) -> str:
64
+ """Returns the tree as one string, wrapped in a Markdown code fence unless fence is disabled."""
65
+
66
+ body = "\n".join([f"{self.root.name}/", *self._lines(self._nodes(), "", 1)])
67
+ return f"```plaintext\n{body}\n```" if self.fence else body
68
+
69
+ def _nodes(self) -> dict[str, dict]:
70
+ """Turns the collected paths into nested dicts, dropping paths that contain an ignored or excluded part."""
71
+
72
+ tree: dict[str, dict] = {}
73
+ for parts in self.collect():
74
+ if any(part in IGNORED for part in parts) or self._is_excluded(parts):
75
+ continue
76
+ current = tree
77
+ for part in parts:
78
+ current = current.setdefault(part, {})
79
+ return tree
80
+
81
+ def _is_excluded(self, parts: list[str]) -> bool:
82
+ """Returns whether any part name or any slash-joined prefix of the parts matches an exclude pattern.
83
+
84
+ Args:
85
+ parts: Path parts relative to the root.
86
+ """
87
+
88
+ for index, part in enumerate(parts):
89
+ prefix = "/".join(parts[: index + 1])
90
+ if any(fnmatchcase(part, pattern) or fnmatchcase(prefix, pattern) for pattern in self.exclude):
91
+ return True
92
+ return False
93
+
94
+ def _lines(self, nodes: dict[str, dict], prefix: str, level: int) -> Iterator[str]:
95
+ """Yields the rendered lines for one level of the node tree and recurses into its children up to the depth limit.
96
+
97
+ Args:
98
+ nodes: Mapping of entry name to its child nodes.
99
+ prefix: Indentation string carried over from the parent levels.
100
+ level: Level of the given nodes below the root, starting at 1.
101
+ """
102
+
103
+ entries = sorted(nodes.items(), key=lambda item: (not item[1], item[0].lower()))
104
+ if self.dirs_only:
105
+ entries = [entry for entry in entries if entry[1]]
106
+ for index, (name, children) in enumerate(entries):
107
+ is_last = index == len(entries) - 1
108
+ connector = self.glyphs["last"] if is_last else self.glyphs["branch"]
109
+ yield f"{prefix}{connector}{name}{'/' if children else ''}"
110
+ if children and (self.depth is None or level < self.depth):
111
+ indent = self.glyphs["space"] if is_last else self.glyphs["pipe"]
112
+ yield from self._lines(children, prefix + indent, level + 1)
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: fsg
3
+ Version: 0.1.0
4
+ Summary: Generates clean directory trees that respect .gitignore and keep your docs up to date.
5
+ Project-URL: Homepage, https://github.com/KarnesTH/fsg
6
+ Project-URL: Issues, https://github.com/KarnesTH/fsg/issues
7
+ Project-URL: Changelog, https://github.com/KarnesTH/fsg/blob/main/CHANGELOG.md
8
+ Author: Patrick Hähnel
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,directory,documentation,gitignore,pre-commit,project-structure,readme,tree
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Documentation
23
+ Classifier: Topic :: Software Development :: Documentation
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+
28
+ <p align="center">
29
+ <img src="assets/logo.svg" alt="fsg logo" width="128" />
30
+ </p>
31
+
32
+ <h1 align="center">fsg</h1>
33
+
34
+ <p align="center">
35
+ <img src="https://img.shields.io/badge/version-0.1.0-blue" alt="Version 0.1.0" />
36
+ <img src="https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white" alt="Python 3.11+" />
37
+ <img src="https://img.shields.io/badge/dependencies-none-brightgreen" alt="No dependencies" />
38
+ <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" />
39
+ </p>
40
+
41
+ <p align="center">Generates clean directory trees that respect .gitignore and keep your docs up to date.</p>
42
+
43
+ ## Features
44
+
45
+ - Inside a git repository, only files git tracks or would track are listed (`.gitignore` is respected)
46
+ - Outside a repository, common generated and tool directories are skipped (`.venv`, `__pycache__`, `node_modules`, `target`, `.idea`, …)
47
+ - Directories first, entries sorted case-insensitively, empty directories omitted
48
+ - Keeps a marked section of your README up to date, with a check mode for CI
49
+ - Sets up the commit hook for you: extends an existing `.pre-commit-config.yaml` or the native git hook, never overwrites
50
+ - Limit the depth, render directories only or exclude entries by glob pattern
51
+ - Options can live in `.fsg.toml` or `[tool.fsg]` in `pyproject.toml`
52
+ - Output is wrapped in a `plaintext` code fence (optional) and always written as UTF-8
53
+ - Optional ASCII branches (`|--`, `` `-- ``) for environments without Unicode support
54
+
55
+ ## Installation
56
+
57
+ Requires Python 3.11+ and git (optional, for `.gitignore` support).
58
+
59
+ ```bash
60
+ uv tool install fsg # or: pipx install fsg
61
+ uvx fsg # run without installing
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ fsg [options] [path]
68
+ ```
69
+
70
+ | Argument | Description |
71
+ |----------------------------------|-----------------------------------------------------------------------------|
72
+ | `path` | Directory to render (default: current directory) |
73
+ | `-d N`, `--depth N` | Render only the first N levels below the root |
74
+ | `--dirs-only`, `--no-dirs-only` | Render directories only |
75
+ | `-e`, `--exclude PATTERN` | Leave out entries whose name or relative path matches the glob (repeatable) |
76
+ | `--fence`, `--no-fence` | Wrap the tree in a Markdown code fence (default: on) |
77
+ | `--ascii`, `--no-ascii` | Draw the tree with ASCII instead of box-drawing characters |
78
+ | `-u`, `--update FILE` | Write the tree between the fsg markers of FILE |
79
+ | `-c`, `--check FILE` | Exit with status 1 when the tree in FILE is out of date |
80
+ | `--setup-hook FILE` | Run `fsg --update FILE` on every commit |
81
+ | `-h`, `--help` | Show the help text and exit |
82
+ | `-V`, `--version` | Show the version and exit |
83
+
84
+ Example:
85
+
86
+ ```bash
87
+ fsg src --depth 2
88
+ ```
89
+ <!-- fsg:start -->
90
+ ```plaintext
91
+ src/
92
+ └── fsg/
93
+ ├── hooks/
94
+ ├── trees/
95
+ ├── __init__.py
96
+ ├── __main__.py
97
+ ├── cli.py
98
+ ├── config.py
99
+ ├── constants.py
100
+ └── readme_section.py
101
+ ```
102
+ <!-- fsg:end -->
103
+
104
+ ## Configuration
105
+
106
+ With `--update` and `--check`, the path and all options not given on the command line are read from `.fsg.toml`
107
+ or the `[tool.fsg]` table in `pyproject.toml`, searched from the directory of the target file upwards.
108
+ `path` is relative to the config file. Command line options take precedence, exclude patterns are combined.
109
+ Plain `fsg` ignores the config.
110
+
111
+ ```toml
112
+ [tool.fsg]
113
+ path = "src"
114
+ depth = 2
115
+ dirs-only = false
116
+ exclude = ["*.log", "docs/api"]
117
+ fence = true
118
+ ascii = false
119
+ ```
120
+
121
+ ## Keep your README up to date
122
+
123
+ Add the markers where the tree should appear:
124
+
125
+ ```markdown
126
+ <!-- fsg:start -->
127
+ <!-- fsg:end -->
128
+ ```
129
+
130
+ ```bash
131
+ fsg --update README.md # writes the tree between the markers
132
+ fsg --check README.md # exit 1 if outdated, 2 if the file or markers are missing
133
+ fsg --setup-hook README.md # updates the tree on every commit
134
+ ```
135
+
136
+ `--setup-hook` adds fsg to an existing `.pre-commit-config.yaml`. Without one, it adds a step to the git
137
+ `pre-commit` hook that updates the tree and stops the commit so you can stage the change.
138
+
139
+ ### pre-commit
140
+
141
+ To add the hook by hand:
142
+
143
+ ```yaml
144
+ repos:
145
+ - repo: https://github.com/KarnesTH/fsg
146
+ rev: v0.1.0
147
+ hooks:
148
+ - id: fsg # updates README.md
149
+ # - id: fsg-check # only checks, e.g. in CI
150
+ ```
151
+
152
+ Use `args` for another file, e.g. `args: [--update, docs/structure.md]`.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ git clone https://github.com/KarnesTH/fsg.git
158
+ cd fsg
159
+ uv sync
160
+ uv run pytest
161
+ ```
@@ -0,0 +1,19 @@
1
+ fsg/__init__.py,sha256=nYbZZ7HZoQz6t_Qr11yC4IUP7cBtO2pWbKNl-qmkgFk,132
2
+ fsg/__main__.py,sha256=_eJ4SF_a5rJg1-wBkNJ6Cj5TPZb8B5Njydkc3PGCRRQ,81
3
+ fsg/cli.py,sha256=WZs1Uzp3E-r_sCzIgngamK65JIXox6usLiN1l3jOzQE,7117
4
+ fsg/config.py,sha256=n8qYNNAjpv-p3S649vyH-8CIrF7IBTQZb8Of2Pz7L0M,5497
5
+ fsg/constants.py,sha256=FFnpodbPpdQx-iTHN395_jQwVYTfG9tA1f7HwIGElTU,465
6
+ fsg/readme_section.py,sha256=8qTAeztwGge810OGcmkb9uxDSnRa6QpKxLumSVCy8SU,3230
7
+ fsg/hooks/__init__.py,sha256=JHMpjdSifTt6DRwmnhLUAQSWl0bs_U8qpvLKgO9zhCs,170
8
+ fsg/hooks/git_hook.py,sha256=msc5vByfPXvdIxvs8Y-idyEclcIAZHabi0KnGMQ6UTg,2651
9
+ fsg/hooks/hook.py,sha256=kDyNGUrwFDs2oVM1U1p3lmoHSGcHrRoOfD7e2PMpsF8,2327
10
+ fsg/hooks/pre_commit_hook.py,sha256=tzfytfpLN3RfJiB8RqUq6l9YFUYoqgrB79fiH3GmuKg,2907
11
+ fsg/trees/__init__.py,sha256=4gU-RkrKlerEQ2vorjM94B_aVVvALGTKH86sEBBhx0w,223
12
+ fsg/trees/git_project_tree.py,sha256=chdO4jF2UaSAMCMeBDtCAfAaDorC0yB9WQICIHqaeCE,1389
13
+ fsg/trees/plain_project_tree.py,sha256=0g4bnOwnveD93M9AvT78MFIAHhKPWi7Ebc2b9CkD1tI,1197
14
+ fsg/trees/project_tree.py,sha256=SSiQDEOdizdA3tM16jeAmXrM8kAlntiLZ607Jf1lxRg,4593
15
+ fsg-0.1.0.dist-info/METADATA,sha256=NZWQFeIK29Bu1nll7b61SH2yYFJH8LTALFT_bBIIUMk,6149
16
+ fsg-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ fsg-0.1.0.dist-info/entry_points.txt,sha256=b1R-8lEHlcnmtcDyOVlNPbaUH5m9i3Jy3P2WvcQm0tM,37
18
+ fsg-0.1.0.dist-info/licenses/LICENSE,sha256=6AwFjMZ0hf2E5qU0Bi302AM5K9Uq0gOlGhxFrqOe5dM,1071
19
+ fsg-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fsg = fsg.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Hähnel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.