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/hir.py ADDED
@@ -0,0 +1,93 @@
1
+ """Hython Intermediate Representation and conservative optimizer."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ import operator
5
+
6
+ @dataclass
7
+ class HIRCode:
8
+ name: str
9
+ parameters: list[str]
10
+ constants: list[object] = field(default_factory=list)
11
+ instructions: list[list] = field(default_factory=list)
12
+ lines: list[int] = field(default_factory=list)
13
+
14
+ def to_dict(self) -> dict:
15
+ return {"name": self.name, "parameters": self.parameters, "constants": self.constants, "instructions": self.instructions,"lines":self.lines}
16
+
17
+ @classmethod
18
+ def from_dict(cls,value:dict) -> "HIRCode":
19
+ return cls(value["name"],value["parameters"],value["constants"],value["instructions"],value.get("lines",[]))
20
+
21
+ _BINARY = {"ADD":operator.add,"SUB":operator.sub,"MUL":operator.mul,"DIV":operator.truediv,
22
+ "FLOORDIV":operator.floordiv,"MOD":operator.mod,"POW":operator.pow,
23
+ "EQ":operator.eq,"NE":operator.ne,"LT":operator.lt,"LE":operator.le,
24
+ "GT":operator.gt,"GE":operator.ge,"IS":operator.is_}
25
+ _UNARY = {"NEG":operator.neg,"POS":operator.pos,"NOT":operator.not_}
26
+
27
+ def optimize_hir(code: HIRCode) -> HIRCode:
28
+ """Fold safe constants without changing instruction offsets."""
29
+ ins = code.instructions
30
+ for index in range(len(ins) - 2):
31
+ first, second, third = ins[index:index + 3]
32
+ if first[0] == second[0] == "CONST" and third[0] in _BINARY:
33
+ try:
34
+ value = _BINARY[third[0]](code.constants[first[1]], code.constants[second[1]])
35
+ except Exception:
36
+ continue
37
+ if isinstance(value, (type(None), bool, int, float, str)):
38
+ code.constants.append(value)
39
+ ins[index:index + 3] = [["CONST", len(code.constants)-1], ["NOP"], ["NOP"]]
40
+ for index in range(len(ins) - 1):
41
+ first, second = ins[index:index + 2]
42
+ if first[0] == "CONST" and second[0] in _UNARY:
43
+ try: value = _UNARY[second[0]](code.constants[first[1]])
44
+ except Exception: continue
45
+ code.constants.append(value)
46
+ ins[index:index + 2] = [["CONST", len(code.constants)-1], ["NOP"]]
47
+ for instruction in ins:
48
+ if instruction[0] in ("MAKE_FUNCTION","MAKE_CLASS"):
49
+ nested = instruction[1]["code"]
50
+ child = HIRCode.from_dict(nested)
51
+ optimized=optimize_hir(child).to_dict()
52
+ instruction[1]["code"]=optimized
53
+ elif instruction[0]=="TRY":
54
+ payload=instruction[1]
55
+ for key in ("body","else","finally"):
56
+ nested=payload.get(key)
57
+ if nested:
58
+ payload[key]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
59
+ for handler in payload["handlers"]:
60
+ nested=handler["code"]
61
+ handler["code"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
62
+ elif instruction[0]=="COMPREHENSION":
63
+ payload=instruction[1]
64
+ for key in ("element","key","value"):
65
+ nested=payload.get(key)
66
+ if nested: payload[key]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
67
+ for clause in payload["clauses"]:
68
+ nested=clause["iter"]
69
+ clause["iter"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
70
+ clause["filters"]=[optimize_hir(HIRCode.from_dict(n)).to_dict() for n in clause["filters"]]
71
+ elif instruction[0]=="WITH":
72
+ payload=instruction[1]; nested=payload["body"]
73
+ payload["body"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
74
+ for manager in payload["managers"]:
75
+ nested=manager["code"]
76
+ manager["code"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
77
+ elif instruction[0] in ("ASYNC_FOR","ASYNC_WITH"):
78
+ payload=instruction[1]
79
+ for key in ("iter","body","else"):
80
+ nested=payload.get(key)
81
+ if nested: payload[key]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
82
+ for manager in payload.get("managers",[]):
83
+ nested=manager["code"]; manager["code"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
84
+ elif instruction[0]=="ANNOTATE_LAZY":
85
+ nested=instruction[1]["code"]; instruction[1]["code"]=optimize_hir(HIRCode.from_dict(nested)).to_dict()
86
+ return code
87
+
88
+ def format_hir(code: HIRCode) -> str:
89
+ lines = [f"HIR {code.name}({', '.join(code.parameters)})"]
90
+ for index, instruction in enumerate(code.instructions):
91
+ argument = " ".join(map(str, instruction[1:]))
92
+ lines.append(f"{index:04d} {instruction[0]:14} {argument}")
93
+ return "\n".join(lines)
hython/importer.py ADDED
@@ -0,0 +1,76 @@
1
+ """Import hook for loading ``.hy`` source modules and packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.abc
6
+ import importlib.machinery
7
+ import importlib.util
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .translator import compile_hython
12
+
13
+
14
+ class HythonLoader(importlib.abc.SourceLoader):
15
+ """Load a Hython source file as a normal Python module."""
16
+
17
+ def __init__(self, fullname: str, path: Path) -> None:
18
+ self.fullname = fullname
19
+ self.path = path
20
+
21
+ def get_filename(self, fullname: str) -> str:
22
+ return str(self.path)
23
+
24
+ def get_data(self, path: str) -> bytes:
25
+ return Path(path).read_bytes()
26
+
27
+ def get_code(self, fullname: str):
28
+ source = self.path.read_text(encoding="utf-8-sig")
29
+ return compile_hython(source, str(self.path))
30
+
31
+ def set_data(self, path: str, data: bytes, *, _mode: int = 0o666) -> None:
32
+ # Do not create misleading CPython .pyc cache files for Hython sources.
33
+ return None
34
+
35
+
36
+ class HythonFinder(importlib.abc.MetaPathFinder):
37
+ """Find ``name.hy`` and ``name/__init__.hy`` on the import path."""
38
+
39
+ marker = "hython-finder-v1"
40
+
41
+ def find_spec(self, fullname: str, path=None, target=None):
42
+ name = fullname.rpartition(".")[2]
43
+ search_paths = sys.path if path is None else path
44
+ for entry in search_paths:
45
+ base = Path(entry or ".")
46
+ package_file = base / name / "__init__.hy"
47
+ if package_file.is_file():
48
+ loader = HythonLoader(fullname, package_file)
49
+ return importlib.util.spec_from_file_location(
50
+ fullname,
51
+ package_file,
52
+ loader=loader,
53
+ submodule_search_locations=[str(package_file.parent)],
54
+ )
55
+ module_file = base / f"{name}.hy"
56
+ if module_file.is_file():
57
+ return importlib.util.spec_from_file_location(
58
+ fullname, module_file, loader=HythonLoader(fullname, module_file)
59
+ )
60
+ return None
61
+
62
+
63
+ def install_importer() -> HythonFinder:
64
+ """Install the finder once, ahead of Python's normal path finder."""
65
+ for finder in sys.meta_path:
66
+ if isinstance(finder, HythonFinder):
67
+ return finder
68
+ finder = HythonFinder()
69
+ sys.meta_path.insert(0, finder)
70
+ return finder
71
+
72
+
73
+ def uninstall_importer() -> None:
74
+ """Remove all installed Hython finders (mainly useful for tests)."""
75
+ sys.meta_path[:] = [finder for finder in sys.meta_path if not isinstance(finder, HythonFinder)]
76
+
@@ -0,0 +1,343 @@
1
+ """Package installation and safe, static public-API dictionary generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.machinery
7
+ import importlib.metadata
8
+ import json
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import sysconfig
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from .phonetics import pronounce_identifier
17
+ from .environment import is_frozen, package_store
18
+
19
+
20
+ _DEEP_SCAN_SCRIPT = r"""
21
+ import importlib, inspect, json, sys
22
+ if sys.argv[2]:
23
+ sys.path.insert(0, sys.argv[2])
24
+ module = importlib.import_module(sys.argv[1])
25
+ names = set()
26
+ def visit(obj, depth=0):
27
+ try:
28
+ members = inspect.getmembers_static(obj)
29
+ except Exception:
30
+ return
31
+ for name, value in members:
32
+ if not name.startswith("_"):
33
+ names.add(name)
34
+ if depth == 0 and inspect.isclass(value):
35
+ visit(value, 1)
36
+ visit(module)
37
+ print("__HYTHON_API__" + json.dumps(sorted(names), ensure_ascii=False))
38
+ """
39
+
40
+ _DICTIONARY_CACHE_KEY: tuple[tuple[str, int, int], ...] | None = None
41
+ _DICTIONARY_CACHE: dict[str, str] = {}
42
+
43
+
44
+ def infer_module_name(spec: str) -> str:
45
+ """Infer an import name from a PEP 508-style distribution specifier."""
46
+ match=re.match(r"\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)",spec)
47
+ if not match or spec[match.end():match.end()+1] not in ("","[","<",">","=","!","~","@"," ","\t"):
48
+ raise ValueError("패키지 지정자에서 모듈명을 추론할 수 없습니다. --module을 지정하세요.")
49
+ return match.group(1).replace("-","_")
50
+
51
+
52
+ def modules_for_distribution(spec: str, fallback: str | None = None) -> list[str]:
53
+ """Return every top-level import supplied by a distribution."""
54
+ distribution = infer_module_name(spec)
55
+ normalized = re.sub(r"[-_.]+", "-", distribution).lower()
56
+ modules = {
57
+ module
58
+ for module, distributions in importlib.metadata.packages_distributions().items()
59
+ if any(re.sub(r"[-_.]+", "-", item).lower() == normalized for item in distributions)
60
+ and module.isidentifier()
61
+ }
62
+ if fallback:
63
+ modules.add(fallback)
64
+ elif not modules:
65
+ modules.add(distribution)
66
+ return sorted(modules)
67
+
68
+
69
+ def dictionary_dir() -> Path:
70
+ root = Path(__import__("os").environ.get("HYTHON_HOME", Path.home() / ".hython")) / "dictionaries"
71
+ root.mkdir(parents=True, exist_ok=True)
72
+ return root
73
+
74
+
75
+ def install(package: str, *, upgrade: bool = False) -> None:
76
+ """Install through the current interpreter; subprocess avoids pip internals."""
77
+ if is_frozen():
78
+ from .runtime_manager import find_manager
79
+ command=[find_manager(),"exec","-V:default","-m","pip","install","--target",str(package_store())]
80
+ else:
81
+ command = [sys.executable, "-m", "pip", "install"]
82
+ if upgrade:
83
+ command.append("--upgrade")
84
+ command.append(package)
85
+ subprocess.run(command, check=True)
86
+
87
+
88
+ def uninstall(package: str) -> None:
89
+ """Remove a distribution through pip."""
90
+ if is_frozen():
91
+ from .runtime_manager import find_manager
92
+ env=__import__("os").environ.copy()
93
+ env["PYTHONPATH"]=str(package_store())+__import__("os").pathsep+env.get("PYTHONPATH","")
94
+ command=[find_manager(),"exec","-V:default","-m","pip","uninstall","-y",package]
95
+ subprocess.run(command,check=True,env=env)
96
+ else:
97
+ subprocess.run([sys.executable,"-m","pip","uninstall","-y",package],check=True)
98
+
99
+
100
+ def _public_names(path: Path) -> set[str]:
101
+ names: set[str] = set()
102
+ try:
103
+ tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
104
+ except (OSError, UnicodeError, SyntaxError):
105
+ return names
106
+ explicit_all: set[str] = set()
107
+ def collect_node(node: ast.AST) -> None:
108
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
109
+ if not node.name.startswith("_"):
110
+ names.add(node.name)
111
+ # Public methods and nested public API classes are part of the
112
+ # spelling surface too (for example tkinter's title/mainloop).
113
+ if isinstance(node, ast.ClassDef):
114
+ for child in node.body:
115
+ collect_node(child)
116
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
117
+ for alias in node.names:
118
+ exposed = alias.asname or alias.name.split(".")[0]
119
+ if exposed != "*" and not exposed.startswith("_"):
120
+ names.add(exposed)
121
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
122
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
123
+ for target in targets:
124
+ if isinstance(target, ast.Name) and not target.id.startswith("_"):
125
+ names.add(target.id)
126
+ if isinstance(node, ast.Assign) and any(
127
+ isinstance(target, ast.Name) and target.id == "__all__" for target in node.targets
128
+ ) and isinstance(node.value, (ast.List, ast.Tuple)):
129
+ explicit_all.update(
130
+ item.value for item in node.value.elts
131
+ if isinstance(item, ast.Constant) and isinstance(item.value, str)
132
+ )
133
+ for node in tree.body:
134
+ collect_node(node)
135
+ return explicit_all or names
136
+
137
+
138
+ def _deep_public_names(module: str, timeout: int = 30) -> set[str]:
139
+ """Inspect extension/dynamic exports in an isolated child interpreter."""
140
+ if is_frozen():
141
+ from .runtime_manager import find_manager
142
+ command = [
143
+ find_manager(), "exec", "-V:default", "-c", _DEEP_SCAN_SCRIPT,
144
+ module, str(package_store()),
145
+ ]
146
+ else:
147
+ command = [sys.executable, "-I", "-c", _DEEP_SCAN_SCRIPT, module, ""]
148
+ result = subprocess.run(
149
+ command, text=True, capture_output=True, errors="replace", timeout=timeout
150
+ )
151
+ if result.returncode:
152
+ detail = (result.stderr or result.stdout).strip().splitlines()
153
+ raise RuntimeError(
154
+ "패키지 심층 API 분석 실패: " + (detail[-1] if detail else module)
155
+ )
156
+ marker = next(
157
+ (line.removeprefix("__HYTHON_API__") for line in result.stdout.splitlines()
158
+ if line.startswith("__HYTHON_API__")),
159
+ None,
160
+ )
161
+ if marker is None:
162
+ raise RuntimeError("패키지 심층 API 분석 결과를 읽을 수 없습니다.")
163
+ payload = json.loads(marker)
164
+ return {name for name in payload if isinstance(name, str)}
165
+
166
+
167
+ def _unique_spellings(
168
+ names: set[str], reserved: dict[str, str] | None = None
169
+ ) -> dict[str, str]:
170
+ """Give every public API a deterministic, collision-free Hangul spelling."""
171
+ result: dict[str, str] = {}
172
+ occupied: dict[str, str] = dict(reserved or {})
173
+ for name in sorted(names, key=lambda item: (pronounce_identifier(item), item)):
174
+ base = pronounce_identifier(name)
175
+ spoken = base
176
+ suffix = 2
177
+ while spoken in occupied and occupied[spoken] != name:
178
+ spoken = f"{base}{suffix}"
179
+ suffix += 1
180
+ result[name] = spoken
181
+ occupied[spoken] = name
182
+ return result
183
+
184
+
185
+ def scan(module: str, *, deep: bool = False) -> Path:
186
+ """Statically scan a module/package without importing or executing it."""
187
+ spec = _find_spec_static(module)
188
+ if spec is None:
189
+ raise ModuleNotFoundError(f"패키지를 찾을 수 없습니다: {module}")
190
+ files: list[Path] = []
191
+ if spec.submodule_search_locations:
192
+ for location in spec.submodule_search_locations:
193
+ files.extend(Path(location).rglob("*.py"))
194
+ elif spec.origin and spec.origin.endswith(".py"):
195
+ files.append(Path(spec.origin))
196
+ # The module spelling itself must be translatable in an import statement.
197
+ names: set[str] = {part for part in module.split(".") if not part.startswith("_")}
198
+ for file in files:
199
+ names.update(_public_names(file))
200
+ if deep:
201
+ names.update(_deep_public_names(module))
202
+ from .vocabulary import HYTHON_TO_PYTHON
203
+ python_to_hython = _unique_spellings(
204
+ names, load_dictionaries() | HYTHON_TO_PYTHON
205
+ )
206
+ payload = {
207
+ "format": 1,
208
+ "module": module,
209
+ "scan_mode": "deep" if deep else "static",
210
+ "distribution_version": _version_for(module),
211
+ "python_to_hython": python_to_hython,
212
+ }
213
+ output = dictionary_dir() / f"{module.replace('.', '__')}.json"
214
+ _atomic_json(output, payload)
215
+ return output
216
+
217
+
218
+ def scan_standard_library() -> Path:
219
+ """Build one collision-free dictionary for the active Python stdlib."""
220
+ root = Path(sysconfig.get_paths()["stdlib"])
221
+ names = {
222
+ name for name in getattr(sys, "stdlib_module_names", ())
223
+ if isinstance(name, str) and not name.startswith("_")
224
+ }
225
+ for file in root.rglob("*.py"):
226
+ lowered = {part.lower() for part in file.parts}
227
+ if "site-packages" in lowered or "dist-packages" in lowered:
228
+ continue
229
+ names.update(_public_names(file))
230
+ from .vocabulary import HYTHON_TO_PYTHON
231
+ spellings = _unique_spellings(names, load_dictionaries() | HYTHON_TO_PYTHON)
232
+ output = dictionary_dir() / "_stdlib.json"
233
+ _atomic_json(output, {
234
+ "format": 1,
235
+ "module": "__stdlib__",
236
+ "scan_mode": "stdlib",
237
+ "python_version": sys.version.split()[0],
238
+ "python_to_hython": spellings,
239
+ })
240
+ return output
241
+
242
+
243
+ def _atomic_json(output: Path, payload: dict) -> None:
244
+ handle, temporary = tempfile.mkstemp(prefix=output.name + ".", suffix=".tmp", dir=output.parent)
245
+ try:
246
+ with __import__("os").fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
247
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
248
+ stream.write("\n")
249
+ stream.flush()
250
+ __import__("os").fsync(stream.fileno())
251
+ Path(temporary).replace(output)
252
+ except BaseException:
253
+ Path(temporary).unlink(missing_ok=True)
254
+ raise
255
+
256
+
257
+ def remove_dictionary(module: str) -> bool:
258
+ path = dictionary_dir() / f"{module.replace('.', '__')}.json"
259
+ existed = path.exists()
260
+ path.unlink(missing_ok=True)
261
+ return existed
262
+
263
+
264
+ def refresh_dictionaries() -> tuple[list[Path], list[Path]]:
265
+ """Rescan installed modules and prune dictionaries whose packages disappeared."""
266
+ refreshed: list[Path] = []
267
+ removed: list[Path] = []
268
+ for path in sorted(dictionary_dir().glob("*.json")):
269
+ try:
270
+ payload = json.loads(path.read_text(encoding="utf-8"))
271
+ module = payload["module"]
272
+ if not isinstance(module, str):
273
+ continue
274
+ except (OSError, UnicodeError, json.JSONDecodeError, KeyError):
275
+ continue
276
+ if module == "__stdlib__":
277
+ refreshed.append(scan_standard_library())
278
+ elif _find_spec_static(module) is None:
279
+ path.unlink(missing_ok=True)
280
+ removed.append(path)
281
+ else:
282
+ refreshed.append(scan(module, deep=payload.get("scan_mode") == "deep"))
283
+ return refreshed, removed
284
+
285
+
286
+ def _find_spec_static(module: str):
287
+ """Resolve dotted modules without importing their parent packages."""
288
+ if not module or any(not part for part in module.split(".")):
289
+ return None
290
+ search_path = None
291
+ fullname = ""
292
+ spec = None
293
+ for part in module.split("."):
294
+ fullname = f"{fullname}.{part}" if fullname else part
295
+ spec = importlib.machinery.PathFinder.find_spec(fullname, search_path)
296
+ if spec is None:
297
+ return None
298
+ search_path = spec.submodule_search_locations
299
+ return spec
300
+
301
+
302
+ def _version_for(module: str) -> str | None:
303
+ packages = importlib.metadata.packages_distributions()
304
+ distributions = packages.get(module.split(".")[0], [])
305
+ if not distributions:
306
+ return None
307
+ try:
308
+ return importlib.metadata.version(distributions[0])
309
+ except importlib.metadata.PackageNotFoundError:
310
+ return None
311
+
312
+
313
+ def load_dictionaries() -> dict[str, str]:
314
+ """Load valid user dictionaries as Hython-to-Python mappings."""
315
+ global _DICTIONARY_CACHE_KEY, _DICTIONARY_CACHE
316
+ paths = sorted(dictionary_dir().glob("*.json"))
317
+ key = tuple(
318
+ (str(path), stat.st_mtime_ns, stat.st_size)
319
+ for path in paths
320
+ if (stat := path.stat())
321
+ )
322
+ if key == _DICTIONARY_CACHE_KEY:
323
+ return dict(_DICTIONARY_CACHE)
324
+ result: dict[str, str] = {}
325
+ for path in paths:
326
+ try:
327
+ payload = json.loads(path.read_text(encoding="utf-8"))
328
+ entries = payload["python_to_hython"]
329
+ if not isinstance(entries, dict):
330
+ continue
331
+ for python_name, spoken in entries.items():
332
+ if isinstance(python_name, str) and isinstance(spoken, str):
333
+ result.setdefault(spoken, python_name)
334
+ except (OSError, UnicodeError, json.JSONDecodeError, KeyError):
335
+ continue
336
+ _DICTIONARY_CACHE_KEY = key
337
+ _DICTIONARY_CACHE = dict(result)
338
+ return result
339
+
340
+
341
+ def load_python_dictionaries() -> dict[str, str]:
342
+ """Load valid user dictionaries as Python-to-Hython mappings."""
343
+ return {python: spoken for spoken, python in load_dictionaries().items()}
hython/phonetics.py ADDED
@@ -0,0 +1,49 @@
1
+ """Deterministic English-identifier to Hangul phonetic approximation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Longest fragments are applied first. This is deliberately deterministic: users
8
+ # may edit generated dictionaries when a package's preferred pronunciation differs.
9
+ _SOUNDS = {
10
+ "tion": "션", "sion": "전", "ough": "오", "ight": "아이트",
11
+ "ph": "프", "sh": "시", "ch": "치", "th": "스", "wh": "우",
12
+ "ck": "크", "qu": "쿠", "ng": "응", "ee": "이", "oo": "우",
13
+ "ai": "에이", "ay": "에이", "ea": "이", "ou": "아우", "ow": "오우",
14
+ "a": "아", "b": "브", "c": "크", "d": "드", "e": "에",
15
+ "f": "프", "g": "그", "h": "흐", "i": "이", "j": "제이",
16
+ "k": "크", "l": "르", "m": "므", "n": "느", "o": "오",
17
+ "p": "프", "q": "큐", "r": "르", "s": "스", "t": "트",
18
+ "u": "유", "v": "브", "w": "우", "x": "엑스", "y": "이",
19
+ "z": "즈",
20
+ }
21
+ _PARTS = sorted(_SOUNDS, key=len, reverse=True)
22
+
23
+
24
+ def pronounce_word(word: str) -> str:
25
+ """Return a stable Hangul approximation for one ASCII word."""
26
+ result: list[str] = []
27
+ lowered = word.lower()
28
+ index = 0
29
+ while index < len(lowered):
30
+ if lowered[index].isdigit():
31
+ result.append(lowered[index])
32
+ index += 1
33
+ continue
34
+ for part in _PARTS:
35
+ if lowered.startswith(part, index):
36
+ result.append(_SOUNDS[part])
37
+ index += len(part)
38
+ break
39
+ else:
40
+ result.append(lowered[index])
41
+ index += 1
42
+ return "".join(result)
43
+
44
+
45
+ def pronounce_identifier(name: str) -> str:
46
+ """Pronounce snake_case and CamelCase identifiers while preserving underscores."""
47
+ expanded = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", name)
48
+ expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", expanded)
49
+ return "_".join(pronounce_word(part) for part in expanded.split("_"))
hython/runtime.py ADDED
@@ -0,0 +1,110 @@
1
+ """Python runtime discovery and Hython syntax compatibility profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import keyword
7
+ import platform
8
+ import sys
9
+ import sysconfig
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from .phonetics import pronounce_identifier
14
+ from .translator import to_python
15
+ from .vocabulary import KEYWORDS
16
+ from .environment import display_executable
17
+
18
+
19
+ def profile_dir() -> Path:
20
+ path = Path(__import__("os").environ.get("HYTHON_HOME", Path.home() / ".hython")) / "runtimes"
21
+ path.mkdir(parents=True, exist_ok=True)
22
+ return path
23
+
24
+
25
+ def inspect_runtime() -> dict:
26
+ """Inspect the active interpreter instead of assuming a Python grammar version."""
27
+ hard = sorted(keyword.kwlist)
28
+ soft = sorted(getattr(keyword, "softkwlist", []))
29
+ known = set(KEYWORDS)
30
+ # Pattern wildcard `_` is intentionally identical in Hython.
31
+ discovered = [name for name in hard + soft if name not in known and name != "_"]
32
+ return {
33
+ "format": 1,
34
+ "implementation": platform.python_implementation(),
35
+ "version": platform.python_version(),
36
+ "version_info": list(sys.version_info[:3]),
37
+ "executable": str(display_executable()),
38
+ "platform": sysconfig.get_platform(),
39
+ "hard_keywords": hard,
40
+ "soft_keywords": soft,
41
+ "new_keywords": discovered,
42
+ "generated_spellings": {
43
+ name: pronounce_identifier(name) for name in discovered
44
+ },
45
+ }
46
+
47
+
48
+ def sync_runtime() -> Path:
49
+ """Persist a reproducible profile for the active Python runtime."""
50
+ profile = inspect_runtime()
51
+ output = profile_dir() / f"python-{profile['version']}.json"
52
+ _atomic_json(output, profile)
53
+ # Keep the entire standard-library API pronunciation layer matched to the
54
+ # selected Python version. This state lives outside the installed core.
55
+ from .package_manager import scan_standard_library
56
+ scan_standard_library()
57
+ return output
58
+
59
+
60
+ def _atomic_json(output: Path, payload: dict) -> None:
61
+ """Replace generated state without exposing a half-written profile."""
62
+ output.parent.mkdir(parents=True, exist_ok=True)
63
+ handle, temporary = tempfile.mkstemp(prefix=output.name + ".", suffix=".tmp", dir=output.parent)
64
+ try:
65
+ with __import__("os").fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
66
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
67
+ stream.write("\n")
68
+ stream.flush()
69
+ __import__("os").fsync(stream.fileno())
70
+ Path(temporary).replace(output)
71
+ except BaseException:
72
+ Path(temporary).unlink(missing_ok=True)
73
+ raise
74
+
75
+
76
+ def load_runtime_spellings() -> dict[str, str]:
77
+ """Return generated spellings for the active interpreter only."""
78
+ path = profile_dir() / f"python-{platform.python_version()}.json"
79
+ try:
80
+ payload = json.loads(path.read_text(encoding="utf-8"))
81
+ entries = payload.get("generated_spellings", {})
82
+ if payload.get("format") != 1 or not isinstance(entries, dict):
83
+ return {}
84
+ return {
85
+ spoken: python_name for python_name, spoken in entries.items()
86
+ if isinstance(python_name, str) and isinstance(spoken, str)
87
+ }
88
+ except (OSError, UnicodeError, json.JSONDecodeError):
89
+ return {}
90
+
91
+
92
+ def check_file(path: Path) -> tuple[bool, str | None]:
93
+ """Compile a Hython file with the active interpreter without executing it."""
94
+ try:
95
+ source = path.read_text(encoding="utf-8-sig")
96
+ compile(to_python(source), str(path), "exec")
97
+ except (OSError, UnicodeError, SyntaxError) as exc:
98
+ return False, str(exc)
99
+ return True, None
100
+
101
+
102
+ def check_tree(root: Path) -> list[tuple[Path, str]]:
103
+ """Return syntax failures for all Hython files under a path."""
104
+ paths = [root] if root.is_file() else sorted(root.rglob("*.hy"))
105
+ failures: list[tuple[Path, str]] = []
106
+ for path in paths:
107
+ valid, error = check_file(path)
108
+ if not valid:
109
+ failures.append((path, error or "알 수 없는 오류"))
110
+ return failures