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 ADDED
@@ -0,0 +1,6 @@
1
+ """Molty: a strict superset of Python that turns common Python traps into errors."""
2
+
3
+ from molty.runtime import checked, isolated, scope
4
+
5
+ __all__ = ["checked", "isolated", "scope"]
6
+ __version__ = "0.1.0"
@@ -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())