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/__init__.py +15 -0
- hython/__main__.py +3 -0
- hython/bytecode.py +368 -0
- hython/cli.py +449 -0
- hython/compiler.py +870 -0
- hython/compiler_manager.py +227 -0
- hython/diagnostics.py +158 -0
- hython/environment.py +48 -0
- hython/exe_builder.py +330 -0
- hython/frontend.py +870 -0
- hython/hir.py +93 -0
- hython/importer.py +76 -0
- hython/package_manager.py +343 -0
- hython/phonetics.py +49 -0
- hython/runtime.py +110 -0
- hython/runtime_manager.py +83 -0
- hython/translator.py +244 -0
- hython/updater.py +93 -0
- hython/vm.py +2409 -0
- hython/vocabulary.py +132 -0
- hython_lang-2.0.0.dist-info/METADATA +327 -0
- hython_lang-2.0.0.dist-info/RECORD +26 -0
- hython_lang-2.0.0.dist-info/WHEEL +5 -0
- hython_lang-2.0.0.dist-info/entry_points.txt +2 -0
- hython_lang-2.0.0.dist-info/licenses/LICENSE +22 -0
- hython_lang-2.0.0.dist-info/top_level.txt +1 -0
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
|