pymap-cli 0.3.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.
pymap/mapper.py ADDED
@@ -0,0 +1,198 @@
1
+ """Orchestration: chain the analyses and write the map.
2
+
3
+ This is also pymap's library API::
4
+
5
+ from pymap import Settings, map_codebase
6
+
7
+ report = map_codebase(Settings(target="src/mypkg"))
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Callable
15
+
16
+ from pymap import render
17
+ from pymap.analysis import cycles as cycle_analysis
18
+ from pymap.analysis.calls import load_call_graph
19
+ from pymap.analysis.symbols import Symbol, coverage, duplicate_modules, extract
20
+ from pymap.settings import Settings, count_lines
21
+ from pymap.tools import code2flow, pydeps, pyreverse, tach
22
+
23
+ Log = Callable[[str], Any]
24
+
25
+ #: Steps, in execution order. Adding a tool means adding one line here.
26
+ STEPS = (
27
+ ("pydeps", "import graph", pydeps),
28
+ ("pyreverse", "classes and inheritance", pyreverse),
29
+ ("code2flow", "call graph", code2flow),
30
+ ("tach", "modules and cycles", tach),
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class Report:
36
+ """What pymap found, and where it wrote it."""
37
+
38
+ settings: Settings
39
+ files: list[Path] = field(default_factory=list)
40
+ lines: int = 0
41
+ symbols: list[Symbol] = field(default_factory=list)
42
+ documentable: int = 0
43
+ documented: int = 0
44
+ coverage: int = 0
45
+ cycles: list[list[str]] = field(default_factory=list)
46
+ duplicates: dict[str, int] = field(default_factory=dict)
47
+ tools: dict[str, dict[str, Any]] = field(default_factory=dict)
48
+ index: Path | None = None
49
+ explorer: Path | None = None
50
+
51
+ @property
52
+ def undocumented(self) -> int:
53
+ return self.documentable - self.documented
54
+
55
+
56
+ def map_codebase(settings: Settings, log: Log = lambda _: None) -> Report:
57
+ """Analyse ``settings.target`` and write the map into ``settings.output``."""
58
+ if not settings.target.is_dir():
59
+ raise NotADirectoryError(f"directory not found: {settings.target}")
60
+ settings.output.mkdir(parents=True, exist_ok=True)
61
+
62
+ report = Report(settings=settings, files=settings.files())
63
+ if not report.files:
64
+ raise FileNotFoundError(f"no Python file under {settings.target}")
65
+ report.lines = count_lines(report.files)
66
+ log(f"Mapping {settings.name}: {len(report.files)} files, {report.lines} lines\n")
67
+
68
+ total = len(STEPS) + 1
69
+ for index, (name, role, module) in enumerate(STEPS, 1):
70
+ log(f"[{index}/{total}] {name:11} {role}...")
71
+ report.tools[name] = result = module.execute(settings)
72
+ log(" " + (result["log"] or _summary(name, result)))
73
+
74
+ log(f"[{total}/{total}] explorer assembling...")
75
+ report.cycles = cycle_analysis.detect(report.tools["tach"]["deps"])
76
+ report.symbols = extract(settings.target, report.files)
77
+ report.duplicates = duplicate_modules(report.files)
78
+ report.documentable, report.documented, report.coverage = coverage(report.symbols)
79
+
80
+ relations = load_call_graph(report.tools["code2flow"]["json"] or "")
81
+ report.explorer = settings.output / "explorer.html"
82
+ report.explorer.write_text(
83
+ render.explorer_page(
84
+ settings.name,
85
+ settings.target,
86
+ report.symbols,
87
+ relations,
88
+ report.coverage,
89
+ settings.editor_uri,
90
+ ),
91
+ encoding="utf-8",
92
+ )
93
+ log(
94
+ f" {len(report.symbols)} symbols, {report.coverage}% documented "
95
+ f"({report.undocumented} without a docstring)"
96
+ )
97
+ for stem, count in report.duplicates.items():
98
+ log(
99
+ f" note: {count} files are named {stem}.py; "
100
+ f"their symbols share one namespace in the explorer"
101
+ )
102
+
103
+ report.index = settings.output / "index.html"
104
+ report.index.write_text(
105
+ render.index_page(settings.name, len(report.files), report.lines, _sections(report)),
106
+ encoding="utf-8",
107
+ )
108
+ return report
109
+
110
+
111
+ def _summary(name: str, result: dict[str, Any]) -> str:
112
+ """One terminal line per tool that succeeded."""
113
+ if name == "pydeps":
114
+ return f"{result['modules']} modules, {result['imports']} imports"
115
+ if name == "pyreverse":
116
+ drawn = [kind for kind in ("classes", "packages") if result[kind]]
117
+ return ", ".join(drawn) + " rendered" if drawn else "nothing to draw"
118
+ if name == "code2flow":
119
+ return f"{result['functions']} functions, {result['calls']} calls"
120
+ if name == "tach":
121
+ cut = (
122
+ f" ({result['truncated']} modules beyond the limit)" if result["truncated"] else ""
123
+ )
124
+ return f"{len(result['deps'])} files linked{cut}"
125
+ return "done"
126
+
127
+
128
+ def _sections(report: Report) -> list[str]:
129
+ """The landing page blocks, in the recommended reading order."""
130
+ tools = report.tools
131
+ sections = [
132
+ render.section(
133
+ title="Walkthrough tree",
134
+ what="Every module, class and function with its signature and docstring, "
135
+ "filterable from the keyboard. From any symbol you can jump to what "
136
+ "it calls and to what calls it. Open this one first.",
137
+ figures=[
138
+ (len(report.symbols), "symbols"),
139
+ (f"{report.coverage}%", "documented"),
140
+ (report.undocumented, "without a docstring"),
141
+ ],
142
+ buttons=[("explorer.html", "Open the explorer")],
143
+ )
144
+ ]
145
+
146
+ imports = tools["pydeps"]
147
+ sections.append(
148
+ render.section(
149
+ title="Imports between modules",
150
+ what="Who imports whom. The bird's-eye view: it shows the layers and "
151
+ "brings out the modules everyone pulls in."
152
+ if imports["svg"]
153
+ else "",
154
+ missing="" if imports["svg"] else imports["log"],
155
+ figures=[(imports["modules"], "modules"), (imports["imports"], "imports")]
156
+ if imports["svg"]
157
+ else [],
158
+ buttons=[("imports.svg", "Open the graph")] if imports["svg"] else [],
159
+ )
160
+ )
161
+
162
+ classes = tools["pyreverse"]
163
+ drawn = [
164
+ (path.name, label)
165
+ for path, label in (
166
+ (classes["classes"], "Classes and attributes"),
167
+ (classes["packages"], "Packages"),
168
+ )
169
+ if path
170
+ ]
171
+ sections.append(
172
+ render.section(
173
+ title="Classes and inheritance",
174
+ what="Classes, their attributes and their inheritance links. Useful when "
175
+ "the code is object-oriented; empty when it is not."
176
+ if drawn
177
+ else "",
178
+ missing="" if drawn else classes["log"],
179
+ buttons=drawn,
180
+ )
181
+ )
182
+
183
+ boundaries = tools["tach"]
184
+ known = bool(boundaries["deps"] or boundaries["mermaid"])
185
+ sections.append(
186
+ render.section(
187
+ title="Module boundaries",
188
+ what="The file-by-file dependency map, and the cycles. Paste the Mermaid "
189
+ "source into a README: it will still be readable in ten years."
190
+ if known
191
+ else "",
192
+ missing="" if known else boundaries["log"],
193
+ figures=[(len(boundaries["deps"]), "files linked")] if boundaries["deps"] else [],
194
+ detail=render.cycles_block(report.cycles) if boundaries["deps"] else "",
195
+ buttons=[("tach.mmd", "Mermaid source")] if boundaries["mermaid"] else [],
196
+ )
197
+ )
198
+ return sections
pymap/py.typed ADDED
File without changes
pymap/render.py ADDED
@@ -0,0 +1,170 @@
1
+ """Assembling the HTML pages from templates.
2
+
3
+ Templates are real ``.html`` files (under ``pymap/templates/``), editable and
4
+ readable as-is. Values are injected in place of ``__PYMAP_X__`` tokens: unlike
5
+ ``str.format``, no CSS or JavaScript brace has to be doubled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import json
12
+ import re
13
+ from collections.abc import Iterable, Sequence
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ TOKEN = re.compile(r"__PYMAP_[A-Z_]+__")
19
+ _CACHE: dict[str, str] = {}
20
+
21
+
22
+ def template(filename: str) -> str:
23
+ """Contents of a template, read once and then kept in memory."""
24
+ if filename not in _CACHE:
25
+ _CACHE[filename] = (
26
+ resources.files("pymap.templates").joinpath(filename).read_text(encoding="utf-8")
27
+ )
28
+ return _CACHE[filename]
29
+
30
+
31
+ def fill(filename: str, **values: Any) -> str:
32
+ """Substitute a template's tokens. A forgotten token is an error, not a hole.
33
+
34
+ Validation is done against the template, never against the result: injected
35
+ values legitimately contain ``__PYMAP_...__`` text (pymap can map itself).
36
+ Substitution is a single pass, so one value can never be re-read as the
37
+ token of the next.
38
+ """
39
+ text = template(filename)
40
+ table = {f"__PYMAP_{key.upper()}__": str(value) for key, value in values.items()}
41
+ expected = set(TOKEN.findall(text))
42
+ if table.keys() - expected:
43
+ raise KeyError(f"{filename}: unknown tokens {sorted(table.keys() - expected)}")
44
+ if expected - table.keys():
45
+ raise KeyError(f"{filename}: unfilled tokens {sorted(expected - table.keys())}")
46
+ return TOKEN.sub(lambda match: table[match.group(0)], text)
47
+
48
+
49
+ def script_json(value: Any) -> str:
50
+ """Serialise ``value`` as JSON that is safe inside a ``<script>`` block.
51
+
52
+ A docstring containing ``</script>`` would otherwise close the tag early and
53
+ break the whole page, so the characters that can start HTML markup are
54
+ escaped. ``\\u2028`` and ``\\u2029`` are escaped too: they are valid JSON
55
+ but are line terminators to older JavaScript parsers.
56
+ """
57
+ text = json.dumps(value, ensure_ascii=False)
58
+ for char, escaped in (
59
+ ("<", "\\u003c"),
60
+ (">", "\\u003e"),
61
+ ("&", "\\u0026"),
62
+ ("\u2028", "\\u2028"),
63
+ ("\u2029", "\\u2029"),
64
+ ):
65
+ text = text.replace(char, escaped)
66
+ return text
67
+
68
+
69
+ # --- fragments ---------------------------------------------------------------
70
+
71
+
72
+ def stats(pairs: Sequence[tuple[Any, str]]) -> str:
73
+ """``[(value, label)]`` -> the row of headline figures."""
74
+ if not pairs:
75
+ return ""
76
+ return (
77
+ '<div class="stats">'
78
+ + "".join(
79
+ f'<div class="stat"><b>{html.escape(str(value))}</b>'
80
+ f"<span>{html.escape(str(label))}</span></div>"
81
+ for value, label in pairs
82
+ )
83
+ + "</div>"
84
+ )
85
+
86
+
87
+ def links(pairs: Sequence[tuple[str, str]]) -> str:
88
+ """``[(href, label)]`` -> the open buttons."""
89
+ return "".join(
90
+ f'<a class="view" href="{html.escape(str(href))}">{html.escape(str(label))}</a>'
91
+ for href, label in pairs
92
+ )
93
+
94
+
95
+ def note(text: str) -> str:
96
+ return f'<p class="what">{html.escape(text)}</p>' if text else ""
97
+
98
+
99
+ def cycles_block(cycles: Iterable[Sequence[str]]) -> str:
100
+ """The red panel listing circular dependencies."""
101
+ cycles = list(cycles)
102
+ if not cycles:
103
+ return note("No circular dependency found.")
104
+ items = "".join(
105
+ f"<li><code>{html.escape(' -> '.join(Path(node).name for node in cycle))}</code></li>"
106
+ for cycle in cycles
107
+ )
108
+ plural = "y" if len(cycles) == 1 else "ies"
109
+ return (
110
+ f'<div class="cycles"><b>{len(cycles)} circular dependenc{plural}</b>'
111
+ f"<ul>{items}</ul></div>"
112
+ )
113
+
114
+
115
+ def section(
116
+ title: str,
117
+ what: str = "",
118
+ missing: str = "",
119
+ figures: Sequence[tuple[Any, str]] = (),
120
+ detail: str = "",
121
+ buttons: Sequence[tuple[str, str]] = (),
122
+ ) -> str:
123
+ """One section of the landing page.
124
+
125
+ ``what`` is plain text (escaped here); ``missing`` replaces the description
126
+ when the matching tool did not run.
127
+ """
128
+ description = (
129
+ f'<span class="absent">{html.escape(missing)}</span>' if missing else html.escape(what)
130
+ )
131
+ return fill(
132
+ "section.html",
133
+ title=html.escape(title),
134
+ what=description,
135
+ stats=stats(figures),
136
+ detail=detail,
137
+ links=links(buttons),
138
+ )
139
+
140
+
141
+ # --- pages -------------------------------------------------------------------
142
+
143
+
144
+ def index_page(name: str, file_count: int, line_count: int, sections: Iterable[str]) -> str:
145
+ return fill(
146
+ "index.html",
147
+ name=html.escape(name),
148
+ files=file_count,
149
+ lines=f"{line_count:,}",
150
+ body="\n".join(sections),
151
+ )
152
+
153
+
154
+ def explorer_page(
155
+ name: str,
156
+ root: str | Path,
157
+ symbols: list[dict[str, Any]],
158
+ relations: dict[str, Any],
159
+ coverage: int,
160
+ editor_uri: str = "",
161
+ ) -> str:
162
+ return fill(
163
+ "explorer.html",
164
+ name=html.escape(name),
165
+ coverage=coverage,
166
+ root=script_json(str(root)),
167
+ editor=script_json(editor_uri),
168
+ data=script_json(symbols),
169
+ relations=script_json(relations),
170
+ )
pymap/runner.py ADDED
@@ -0,0 +1,93 @@
1
+ """Running external tools, and detecting the ones that are missing.
2
+
3
+ pymap needs none of these tools to start: every missing step is reported and
4
+ then skipped. That is what makes it safe to install into a project without
5
+ dragging a dependency chain along.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+ from typing import NamedTuple
17
+
18
+
19
+ class Tool(NamedTuple):
20
+ """An external tool pymap knows how to drive."""
21
+
22
+ name: str
23
+ module: str | None
24
+ install: str
25
+ role: str
26
+
27
+
28
+ #: ``module`` is the fallback: when the console script is not on PATH but the
29
+ #: package is installed in the current interpreter, ``python -m`` finds it.
30
+ TOOLS: dict[str, Tool] = {
31
+ "pydeps": Tool("pydeps", "pydeps", "pip install pydeps", "import graph"),
32
+ "pyreverse": Tool("pyreverse", None, "pip install pylint", "class diagrams"),
33
+ "code2flow": Tool("code2flow", "code2flow", "pip install code2flow", "call graph"),
34
+ "tach": Tool("tach", "tach", "pip install tach", "module boundaries"),
35
+ "dot": Tool(
36
+ "dot", None, "brew install graphviz (or apt install graphviz)", "graph rendering"
37
+ ),
38
+ }
39
+
40
+
41
+ def _has_main_module(module: str) -> bool:
42
+ """True when ``python -m module`` makes sense: not every package has a __main__."""
43
+ try:
44
+ return importlib.util.find_spec(f"{module}.__main__") is not None
45
+ except (ImportError, ValueError, AttributeError):
46
+ return False
47
+
48
+
49
+ def resolve(name: str) -> list[str] | None:
50
+ """How to launch ``name`` here, or None when it cannot be found.
51
+
52
+ Look next to the running interpreter first: when pymap is installed in a
53
+ project's virtualenv and invoked by its full path, the venv's ``bin/`` is
54
+ not on PATH even though the tools live there. PATH takes over next, and
55
+ ``python -m`` is the last resort.
56
+ """
57
+ sibling = Path(sys.executable).parent
58
+ for path in (shutil.which(name, path=str(sibling)), shutil.which(name)):
59
+ if path:
60
+ return [path]
61
+ tool = TOOLS.get(name)
62
+ if tool and tool.module and _has_main_module(tool.module):
63
+ return [sys.executable, "-m", tool.module]
64
+ return None
65
+
66
+
67
+ def available(name: str) -> bool:
68
+ return resolve(name) is not None
69
+
70
+
71
+ def missing() -> list[Tool]:
72
+ """Tools that cannot be found, in declaration order."""
73
+ return [tool for name, tool in TOOLS.items() if not available(name)]
74
+
75
+
76
+ def run(
77
+ cmd: Sequence[str], cwd: str | None = None, timeout: int = 300, full: bool = False
78
+ ) -> tuple[bool, str]:
79
+ """Run a command. Pass ``full=True`` when the output has to be parsed.
80
+
81
+ Returns ``(succeeded, output)``. Without ``full`` the output is trimmed to
82
+ its last 1500 characters: enough to diagnose, not enough to drown a log.
83
+ """
84
+ try:
85
+ completed = subprocess.run(
86
+ cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout
87
+ )
88
+ output = completed.stdout + completed.stderr
89
+ return completed.returncode == 0, output if full else output[-1500:]
90
+ except subprocess.TimeoutExpired:
91
+ return False, f"timed out after {timeout}s"
92
+ except (FileNotFoundError, NotADirectoryError, PermissionError) as exc:
93
+ return False, f"command unreachable: {exc}"
pymap/settings.py ADDED
@@ -0,0 +1,176 @@
1
+ """Settings: what to analyse, where to write, what to skip.
2
+
3
+ A single place decides which files enter the map. Without that filter, running
4
+ pymap at a project root would swallow ``.venv/`` and ``node_modules/``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import fnmatch
11
+ import os
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ #: Directories and files never analysed: environments, caches, build artefacts.
18
+ EXCLUDED: tuple[str, ...] = tuple(
19
+ """
20
+ .git .hg .svn __pycache__ .venv venv env .env node_modules site-packages
21
+ build dist *.egg-info .tox .nox .mypy_cache .pytest_cache .ruff_cache
22
+ .ipynb_checkpoints .eggs pymap-out
23
+ """.split()
24
+ )
25
+
26
+ #: URI templates to jump from a diagram to the line of code.
27
+ #: ``{f}`` is the absolute file path, ``{l}`` the line number.
28
+ EDITORS: dict[str, str] = {
29
+ "vscode": "vscode://file/{f}:{l}",
30
+ "vscodium": "vscodium://file/{f}:{l}",
31
+ "cursor": "cursor://file/{f}:{l}",
32
+ "windsurf": "windsurf://file/{f}:{l}",
33
+ "zed": "zed://file/{f}:{l}",
34
+ "pycharm": "pycharm://open?file={f}&line={l}",
35
+ "idea": "idea://open?file={f}&line={l}",
36
+ "sublime": "subl://open?url=file://{f}&line={l}",
37
+ "none": "",
38
+ }
39
+
40
+ #: Directory names that are never a project's main package.
41
+ NOT_A_PACKAGE = set("tests test docs doc examples scripts tools benchmarks migrations".split())
42
+
43
+
44
+ def is_excluded(name: str, patterns: Sequence[str] = EXCLUDED) -> bool:
45
+ """True when ``name`` matches one of the exclusion patterns."""
46
+ return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
47
+
48
+
49
+ def python_files(root: Path, excluded: Sequence[str] = EXCLUDED) -> list[Path]:
50
+ """Sorted list of ``.py`` sources under ``root``, excluded branches pruned.
51
+
52
+ Pruning happens during the walk rather than after it, so a ``.venv`` holding
53
+ 30,000 files is never traversed.
54
+ """
55
+ found: list[Path] = []
56
+ for directory, subdirs, filenames in os.walk(root):
57
+ subdirs[:] = sorted(d for d in subdirs if not is_excluded(d, excluded))
58
+ for filename in sorted(filenames):
59
+ if filename.endswith(".py") and not is_excluded(filename, excluded):
60
+ found.append(Path(directory) / filename)
61
+ return found
62
+
63
+
64
+ def count_lines(files: Iterable[Path]) -> int:
65
+ """Total line count of the given files; unreadable ones count as zero."""
66
+ total = 0
67
+ for path in files:
68
+ with contextlib.suppress(OSError):
69
+ total += len(path.read_text(encoding="utf-8", errors="ignore").splitlines())
70
+ return total
71
+
72
+
73
+ def read_pyproject(start: Path) -> tuple[dict[str, Any], Path | None]:
74
+ """Return the contents of the nearest ``pyproject.toml`` and its path.
75
+
76
+ Walks up from ``start``. Without a TOML reader (Python < 3.11 and no
77
+ ``tomli`` installed) it returns an empty mapping: configuration is optional
78
+ and pymap must still run.
79
+ """
80
+ try:
81
+ import tomllib
82
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
83
+ try:
84
+ import tomli as tomllib # type: ignore[no-redef]
85
+ except ModuleNotFoundError:
86
+ return {}, None
87
+
88
+ for directory in [start, *start.parents]:
89
+ config = directory / "pyproject.toml"
90
+ if config.is_file():
91
+ try:
92
+ return tomllib.loads(config.read_text(encoding="utf-8")), config
93
+ except (OSError, ValueError):
94
+ return {}, config
95
+ return {}, None
96
+
97
+
98
+ def detect_target(start: Path) -> Path | None:
99
+ """Guess which package to map when no target is given.
100
+
101
+ In order: ``[tool.pymap] target``, a ``src/`` layout, the package named
102
+ after the project, then the single top-level package.
103
+ """
104
+ data, config = read_pyproject(start)
105
+ root = config.parent if config else start
106
+
107
+ declared = data.get("tool", {}).get("pymap", {}).get("target")
108
+ if declared:
109
+ path = (root / declared).resolve()
110
+ return path if path.is_dir() else None
111
+
112
+ def packages(directory: Path) -> list[Path]:
113
+ if not directory.is_dir():
114
+ return []
115
+ return sorted(
116
+ child
117
+ for child in directory.iterdir()
118
+ if child.is_dir()
119
+ and (child / "__init__.py").is_file()
120
+ and not is_excluded(child.name)
121
+ and child.name not in NOT_A_PACKAGE
122
+ )
123
+
124
+ under_src = packages(root / "src")
125
+ if len(under_src) == 1:
126
+ return under_src[0]
127
+
128
+ name = data.get("project", {}).get("name", "")
129
+ if name:
130
+ expected = root / name.replace("-", "_")
131
+ if (expected / "__init__.py").is_file():
132
+ return expected
133
+
134
+ top_level = packages(root)
135
+ if len(top_level) == 1:
136
+ return top_level[0]
137
+ return None
138
+
139
+
140
+ @dataclass
141
+ class Settings:
142
+ """Everything a run needs, resolved once and for all.
143
+
144
+ ``target`` and ``output`` are declared as :class:`~pathlib.Path` but accept
145
+ plain strings too: both are coerced and made absolute on construction.
146
+ """
147
+
148
+ target: Path
149
+ output: Path = Path("pymap-out")
150
+ excluded: Sequence[str] = EXCLUDED
151
+ editor: str = "vscode"
152
+ timeout: int = 300
153
+ open_in_browser: bool = False
154
+ _files: list[Path] | None = field(default=None, repr=False, compare=False)
155
+
156
+ def __post_init__(self) -> None:
157
+ self.target = Path(self.target).resolve()
158
+ self.output = Path(self.output).resolve()
159
+ self.excluded = tuple(self.excluded)
160
+
161
+ @property
162
+ def name(self) -> str:
163
+ return self.target.name
164
+
165
+ @property
166
+ def editor_uri(self) -> str:
167
+ """URI template for the chosen editor; empty string when disabled."""
168
+ if self.editor in EDITORS:
169
+ return EDITORS[self.editor]
170
+ return self.editor if "{f}" in self.editor else ""
171
+
172
+ def files(self) -> list[Path]:
173
+ """Analysable sources, computed once."""
174
+ if self._files is None:
175
+ self._files = python_files(self.target, self.excluded)
176
+ return self._files
@@ -0,0 +1,8 @@
1
+ """HTML templates, shipped as package data.
2
+
3
+ This file is what makes ``templates`` a regular package rather than a namespace
4
+ one. Without it, ``importlib.resources.files("pymap.templates")`` raises
5
+ ``TypeError`` on Python 3.9, where the fallback dereferences ``spec.origin`` --
6
+ which is ``None`` for a namespace package. Python 3.10 and later handle the
7
+ namespace case, so the failure only shows up on the oldest version we support.
8
+ """