tree2guide 1.0.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.
tree2guide/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ tree2guide — see your project structure clearly.
3
+
4
+ Public API:
5
+ build_node_tree(root, matcher, options=None) -> TreeNode
6
+ build_tree(root, matcher, options=None) -> list[str] # backward compat
7
+ TreeNode, TreeOptions
8
+ ExcludeMatcher, GitignoreRule, load_exclude_patterns
9
+ analyze(node) -> LlmSummary
10
+ render_markdown / render_text / render_json / render_yaml / render_html / render_llm
11
+ """
12
+
13
+ from tree2guide.ignore import (
14
+ DEFAULT_EXCLUDES,
15
+ EXCLUDE_FILENAME,
16
+ ExcludeMatcher,
17
+ GitignoreRule,
18
+ load_exclude_patterns,
19
+ )
20
+ from tree2guide.llm import LlmSummary, analyze
21
+ from tree2guide.renderers.html import render_html
22
+ from tree2guide.renderers.json_renderer import render_json
23
+ from tree2guide.renderers.llm import render_llm
24
+ from tree2guide.renderers.markdown import render_markdown
25
+ from tree2guide.renderers.text import render_text
26
+ from tree2guide.renderers.yaml_renderer import render_yaml
27
+ from tree2guide.scanner import TreeNode, TreeOptions, build_node_tree, build_tree
28
+
29
+ __version__ = "1.0.0"
30
+
31
+ __all__ = [
32
+ "__version__",
33
+ "DEFAULT_EXCLUDES",
34
+ "EXCLUDE_FILENAME",
35
+ "ExcludeMatcher",
36
+ "GitignoreRule",
37
+ "load_exclude_patterns",
38
+ "TreeNode",
39
+ "TreeOptions",
40
+ "build_node_tree",
41
+ "build_tree",
42
+ "LlmSummary",
43
+ "analyze",
44
+ "render_markdown",
45
+ "render_text",
46
+ "render_json",
47
+ "render_yaml",
48
+ "render_html",
49
+ "render_llm",
50
+ ]
tree2guide/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ tree2guide.cli — argument parsing and the `tree2guide` command entry point.
3
+ """
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from tree2guide.ignore import EXCLUDE_FILENAME, ExcludeMatcher, load_exclude_patterns
10
+ from tree2guide.renderers.html import render_html
11
+ from tree2guide.renderers.json_renderer import render_json
12
+ from tree2guide.renderers.llm import render_llm
13
+ from tree2guide.renderers.markdown import render_markdown
14
+ from tree2guide.renderers.text import render_text
15
+ from tree2guide.renderers.yaml_renderer import render_yaml
16
+ from tree2guide.scanner import TreeOptions, build_node_tree
17
+
18
+ _FORMAT_EXTENSIONS = {
19
+ "markdown": ".md",
20
+ "text": ".txt",
21
+ "json": ".json",
22
+ "yaml": ".yaml",
23
+ "html": ".html",
24
+ "llm": ".txt",
25
+ }
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="tree2guide",
31
+ description="Generate a structured tree view of a folder in multiple formats.",
32
+ )
33
+ parser.add_argument("target", help="Path to the target folder")
34
+ parser.add_argument(
35
+ "-o", "--output", default=None,
36
+ help="Output file path (default: <folder>_tree.<ext> based on --format)",
37
+ )
38
+ parser.add_argument(
39
+ "--exclude-file", default=None,
40
+ help=f"Path to exclude file (default: <target>/{EXCLUDE_FILENAME})",
41
+ )
42
+ parser.add_argument("--title", default=None, help="Optional title above the tree")
43
+ parser.add_argument(
44
+ "--no-footer", action="store_true",
45
+ help="Omit the author/license footer (markdown and html only)",
46
+ )
47
+ parser.add_argument(
48
+ "--stdout", action="store_true",
49
+ help="Print to stdout instead of writing a file",
50
+ )
51
+ parser.add_argument(
52
+ "--format",
53
+ choices=["markdown", "text", "json", "yaml", "html", "llm"],
54
+ default="markdown",
55
+ help="Output format (default: markdown). Use 'llm' for an AI-friendly summary.",
56
+ )
57
+ parser.add_argument(
58
+ "--llm", action="store_true",
59
+ help="Shorthand for --format llm (AI-friendly project summary)",
60
+ )
61
+ parser.add_argument(
62
+ "--max-depth", type=int, default=None, metavar="N",
63
+ help="Limit recursion depth",
64
+ )
65
+ group = parser.add_mutually_exclusive_group()
66
+ group.add_argument("--dirs-only", action="store_true", help="Only show directories")
67
+ group.add_argument("--files-only", action="store_true", help="Only show files")
68
+ parser.add_argument("--no-hidden", action="store_true", help="Skip dotfiles/dotfolders")
69
+ parser.add_argument(
70
+ "--sort",
71
+ choices=["dirs-first", "files-first", "alpha"],
72
+ default="dirs-first",
73
+ help="Sort order within each folder (default: dirs-first)",
74
+ )
75
+ return parser
76
+
77
+
78
+ def _render(fmt: str, tree, title: str | None, include_footer: bool) -> str:
79
+ if fmt == "markdown":
80
+ return render_markdown(tree, title=title, include_footer=include_footer)
81
+ if fmt == "text":
82
+ return render_text(tree, title=title)
83
+ if fmt == "json":
84
+ return render_json(tree)
85
+ if fmt == "yaml":
86
+ return render_yaml(tree)
87
+ if fmt == "html":
88
+ return render_html(tree, title=title, include_footer=include_footer)
89
+ if fmt == "llm":
90
+ return render_llm(tree, title=title)
91
+ raise ValueError(f"Unknown format: {fmt}")
92
+
93
+
94
+ def main(argv: list[str] | None = None) -> int:
95
+ parser = build_parser()
96
+ args = parser.parse_args(argv)
97
+
98
+ # --llm is a shorthand for --format llm
99
+ fmt = "llm" if args.llm else args.format
100
+
101
+ root = Path(args.target).resolve()
102
+ if not root.is_dir():
103
+ print(f"Error: '{root}' is not a valid directory.", file=sys.stderr)
104
+ return 1
105
+
106
+ exclude_file = (
107
+ Path(args.exclude_file).resolve() if args.exclude_file else root / EXCLUDE_FILENAME
108
+ )
109
+ patterns = load_exclude_patterns(exclude_file)
110
+ matcher = ExcludeMatcher(patterns)
111
+
112
+ options = TreeOptions(
113
+ max_depth=args.max_depth,
114
+ dirs_only=args.dirs_only,
115
+ files_only=args.files_only,
116
+ no_hidden=args.no_hidden,
117
+ sort=args.sort,
118
+ )
119
+
120
+ tree = build_node_tree(root, matcher, options)
121
+ output = _render(fmt, tree, title=args.title, include_footer=not args.no_footer)
122
+
123
+ if args.stdout:
124
+ sys.stdout.buffer.write(output.encode("utf-8"))
125
+ return 0
126
+
127
+ ext = _FORMAT_EXTENSIONS[fmt]
128
+ # llm output gets a distinct suffix so it doesn't clobber a plain text file
129
+ suffix = "_llm" if fmt == "llm" else "_tree"
130
+ output_path = (
131
+ Path(args.output).resolve()
132
+ if args.output
133
+ else Path.cwd() / f"{root.name}{suffix}{ext}"
134
+ )
135
+ output_path.write_text(output, encoding="utf-8")
136
+
137
+ print(f"✅ Tree written to: {output_path}")
138
+ if exclude_file.is_file():
139
+ print(f" Used exclude file: {exclude_file}")
140
+ else:
141
+ print(f" No exclude file found at {exclude_file} (only defaults applied)")
142
+
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
tree2guide/ignore.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ tree2guide.ignore — gitignore-compatible pattern matching, zero dependencies.
3
+ """
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ DEFAULT_EXCLUDES: list[str] = [
9
+ ".git",
10
+ ".tree2ignore",
11
+ "__pycache__",
12
+ "*.pyc",
13
+ ".DS_Store",
14
+ ]
15
+
16
+ EXCLUDE_FILENAME = ".tree2ignore"
17
+
18
+
19
+ def load_exclude_patterns(exclude_file: Path) -> list[str]:
20
+ patterns = list(DEFAULT_EXCLUDES)
21
+ if exclude_file.is_file():
22
+ for line in exclude_file.read_text(encoding="utf-8").splitlines():
23
+ stripped = line.strip()
24
+ if not stripped or stripped.startswith("#"):
25
+ continue
26
+ patterns.append(line.rstrip("\n"))
27
+ return patterns
28
+
29
+
30
+ class GitignoreRule:
31
+ def __init__(self, raw: str):
32
+ pattern = raw
33
+ self.negate = pattern.startswith("!")
34
+ if self.negate:
35
+ pattern = pattern[1:]
36
+ self.dir_only = pattern.endswith("/")
37
+ if self.dir_only:
38
+ pattern = pattern[:-1]
39
+ self.anchored = pattern.startswith("/")
40
+ if self.anchored:
41
+ pattern = pattern[1:]
42
+ has_slash = "/" in pattern
43
+ if not has_slash and not self.anchored:
44
+ pattern = "**/" + pattern
45
+ self.regex = re.compile(self._translate(pattern))
46
+
47
+ @staticmethod
48
+ def _translate(pattern: str) -> str:
49
+ segments = pattern.split("/")
50
+ parts = []
51
+ for i, segment in enumerate(segments):
52
+ if segment == "**":
53
+ if i == 0 and i == len(segments) - 1:
54
+ parts.append(".*")
55
+ elif i == 0:
56
+ parts.append("(?:.*/)?")
57
+ elif i == len(segments) - 1:
58
+ parts.append("(?:/.*)?" if parts else ".*")
59
+ else:
60
+ parts.append("(?:[^/]+/)*")
61
+ continue
62
+ seg_regex = "".join(
63
+ "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
64
+ for c in segment
65
+ )
66
+ if parts and segments[i - 1] != "**":
67
+ parts.append("/")
68
+ parts.append(seg_regex)
69
+ body = "".join(parts)
70
+ return f"^{body}$"
71
+
72
+ def matches(self, rel_path: str, is_dir: bool) -> bool:
73
+ parts = rel_path.split("/")
74
+ if not (self.dir_only and not is_dir):
75
+ if self.regex.match(rel_path):
76
+ return True
77
+ for k in range(1, len(parts)):
78
+ if self.regex.match("/".join(parts[:k])):
79
+ return True
80
+ return False
81
+
82
+
83
+ class ExcludeMatcher:
84
+ def __init__(self, patterns: list[str]):
85
+ self.rules: list[GitignoreRule] = [GitignoreRule(p) for p in patterns]
86
+
87
+ def is_excluded(self, rel_path: str, is_dir: bool) -> bool:
88
+ excluded = False
89
+ for rule in self.rules:
90
+ if rule.matches(rel_path, is_dir):
91
+ excluded = not rule.negate
92
+ return excluded
tree2guide/llm.py ADDED
@@ -0,0 +1,211 @@
1
+ """
2
+ tree2guide.llm — rule-based project heuristics for --llm mode.
3
+
4
+ Zero dependencies, zero network calls. All detection is done by inspecting
5
+ the names present in the TreeNode tree that the scanner already built —
6
+ no second filesystem pass is needed.
7
+
8
+ Produces a structured LlmSummary dataclass that the LLM renderer uses to
9
+ build its output.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ from dataclasses import dataclass, field
14
+ from tree2guide.scanner import TreeNode
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Detection tables
19
+ # ---------------------------------------------------------------------------
20
+
21
+ # Maps a filename/pattern to (language_or_framework, display_label)
22
+ _STACK_SIGNALS: list[tuple[str, str]] = [
23
+ # Python
24
+ ("pyproject.toml", "Python (pyproject.toml)"),
25
+ ("setup.py", "Python (setup.py)"),
26
+ ("requirements.txt", "Python (requirements.txt)"),
27
+ ("Pipfile", "Python (Pipfile)"),
28
+ ("poetry.lock", "Python / Poetry"),
29
+ # JavaScript / TypeScript
30
+ ("package.json", "Node.js / JavaScript"),
31
+ ("tsconfig.json", "TypeScript"),
32
+ ("next.config.js", "Next.js"),
33
+ ("next.config.ts", "Next.js (TypeScript)"),
34
+ ("nuxt.config.ts", "Nuxt.js"),
35
+ ("nuxt.config.js", "Nuxt.js"),
36
+ ("vite.config.ts", "Vite"),
37
+ ("vite.config.js", "Vite"),
38
+ ("angular.json", "Angular"),
39
+ ("svelte.config.js", "SvelteKit"),
40
+ # Go
41
+ ("go.mod", "Go"),
42
+ # Rust
43
+ ("Cargo.toml", "Rust / Cargo"),
44
+ # Ruby
45
+ ("Gemfile", "Ruby"),
46
+ ("Rakefile", "Ruby / Rake"),
47
+ # Java / Kotlin / JVM
48
+ ("pom.xml", "Java / Maven"),
49
+ ("build.gradle", "Java / Gradle"),
50
+ ("build.gradle.kts", "Kotlin / Gradle"),
51
+ # C / C++
52
+ ("CMakeLists.txt", "C/C++ / CMake"),
53
+ ("Makefile", "C/C++ / Make"),
54
+ # PHP
55
+ ("composer.json", "PHP / Composer"),
56
+ # Swift / iOS
57
+ ("Package.swift", "Swift / SPM"),
58
+ # Dart / Flutter
59
+ ("pubspec.yaml", "Dart / Flutter"),
60
+ # .NET
61
+ ("*.csproj", ".NET / C#"),
62
+ ("*.fsproj", ".NET / F#"),
63
+ ("*.sln", ".NET Solution"),
64
+ # Elixir
65
+ ("mix.exs", "Elixir / Mix"),
66
+ # Haskell
67
+ ("stack.yaml", "Haskell / Stack"),
68
+ ("*.cabal", "Haskell / Cabal"),
69
+ # Docker / infra
70
+ ("Dockerfile", "Docker"),
71
+ ("docker-compose.yml", "Docker Compose"),
72
+ ("docker-compose.yaml", "Docker Compose"),
73
+ ]
74
+
75
+ # Files/dirs whose presence is worth flagging
76
+ _NOTABLE_FLAGS: list[tuple[str, str]] = [
77
+ (".env.example", "Environment template (.env.example) present"),
78
+ (".env", "Live .env file present (check it's gitignored!)"),
79
+ ("tests", "Tests directory present"),
80
+ ("test", "Test directory present"),
81
+ ("__tests__", "Jest-style __tests__ directory present"),
82
+ ("spec", "Spec directory present"),
83
+ (".github", "GitHub Actions / workflows present"),
84
+ (".gitlab-ci.yml", "GitLab CI config present"),
85
+ ("Jenkinsfile", "Jenkins CI config present"),
86
+ (".circleci", "CircleCI config present"),
87
+ ("azure-pipelines.yml", "Azure Pipelines config present"),
88
+ ("LICENSE", "LICENSE file present"),
89
+ ("LICENSE.md", "LICENSE file present"),
90
+ ("CHANGELOG.md", "CHANGELOG present"),
91
+ ("CONTRIBUTING.md", "CONTRIBUTING guide present"),
92
+ ("Makefile", "Makefile present (build/task automation)"),
93
+ ("docker-compose.yml", "Docker Compose present"),
94
+ ("docker-compose.yaml", "Docker Compose present"),
95
+ (".pre-commit-config.yaml", "Pre-commit hooks configured"),
96
+ ("renovate.json", "Renovate dependency-update bot configured"),
97
+ (".editorconfig", "EditorConfig present"),
98
+ ]
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Summary dataclass
103
+ # ---------------------------------------------------------------------------
104
+
105
+ @dataclass
106
+ class LlmSummary:
107
+ """Structured heuristic summary of the project."""
108
+ detected_stack: list[str] = field(default_factory=list)
109
+ file_count: int = 0
110
+ dir_count: int = 0
111
+ notable_flags: list[str] = field(default_factory=list)
112
+ top_level_dirs: list[str] = field(default_factory=list)
113
+ top_level_files: list[str] = field(default_factory=list)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Detection logic
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def _collect_all_names(node: TreeNode) -> tuple[set[str], set[str]]:
121
+ """
122
+ Walk the entire TreeNode tree and collect every file name and dir name
123
+ into two flat sets. Used for stack/flag detection without a second
124
+ filesystem pass.
125
+ """
126
+ files: set[str] = set()
127
+ dirs: set[str] = set()
128
+
129
+ def _walk(n: TreeNode) -> None:
130
+ for child in n.children:
131
+ if child.is_symlink:
132
+ continue
133
+ if child.is_dir:
134
+ dirs.add(child.name)
135
+ _walk(child)
136
+ else:
137
+ files.add(child.name)
138
+
139
+ _walk(node)
140
+ return files, dirs
141
+
142
+
143
+ def _count_entries(node: TreeNode) -> tuple[int, int]:
144
+ """Return (total_files, total_dirs) recursively."""
145
+ files = dirs = 0
146
+ for child in node.children:
147
+ if child.is_symlink:
148
+ continue
149
+ if child.is_dir:
150
+ dirs += 1
151
+ f, d = _count_entries(child)
152
+ files += f
153
+ dirs += d
154
+ else:
155
+ files += 1
156
+ return files, dirs
157
+
158
+
159
+ def _matches_pattern(name: str, pattern: str) -> bool:
160
+ """Simple glob-style match: supports leading/trailing '*' wildcards only."""
161
+ if pattern.startswith("*") and pattern.endswith("*"):
162
+ return pattern[1:-1] in name
163
+ if pattern.startswith("*"):
164
+ return name.endswith(pattern[1:])
165
+ if pattern.endswith("*"):
166
+ return name.startswith(pattern[:-1])
167
+ return name == pattern
168
+
169
+
170
+ def analyze(node: TreeNode) -> LlmSummary:
171
+ """
172
+ Inspect a TreeNode tree and return an LlmSummary with detected stack,
173
+ counts, flags, and top-level structure. Pure rule-based, zero deps,
174
+ no filesystem access beyond the already-built tree.
175
+ """
176
+ summary = LlmSummary()
177
+
178
+ all_files, all_dirs = _collect_all_names(node)
179
+ all_names = all_files | all_dirs
180
+
181
+ # Stack detection — first match wins per signal, deduplicate labels
182
+ seen_labels: set[str] = set()
183
+ for pattern, label in _STACK_SIGNALS:
184
+ for name in all_names:
185
+ if _matches_pattern(name, pattern) and label not in seen_labels:
186
+ summary.detected_stack.append(label)
187
+ seen_labels.add(label)
188
+ break
189
+
190
+ # Notable flags
191
+ seen_flags: set[str] = set()
192
+ for pattern, message in _NOTABLE_FLAGS:
193
+ for name in all_names:
194
+ if _matches_pattern(name, pattern) and message not in seen_flags:
195
+ summary.notable_flags.append(message)
196
+ seen_flags.add(message)
197
+ break
198
+
199
+ # Counts
200
+ summary.file_count, summary.dir_count = _count_entries(node)
201
+
202
+ # Top-level structure
203
+ for child in node.children:
204
+ if child.is_symlink:
205
+ continue
206
+ if child.is_dir:
207
+ summary.top_level_dirs.append(child.name + "/")
208
+ else:
209
+ summary.top_level_files.append(child.name)
210
+
211
+ return summary
@@ -0,0 +1,7 @@
1
+ """
2
+ tree2guide.renderers — output formatters for a tree built by tree2guide.scanner.
3
+
4
+ Renderers: markdown (default), text, json, yaml, html, llm.
5
+ Each takes a TreeNode and returns a string. Only the llm renderer also
6
+ calls tree2guide.llm.analyze() to attach heuristic metadata.
7
+ """