sphinx-java-autodoc 0.2.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.
@@ -0,0 +1,254 @@
1
+ """One compiler invocation per content fingerprint; no application code execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import xml.etree.ElementTree as ET
13
+
14
+ from sphinx.errors import ExtensionError
15
+
16
+ from .model import read_model
17
+
18
+ VERSION = "0.2.0" # x-release-please-version
19
+ DOCLET = Path(__file__).parent / "java" / "SphinxDoclet.java"
20
+
21
+
22
+ def run(arguments: list[str], *, cwd: Path | None = None) -> str:
23
+ try:
24
+ result = subprocess.run(
25
+ arguments,
26
+ cwd=cwd,
27
+ text=True,
28
+ encoding="utf-8",
29
+ errors="replace",
30
+ capture_output=True,
31
+ timeout=180,
32
+ )
33
+ except (OSError, subprocess.TimeoutExpired) as exc:
34
+ raise ExtensionError(
35
+ f"Java autodoc: could not run {arguments[0]}: {exc}"
36
+ ) from exc
37
+ if result.returncode:
38
+ raise ExtensionError(
39
+ f"Java autodoc: {Path(arguments[0]).name} failed (exit {result.returncode}). "
40
+ "Check JDK 25, source paths, --release and the compile classpath.\n"
41
+ + result.stdout
42
+ + result.stderr
43
+ )
44
+ return result.stdout + result.stderr
45
+
46
+
47
+ def atomic_write(path: Path, data: bytes) -> None:
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ if path.exists() and path.read_bytes() == data:
50
+ return
51
+ with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
52
+ temporary = Path(stream.name)
53
+ try:
54
+ stream.write(data)
55
+ stream.flush()
56
+ stream.close()
57
+ os.replace(temporary, path)
58
+ finally:
59
+ temporary.unlink(missing_ok=True)
60
+
61
+
62
+ def extract(
63
+ sources: list[Path],
64
+ classpath: list[Path],
65
+ cache: Path,
66
+ *,
67
+ jdk: str = "",
68
+ release: str = "",
69
+ sourcepath: list[Path] | None = None,
70
+ ) -> tuple[Path, str]:
71
+ """Return immutable XML path and input fingerprint. All inputs are content hashed."""
72
+ roots = [p.resolve() for p in sources]
73
+ dependencies = [p.resolve() for p in classpath]
74
+ lookup = [p.resolve() for p in sourcepath or []]
75
+ for path in lookup:
76
+ if not path.is_dir():
77
+ raise ExtensionError(
78
+ f"Java autodoc: source lookup directory does not exist: {path}"
79
+ )
80
+ files: set[Path] = set()
81
+ for path in roots:
82
+ if not path.exists():
83
+ raise ExtensionError(f"Java autodoc: source path does not exist: {path}")
84
+ found = list(path.rglob("*.java")) if path.is_dir() else [path]
85
+ if not found or any(p.suffix != ".java" for p in found):
86
+ raise ExtensionError(f"Java autodoc: no Java source files in {path}")
87
+ files.update(found)
88
+ if not files:
89
+ raise ExtensionError(
90
+ "Java autodoc: no sources configured; set java_sources or java_model."
91
+ )
92
+ semantic_files = files | {p for root in lookup for p in root.rglob("*.java")}
93
+ if any(p.name == "module-info.java" for p in semantic_files):
94
+ raise ExtensionError(
95
+ "Java autodoc: JPMS module-info.java inputs are deferred; supply non-modular source roots or individual Java files and a compile classpath."
96
+ )
97
+ for path in dependencies:
98
+ if not path.exists():
99
+ raise ExtensionError(
100
+ f"Java autodoc: classpath entry does not exist: {path}"
101
+ )
102
+ tools = {}
103
+ for name in ("java", "javac", "javadoc"):
104
+ executable = str(Path(jdk) / "bin" / name) if jdk else shutil.which(name)
105
+ if not executable:
106
+ raise ExtensionError(
107
+ f"Java autodoc: {name} not found; install JDK 25 and set java_jdk."
108
+ )
109
+ tools[name] = executable
110
+ identity = run([tools["java"], "-XshowSettings:properties", "-version"])
111
+ # Use the actual runtime home, avoiding mixed tools from PATH and macOS launchers.
112
+ home = next(
113
+ (
114
+ line.split("=", 1)[1].strip()
115
+ for line in identity.splitlines()
116
+ if line.strip().startswith("java.home =")
117
+ ),
118
+ "",
119
+ )
120
+ version = next(
121
+ (
122
+ line.split("=", 1)[1].strip()
123
+ for line in identity.splitlines()
124
+ if line.strip().startswith("java.version =")
125
+ ),
126
+ "",
127
+ )
128
+ if version.split(".")[0] != "25":
129
+ raise ExtensionError(
130
+ f"Java autodoc: JDK 25 is required for this release; found {version or 'unknown'}."
131
+ )
132
+ if home:
133
+ tools = {name: str(Path(home) / "bin" / name) for name in tools}
134
+ digest = hashlib.sha256()
135
+
136
+ def update(data: bytes) -> None:
137
+ digest.update(len(data).to_bytes(8, "big"))
138
+ digest.update(data)
139
+
140
+ update(
141
+ json.dumps(
142
+ [
143
+ VERSION,
144
+ version,
145
+ home,
146
+ release,
147
+ [str(p) for p in roots],
148
+ [str(p) for p in dependencies],
149
+ [str(p) for p in lookup],
150
+ ]
151
+ ).encode()
152
+ )
153
+ update(DOCLET.read_bytes())
154
+ update(Path(__file__).read_bytes())
155
+ for path in sorted(semantic_files):
156
+ update(str(path).encode())
157
+ update(path.read_bytes())
158
+ for path in dependencies:
159
+ for entry in sorted(path.rglob("*")) if path.is_dir() else [path]:
160
+ if entry.is_file():
161
+ update(str(entry).encode())
162
+ update(entry.read_bytes())
163
+ # JDK replacement at the same path must not reuse an old compiler result.
164
+ for name in ("release", "lib/modules"):
165
+ path = Path(home) / name
166
+ if path.is_file():
167
+ with path.open("rb") as stream:
168
+ while block := stream.read(1024 * 1024):
169
+ digest.update(block)
170
+ fingerprint = digest.hexdigest()
171
+ cache.mkdir(parents=True, exist_ok=True)
172
+ destination = cache / f"{fingerprint}.xml"
173
+ if destination.exists():
174
+ read_model(destination)
175
+ return destination, fingerprint
176
+ with tempfile.TemporaryDirectory(prefix="extract-", dir=cache) as directory:
177
+ work = Path(directory)
178
+ classes = work / "classes"
179
+ run(
180
+ [
181
+ tools["javac"],
182
+ "-encoding",
183
+ "UTF-8",
184
+ "-proc:none",
185
+ "-d",
186
+ str(classes),
187
+ str(DOCLET),
188
+ ]
189
+ )
190
+ output = work / "api.xml"
191
+ args = [
192
+ "-locale",
193
+ "en_US",
194
+ "-quiet",
195
+ "-encoding",
196
+ "UTF-8",
197
+ "-protected",
198
+ "-doclet",
199
+ "sphinxjava.SphinxDoclet",
200
+ "-docletpath",
201
+ str(classes),
202
+ "--sphinx-output",
203
+ str(output),
204
+ ]
205
+ if release:
206
+ args += ["--release", str(release)]
207
+ # An explicit empty classpath avoids implicitly reading classes from cwd.
208
+ empty = work / "empty"
209
+ empty.mkdir()
210
+ args += ["-classpath", os.pathsep.join(map(str, dependencies)) or str(empty)]
211
+ args += [
212
+ "-sourcepath",
213
+ os.pathsep.join(
214
+ str(p) for p in dict.fromkeys([p for p in roots if p.is_dir()] + lookup)
215
+ )
216
+ or str(empty),
217
+ ]
218
+ args += [str(p) for p in sorted(files)]
219
+ # JDK argument files avoid command length limits; no shell interpolation.
220
+ argfile = work / "javadoc.args"
221
+ argfile.write_text(
222
+ "\n".join(
223
+ '"'
224
+ + s.replace("\\", "\\\\")
225
+ .replace('"', '\\"')
226
+ .replace("\n", "\\n")
227
+ .replace("\r", "\\r")
228
+ + '"'
229
+ for s in args
230
+ ),
231
+ encoding="utf-8",
232
+ )
233
+ diagnostics = run([tools["javadoc"], "@" + str(argfile)], cwd=work)
234
+ root = read_model(output)
235
+ root.set("extractor", VERSION)
236
+ if diagnostics.strip():
237
+ # Compiler warnings must remain visible on cache hits too.
238
+ labels = {str(work): "<extract>"}
239
+ for name, paths in (
240
+ ("source", roots),
241
+ ("sourcepath", lookup),
242
+ ("classpath", dependencies),
243
+ ):
244
+ for index, path in enumerate(paths):
245
+ labels.setdefault(str(path), f"<{name}:{index}>")
246
+ message = diagnostics.strip()
247
+ # Longest first, so a root like src does not relabel part of src-gen.
248
+ for path in sorted(labels, key=len, reverse=True):
249
+ message = message.replace(path, labels[path])
250
+ ET.SubElement(root, "diagnostic").text = message
251
+ atomic_write(
252
+ destination, ET.tostring(root, encoding="utf-8", xml_declaration=True)
253
+ )
254
+ return destination, fingerprint