molty 0.1.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.
- molty/__init__.py +6 -0
- molty/compiler/__init__.py +75 -0
- molty/compiler/cli.py +189 -0
- molty/compiler/emit.py +434 -0
- molty/compiler/rules.py +1027 -0
- molty/compiler/tokens.py +235 -0
- molty/py.typed +0 -0
- molty/runtime.py +716 -0
- molty-0.1.0.dist-info/METADATA +371 -0
- molty-0.1.0.dist-info/RECORD +14 -0
- molty-0.1.0.dist-info/WHEEL +5 -0
- molty-0.1.0.dist-info/entry_points.txt +2 -0
- molty-0.1.0.dist-info/licenses/LICENSE +202 -0
- molty-0.1.0.dist-info/top_level.txt +1 -0
molty/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The Molty compiler: ``.molty.py`` source in, plain Python out.
|
|
2
|
+
|
|
3
|
+
pre-pass (tokens) -> ast.parse -> checks (rules) -> emit
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import tokenize
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from molty.compiler import emit, rules, tokens
|
|
13
|
+
from molty.compiler.emit import Explanation
|
|
14
|
+
from molty.compiler.rules import ERROR, WARNING, Diagnostic
|
|
15
|
+
|
|
16
|
+
__all__ = ["CompileResult", "Diagnostic", "Explanation", "compile_source"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CompileResult:
|
|
21
|
+
code: str | None # None when there are errors
|
|
22
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
23
|
+
explanations: list[Explanation] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return self.code is not None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def errors(self) -> list[Diagnostic]:
|
|
31
|
+
return [d for d in self.diagnostics if d.severity == ERROR]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def warnings(self) -> list[Diagnostic]:
|
|
35
|
+
return [d for d in self.diagnostics if d.severity == WARNING]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compile_source(source: str, filename: str = "<molty>", *, rewrite_defaults: bool = False) -> CompileResult:
|
|
39
|
+
"""Compile Molty source to Python. With ``rewrite_defaults`` a mutable default
|
|
40
|
+
argument gets a fresh object per call instead of a MOL1004 warning."""
|
|
41
|
+
try:
|
|
42
|
+
prepared = tokens.prepare(source)
|
|
43
|
+
except (SyntaxError, tokenize.TokenError) as exc:
|
|
44
|
+
return CompileResult(None, [_untokenizable(source, filename, exc)])
|
|
45
|
+
try:
|
|
46
|
+
tree = ast.parse(prepared.source, filename)
|
|
47
|
+
except SyntaxError as exc:
|
|
48
|
+
return CompileResult(None, [_syntax_error(exc, prepared, utf8=False)])
|
|
49
|
+
try:
|
|
50
|
+
# Errors the parser lets through, such as 'await' outside an async function.
|
|
51
|
+
compile(tree, filename, "exec", dont_inherit=True)
|
|
52
|
+
except SyntaxError as exc:
|
|
53
|
+
return CompileResult(None, [_syntax_error(exc, prepared, utf8=True)])
|
|
54
|
+
|
|
55
|
+
analysis = rules.analyze(tree, prepared, rewrite_defaults)
|
|
56
|
+
if analysis.failed:
|
|
57
|
+
return CompileResult(None, analysis.diagnostics)
|
|
58
|
+
code, explanations = emit.emit(tree, prepared, analysis, filename)
|
|
59
|
+
return CompileResult(code, analysis.diagnostics, explanations)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _syntax_error(exc: SyntaxError, prepared: tokens.Prepared, utf8: bool) -> Diagnostic:
|
|
63
|
+
line = exc.lineno or 1
|
|
64
|
+
col = max((exc.offset or 1) - 1, 0)
|
|
65
|
+
# The parser reports character offsets; errors from compiling the tree carry utf-8 ones.
|
|
66
|
+
line, col = prepared.original_position(line, col) if utf8 else prepared.original_char_position(line, col)
|
|
67
|
+
return Diagnostic("MOL0001", ERROR, exc.msg, line, col)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _untokenizable(source: str, filename: str, exc: Exception) -> Diagnostic:
|
|
71
|
+
try:
|
|
72
|
+
ast.parse(source, filename) # the parser gives the better message
|
|
73
|
+
except SyntaxError as parse_error:
|
|
74
|
+
return Diagnostic("MOL0001", ERROR, parse_error.msg, parse_error.lineno or 1, max((parse_error.offset or 1) - 1, 0))
|
|
75
|
+
return Diagnostic("MOL0001", ERROR, str(exc), 1, 0)
|
molty/compiler/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""moltyc: compile ``.molty.py`` files to plain ``.py`` files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tokenize
|
|
10
|
+
import unicodedata
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, TextIO
|
|
13
|
+
|
|
14
|
+
import molty
|
|
15
|
+
from molty.compiler import CompileResult, compile_source
|
|
16
|
+
|
|
17
|
+
SUFFIX = ".molty.py"
|
|
18
|
+
|
|
19
|
+
# Shown by ``moltyc --version`` on a terminal.
|
|
20
|
+
LOGO = r"""
|
|
21
|
+
__ __ ___ _ _______ __
|
|
22
|
+
| \/ |/ _ \| | |_ _\ \ / /
|
|
23
|
+
| |\/| | | | | | | | \ V /
|
|
24
|
+
| | | | |_| | |___| | | |
|
|
25
|
+
|_| |_|\___/|_____|_| |_|
|
|
26
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~:> Molty {version}
|
|
27
|
+
""".strip("\n")
|
|
28
|
+
SNAKE = "~~~~:>" # starts the line for each file that compiled
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Version(argparse.Action):
|
|
32
|
+
"""The logo on a terminal; a plain ``moltyc X.Y.Z`` line for scripts."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, option_strings: list[str], dest: str = argparse.SUPPRESS, default: Any = argparse.SUPPRESS,
|
|
36
|
+
help: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(option_strings, dest=dest, default=default, nargs=0, help=help)
|
|
39
|
+
|
|
40
|
+
def __call__(
|
|
41
|
+
self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Any, option_string: str | None = None
|
|
42
|
+
) -> None:
|
|
43
|
+
if sys.stdout.isatty():
|
|
44
|
+
print(LOGO.format(version=molty.__version__))
|
|
45
|
+
else:
|
|
46
|
+
print(f"moltyc {molty.__version__}")
|
|
47
|
+
parser.exit()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sources(names: list[str], parser: argparse.ArgumentParser) -> list[Path]:
|
|
51
|
+
found = []
|
|
52
|
+
for name in names:
|
|
53
|
+
path = Path(name)
|
|
54
|
+
if path.is_dir():
|
|
55
|
+
found += _walk(path)
|
|
56
|
+
elif path.name.endswith(SUFFIX) and path.is_file():
|
|
57
|
+
found.append(path)
|
|
58
|
+
elif not path.exists():
|
|
59
|
+
parser.error(f"{name}: no such file or directory")
|
|
60
|
+
else:
|
|
61
|
+
parser.error(f"{name}: expected a {SUFFIX} file")
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _walk(directory: Path) -> list[Path]:
|
|
66
|
+
"""The .molty.py files under ``directory``, without following symbolic links.
|
|
67
|
+
|
|
68
|
+
A link can lead outside the tree being compiled; a file named on the
|
|
69
|
+
command line is still read even if it is a link.
|
|
70
|
+
"""
|
|
71
|
+
found = []
|
|
72
|
+
for root, dirs, files in os.walk(directory): # does not descend into linked directories
|
|
73
|
+
found += [Path(root, name) for name in files if name.endswith(SUFFIX) and not Path(root, name).is_symlink()]
|
|
74
|
+
return sorted(found)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _written_by_moltyc(path: Path) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
with path.open("rb") as f:
|
|
80
|
+
head = [f.readline().lstrip(b"\xef\xbb\xbf") for _ in range(3)] # header follows a shebang or coding line
|
|
81
|
+
except OSError:
|
|
82
|
+
return False
|
|
83
|
+
return any(line.startswith(b"# Generated by moltyc") for line in head)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _refusal(target: Path, sources: list[Path], force: bool) -> str | None:
|
|
87
|
+
"""Why moltyc must not write ``target``, if it must not."""
|
|
88
|
+
if target.is_symlink():
|
|
89
|
+
return f"{target} is a symbolic link; moltyc does not write through links"
|
|
90
|
+
if target.exists():
|
|
91
|
+
if any(os.path.samefile(target, source) for source in sources if source.exists()):
|
|
92
|
+
return f"{target} is a source file; writing it would destroy the source"
|
|
93
|
+
# Only a regular file can hold someone's code; /dev/null and the like are fine.
|
|
94
|
+
if not force and target.is_file() and not _written_by_moltyc(target):
|
|
95
|
+
return f"{target} exists and was not generated by moltyc; use --force to overwrite it"
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write(target: Path, text: str, encoding: str) -> None:
|
|
100
|
+
# O_NOFOLLOW makes the open itself refuse a symbolic link, so a link put
|
|
101
|
+
# in place after the checks above still cannot redirect the write.
|
|
102
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
103
|
+
with open(os.open(target, flags, 0o666), "w", encoding=encoding, newline="") as f:
|
|
104
|
+
f.write(text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _caret(text: str, col: int) -> str:
|
|
108
|
+
# Wide (e.g. CJK) characters take two terminal columns; tabs are kept as tabs.
|
|
109
|
+
pad = ("\t" if c == "\t" else " " if unicodedata.east_asian_width(c) in "WF" else " " for c in text[:col])
|
|
110
|
+
return "".join(pad) + "^"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _report(result: CompileResult, path: Path, source: str, explain: bool, out: TextIO) -> None:
|
|
114
|
+
lines = source.splitlines()
|
|
115
|
+
for diagnostic in result.diagnostics:
|
|
116
|
+
print(diagnostic.format(str(path)), file=sys.stderr)
|
|
117
|
+
if 0 < diagnostic.line <= len(lines):
|
|
118
|
+
text = lines[diagnostic.line - 1]
|
|
119
|
+
print(f" {text}", file=sys.stderr)
|
|
120
|
+
print(f" {_caret(text, diagnostic.col)}", file=sys.stderr)
|
|
121
|
+
if explain:
|
|
122
|
+
for explanation in result.explanations:
|
|
123
|
+
print(explanation.format(str(path)), file=out)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _success(path: Path, target: str, result: CompileResult) -> str:
|
|
127
|
+
count = len(result.warnings)
|
|
128
|
+
note = f" ({count} warning{'s' if count != 1 else ''})" if count else ""
|
|
129
|
+
return f"{SNAKE} {path} -> {target} ok{note}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv: list[str] | None = None) -> int:
|
|
133
|
+
parser = argparse.ArgumentParser(prog="moltyc", description="Compile Molty (.molty.py) files to plain Python.")
|
|
134
|
+
parser.add_argument("sources", nargs="+", metavar="SOURCE", help=f"{SUFFIX} files or directories containing them")
|
|
135
|
+
parser.add_argument("-o", "--output", help="output file for a single source; '-' writes to stdout")
|
|
136
|
+
parser.add_argument("--explain", action="store_true", help="list every rewrite moltyc made")
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--rewrite-defaults",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="give mutable default arguments a fresh object per call instead of warning (MOL1004)",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="do not print a line for each compiled file")
|
|
143
|
+
parser.add_argument("--force", action="store_true", help="overwrite a .py file that moltyc did not generate")
|
|
144
|
+
parser.add_argument("--version", action=_Version, help="show the version and exit")
|
|
145
|
+
args = parser.parse_args(argv)
|
|
146
|
+
|
|
147
|
+
paths = _sources(args.sources, parser)
|
|
148
|
+
if args.output and len(paths) != 1:
|
|
149
|
+
parser.error("-o/--output needs exactly one source file")
|
|
150
|
+
|
|
151
|
+
# The success line goes to stderr, and only to a person at a terminal.
|
|
152
|
+
announce = not args.quiet and sys.stderr.isatty()
|
|
153
|
+
failed = False
|
|
154
|
+
for path in paths:
|
|
155
|
+
raw = path.read_bytes()
|
|
156
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
|
|
157
|
+
source = raw.decode(encoding)
|
|
158
|
+
result = compile_source(source, str(path), rewrite_defaults=args.rewrite_defaults)
|
|
159
|
+
to_stdout = args.output == "-"
|
|
160
|
+
_report(result, path, source, args.explain, sys.stderr if to_stdout else sys.stdout)
|
|
161
|
+
code = result.code
|
|
162
|
+
if code is None:
|
|
163
|
+
failed = True
|
|
164
|
+
continue
|
|
165
|
+
if to_stdout:
|
|
166
|
+
sys.stdout.write(code)
|
|
167
|
+
sys.stdout.flush()
|
|
168
|
+
written = "(stdout)"
|
|
169
|
+
else:
|
|
170
|
+
target = Path(args.output) if args.output else path.with_name(path.name[: -len(SUFFIX)] + ".py")
|
|
171
|
+
refusal = _refusal(target, paths, args.force)
|
|
172
|
+
if refusal:
|
|
173
|
+
print(f"moltyc: error: {refusal}", file=sys.stderr)
|
|
174
|
+
failed = True
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
_write(target, code, encoding)
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
print(f"moltyc: error: cannot write {target}: {exc.strerror}", file=sys.stderr)
|
|
180
|
+
failed = True
|
|
181
|
+
continue
|
|
182
|
+
written = str(target)
|
|
183
|
+
if announce:
|
|
184
|
+
print(_success(path, written, result), file=sys.stderr)
|
|
185
|
+
return 1 if failed else 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main())
|