hython-lang 2.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.
hython/exe_builder.py ADDED
@@ -0,0 +1,330 @@
1
+ """Build a standalone Windows executable containing one verified HBC program."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import importlib.util
7
+ import hashlib
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from .bytecode import CodeObject, loads
17
+ from .environment import external_source_root, hython_home, is_frozen
18
+
19
+
20
+ class ExeBuildError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def resolve_entry(source: Path, entry: str | None = None) -> tuple[Path,Path]:
25
+ """Resolve a single HBC or a project directory and its module root."""
26
+ source=source.resolve()
27
+ if source.is_file():
28
+ return source,source.parent
29
+ if not source.is_dir():
30
+ raise ExeBuildError(f"HBC 입력을 찾을 수 없습니다: {source}")
31
+ if entry:
32
+ candidate=(source/entry).resolve()
33
+ if source not in candidate.parents or not candidate.is_file():
34
+ raise ExeBuildError(f"프로젝트 entry를 찾을 수 없습니다: {entry}")
35
+ return candidate,source
36
+ candidates=[source/"main.hbc",source/"메인.hbc"]
37
+ found=[path for path in candidates if path.is_file()]
38
+ if not found:
39
+ top=sorted(source.glob("*.hbc"))
40
+ if len(top)==1:
41
+ found=top
42
+ if len(found)!=1:
43
+ raise ExeBuildError("프로젝트 entry를 결정할 수 없습니다. --entry를 지정하세요.")
44
+ return found[0],source
45
+
46
+
47
+ def imported_modules(code: CodeObject) -> set[str]:
48
+ """Collect Python import roots, including nested Hython code objects."""
49
+ found: set[str]=set()
50
+ seen: set[int]=set()
51
+
52
+ def visit(current: CodeObject) -> None:
53
+ if id(current) in seen:
54
+ return
55
+ seen.add(id(current))
56
+ for instruction in current.instructions:
57
+ if instruction and instruction[0]=="IMPORT" and len(instruction)>1 and isinstance(instruction[1],str):
58
+ name=instruction[1].lstrip(".").split(".",1)[0]
59
+ if name and re.fullmatch(r"[A-Za-z_]\w*",name):
60
+ found.add(name)
61
+ elif instruction and instruction[0]=="IMPORT_FROM" and len(instruction)>1 and isinstance(instruction[1],dict):
62
+ name=str(instruction[1].get("module","")).lstrip(".").split(".",1)[0]
63
+ if name and re.fullmatch(r"[A-Za-z_]\w*",name):
64
+ found.add(name)
65
+ for argument in instruction[1:]:
66
+ visit_value(argument)
67
+ for constant in current.constants:
68
+ visit_value(constant)
69
+
70
+ def visit_value(value) -> None:
71
+ if isinstance(value,CodeObject):
72
+ visit(value)
73
+ elif isinstance(value,dict):
74
+ if {"name","parameters","constants","instructions"} <= value.keys():
75
+ try:
76
+ visit(CodeObject.from_dict(value))
77
+ except (KeyError,TypeError,ValueError):
78
+ pass
79
+ else:
80
+ for item in value.values(): visit_value(item)
81
+ elif isinstance(value,(list,tuple)):
82
+ for item in value: visit_value(item)
83
+
84
+ visit(code)
85
+ return found
86
+
87
+
88
+ def _launcher_source(hbc: bytes) -> str:
89
+ return (
90
+ "# generated by Hython; the HBC payload is integrity-checked again at startup\n"
91
+ "import sys\n"
92
+ "from pathlib import Path\n"
93
+ "from hython.bytecode import loads\n"
94
+ "from hython.vm import VM\n"
95
+ f"HBC = bytes.fromhex({hbc.hex()!r})\n"
96
+ "def main():\n"
97
+ " root=Path(sys.executable).resolve().parent\n"
98
+ " frozen=Path(getattr(sys,'_MEIPASS',root))\n"
99
+ " bundle=frozen/'hbc_modules'\n"
100
+ " import os; os.environ.setdefault('HYTHON_RESOURCE_ROOT',str(frozen/'resources'))\n"
101
+ " VM([bundle,root]).run(loads(HBC))\n"
102
+ "if __name__ == '__main__':\n"
103
+ " main()\n"
104
+ )
105
+
106
+
107
+ def _pyinstaller_command(
108
+ launcher: Path, *, name: str, work: Path, console: bool, icon: Path | None,
109
+ imports: set[str], source_root: Path,
110
+ modules: list[tuple[Path,Path]],
111
+ resources: list[tuple[Path,Path]], onefile: bool = True,
112
+ version_file: Path | None = None, excludes: set[str] = frozenset(),
113
+ prefix: list[str] | None = None,
114
+ ) -> list[str]:
115
+ command=[
116
+ *(prefix or [sys.executable,"-m","PyInstaller"]),"--noconfirm","--clean",
117
+ "--onefile" if onefile else "--onedir",
118
+ "--name",name,"--distpath",str(work/"dist"),"--workpath",str(work/"build"),
119
+ "--specpath",str(work/"spec"),"--paths",str(source_root),
120
+ "--console" if console else "--windowed",
121
+ ]
122
+ distributions=importlib.metadata.packages_distributions()
123
+ if "tkinter" in imports:
124
+ imports.update({"tkinter.ttk","tkinter.messagebox","tkinter.filedialog","tkinter.colorchooser","tkinter.simpledialog"})
125
+ for module in sorted(imports):
126
+ command.extend(["--hidden-import",module])
127
+ if module in distributions:
128
+ command.extend(["--collect-all",module])
129
+ for module in sorted(excludes):
130
+ command.extend(["--exclude-module",module])
131
+ if icon is not None:
132
+ command.extend(["--icon",str(icon.resolve())])
133
+ for module,relative in modules:
134
+ destination=Path("hbc_modules")/relative.parent
135
+ command.extend(["--add-data",f"{module.resolve()}{os.pathsep}{destination.as_posix()}"])
136
+ for resource,destination in resources:
137
+ staged=work/"resource-stage"/destination
138
+ staged.parent.mkdir(parents=True,exist_ok=True)
139
+ if resource.is_dir():
140
+ shutil.copytree(resource,staged)
141
+ else:
142
+ shutil.copy2(resource,staged)
143
+ target=Path("resources")/destination.parent
144
+ command.extend(["--add-data",f"{staged.resolve()}{os.pathsep}{target.as_posix()}"])
145
+ if version_file is not None:
146
+ command.extend(["--version-file",str(version_file)])
147
+ command.append(str(launcher))
148
+ return command
149
+
150
+
151
+ def _pyinstaller_runtime() -> tuple[list[str],dict[str,str] | None,Path]:
152
+ if not is_frozen():
153
+ if importlib.util.find_spec("PyInstaller") is None:
154
+ raise ExeBuildError("PyInstaller가 없습니다: python -m pip install pyinstaller")
155
+ return [sys.executable,"-m","PyInstaller"],None,external_source_root()
156
+ from .runtime_manager import find_manager
157
+ tools=hython_home()/"tools"/"pyinstaller"
158
+ if not (tools/"PyInstaller").is_dir():
159
+ tools.mkdir(parents=True,exist_ok=True)
160
+ result=subprocess.run([
161
+ find_manager(),"exec","-V:default","-m","pip","install","--target",str(tools),"pyinstaller>=6.16"
162
+ ])
163
+ if result.returncode:
164
+ raise ExeBuildError("독립 EXE용 PyInstaller 도구 설치에 실패했습니다.")
165
+ env=os.environ.copy()
166
+ env["PYTHONPATH"]=str(tools)+os.pathsep+env.get("PYTHONPATH","")
167
+ return [find_manager(),"exec","-V:default","-m","PyInstaller"],env,external_source_root()
168
+
169
+
170
+ def _validate_resources(resources: list[tuple[Path,Path]]) -> list[tuple[Path,Path]]:
171
+ result=[]
172
+ for source,destination in resources:
173
+ if not source.exists():
174
+ raise ExeBuildError(f"리소스를 찾을 수 없습니다: {source}")
175
+ if destination.is_absolute() or ".." in destination.parts:
176
+ raise ExeBuildError(f"안전하지 않은 리소스 대상 경로: {destination}")
177
+ result.append((source,destination))
178
+ return result
179
+
180
+
181
+ def _version_source(metadata: dict[str,str]) -> str:
182
+ version=metadata.get("version","1.0.0.0")
183
+ try:
184
+ numbers=[int(item) for item in version.split(".")]
185
+ except ValueError as exc:
186
+ raise ExeBuildError("EXE 버전은 숫자와 점만 사용할 수 있습니다.") from exc
187
+ if not 1<=len(numbers)<=4 or any(item<0 or item>65535 for item in numbers):
188
+ raise ExeBuildError("EXE 버전은 0~65535의 숫자 1~4개여야 합니다.")
189
+ numbers=(numbers+[0]*4)[:4]; dotted=".".join(map(str,numbers))
190
+ product=metadata.get("product",metadata.get("name","Hython Program"))
191
+ description=metadata.get("description",product); company=metadata.get("company","")
192
+ copyright_=metadata.get("copyright",""); filename=metadata.get("filename","program.exe")
193
+ pairs={"CompanyName":company,"FileDescription":description,"FileVersion":dotted,
194
+ "InternalName":metadata.get("name",Path(filename).stem),"LegalCopyright":copyright_,
195
+ "OriginalFilename":filename,"ProductName":product,"ProductVersion":dotted}
196
+ strings=",\n".join(f"StringStruct({key!r}, {value!r})" for key,value in pairs.items())
197
+ return ("VSVersionInfo(ffi=FixedFileInfo(filevers="+repr(tuple(numbers))+", prodvers="+repr(tuple(numbers))+
198
+ ", mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0,0)), "
199
+ "kids=[StringFileInfo([StringTable('040904B0',["+strings+"]) ]), VarFileInfo([VarStruct('Translation',[1033,1200])])])\n")
200
+
201
+
202
+ def _sign(executable: Path, pfx: Path, password_env: str | None, timestamp_url: str) -> None:
203
+ tool=shutil.which("signtool")
204
+ if not tool:
205
+ raise ExeBuildError("signtool을 찾을 수 없습니다. Windows SDK를 설치하세요.")
206
+ if not pfx.is_file():
207
+ raise ExeBuildError(f"코드 서명 인증서를 찾을 수 없습니다: {pfx}")
208
+ command=[tool,"sign","/fd","SHA256","/f",str(pfx.resolve())]
209
+ if password_env:
210
+ password=os.environ.get(password_env)
211
+ if password is None:
212
+ raise ExeBuildError(f"서명 암호 환경 변수가 없습니다: {password_env}")
213
+ command.extend(["/p",password])
214
+ if timestamp_url:
215
+ command.extend(["/tr",timestamp_url,"/td","SHA256"])
216
+ command.append(str(executable))
217
+ result=subprocess.run(command,text=True,capture_output=True)
218
+ if result.returncode:
219
+ detail=(result.stderr or result.stdout).strip().splitlines()
220
+ raise ExeBuildError("Authenticode 서명 실패: "+(detail[-1] if detail else "알 수 없는 오류"))
221
+
222
+
223
+ def create_archive(artifact: Path, archive: Path) -> tuple[Path,Path]:
224
+ archive=archive.resolve()
225
+ if archive.suffix.lower() != ".zip": archive=archive.with_suffix(".zip")
226
+ archive.parent.mkdir(parents=True,exist_ok=True)
227
+ with tempfile.TemporaryDirectory(prefix="hython-archive-") as directory:
228
+ temporary=Path(directory)/archive.name
229
+ if artifact.is_dir():
230
+ shutil.make_archive(str(temporary.with_suffix("")),"zip",artifact.parent,artifact.name)
231
+ else:
232
+ with __import__("zipfile").ZipFile(temporary,"w",__import__("zipfile").ZIP_DEFLATED) as bundle:
233
+ bundle.write(artifact,artifact.name)
234
+ temporary.replace(archive)
235
+ digest=hashlib.sha256(archive.read_bytes()).hexdigest()
236
+ checksum=archive.with_suffix(archive.suffix+".sha256")
237
+ checksum.write_text(f"{digest} {archive.name}\n",encoding="ascii")
238
+ return archive,checksum
239
+
240
+
241
+ def build_exe(
242
+ hbc_path: Path, output: Path, *, console: bool = True, icon: Path | None = None,
243
+ module_root: Path | None = None, resources: list[tuple[Path,Path]] | None = None,
244
+ onefile: bool = True, metadata: dict[str,str] | None = None,
245
+ excludes: set[str] | None = None, sign_pfx: Path | None = None,
246
+ sign_password_env: str | None = None,
247
+ timestamp_url: str = "http://timestamp.digicert.com",
248
+ ) -> Path:
249
+ if os.name != "nt":
250
+ raise ExeBuildError("현재 EXE 빌더는 Windows만 지원합니다.")
251
+ prefix,build_env,source_root=_pyinstaller_runtime()
252
+ if icon is not None and not icon.is_file():
253
+ raise ExeBuildError(f"아이콘 파일을 찾을 수 없습니다: {icon}")
254
+ try:
255
+ hbc=hbc_path.read_bytes()
256
+ except OSError as exc:
257
+ raise ExeBuildError(f"HBC 파일을 읽을 수 없습니다: {exc}") from exc
258
+ code=loads(hbc)
259
+ module_root=(module_root or hbc_path.parent).resolve()
260
+ if not module_root.is_dir():
261
+ raise ExeBuildError(f"HBC 모듈 루트를 찾을 수 없습니다: {module_root}")
262
+ modules: list[tuple[Path,Path]]=[]
263
+ resources=_validate_resources(resources or [])
264
+ imports=imported_modules(code)
265
+ for module in sorted(module_root.rglob("*.hbc")):
266
+ if module.resolve()==hbc_path.resolve():
267
+ continue
268
+ try:
269
+ module_code=loads(module.read_bytes())
270
+ except (OSError,ValueError) as exc:
271
+ raise ExeBuildError(f"번들 HBC 모듈 검증 실패: {module}: {exc}") from exc
272
+ modules.append((module,module.relative_to(module_root)))
273
+ imports.update(imported_modules(module_code))
274
+ local_roots={relative.parts[0].removesuffix(".hbc") for _,relative in modules if relative.parts}
275
+ imports.difference_update(local_roots)
276
+ output=output.resolve()
277
+ output.parent.mkdir(parents=True,exist_ok=True)
278
+ safe_name=re.sub(r"[^A-Za-z0-9_.-]","_",output.stem) or "hython_program"
279
+ with tempfile.TemporaryDirectory(prefix="hython-exe-build-") as directory:
280
+ work=Path(directory); launcher=work/"launcher.py"
281
+ launcher.write_text(_launcher_source(hbc),encoding="utf-8",newline="\n")
282
+ version_file=None
283
+ if metadata:
284
+ version_file=work/"version_info.txt"
285
+ version_file.write_text(_version_source(metadata|{"filename":output.name}),encoding="utf-8")
286
+ command=_pyinstaller_command(
287
+ launcher,name=safe_name,work=work,console=console,icon=icon,
288
+ imports=imports,source_root=source_root,modules=modules,
289
+ resources=resources,onefile=onefile,version_file=version_file,
290
+ excludes=excludes or set(),
291
+ prefix=prefix,
292
+ )
293
+ # Frozen Hython runs in UTF-8 mode, while Windows build tools may emit
294
+ # CP949/OEM bytes. A diagnostic stream must never crash the reader
295
+ # thread after an otherwise successful build.
296
+ result=subprocess.run(
297
+ command,text=True,capture_output=True,errors="replace",env=build_env
298
+ )
299
+ if result.returncode:
300
+ detail=(result.stderr or result.stdout).strip().splitlines()
301
+ raise ExeBuildError("PyInstaller 빌드 실패: "+(detail[-1] if detail else "알 수 없는 오류"))
302
+ built=(work/"dist"/(safe_name+".exe")) if onefile else (work/"dist"/safe_name/(safe_name+".exe"))
303
+ if not built.is_file() or built.stat().st_size<1024:
304
+ raise ExeBuildError("PyInstaller가 유효한 EXE를 생성하지 않았습니다.")
305
+ if onefile:
306
+ handle,temporary=tempfile.mkstemp(prefix=output.name+".",suffix=".tmp",dir=output.parent)
307
+ os.close(handle); temporary_path=Path(temporary)
308
+ try:
309
+ shutil.copy2(built,temporary_path)
310
+ if sign_pfx: _sign(temporary_path,sign_pfx,sign_password_env,timestamp_url)
311
+ temporary_path.replace(output)
312
+ except BaseException:
313
+ temporary_path.unlink(missing_ok=True); raise
314
+ else:
315
+ built_dir=built.parent; staging=Path(tempfile.mkdtemp(prefix=output.name+".",dir=output.parent))
316
+ backup=None
317
+ try:
318
+ shutil.rmtree(staging); shutil.copytree(built_dir,staging)
319
+ if sign_pfx: _sign(staging/(safe_name+".exe"),sign_pfx,sign_password_env,timestamp_url)
320
+ if output.exists():
321
+ backup=output.with_name(output.name+".previous")
322
+ if backup.exists(): shutil.rmtree(backup)
323
+ output.replace(backup)
324
+ staging.replace(output)
325
+ if backup: shutil.rmtree(backup)
326
+ except BaseException:
327
+ shutil.rmtree(staging,ignore_errors=True)
328
+ if backup and backup.exists() and not output.exists(): backup.replace(output)
329
+ raise
330
+ return output