pybrace 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.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.5
2
+ Name: pybrace
3
+ Version: 0.1.0
4
+ Summary: Write Python with braces and semicolons; .pyj files run transparently and tracebacks point back to the source
5
+ Project-URL: Homepage, https://github.com/yyszh01/pyj
6
+ Project-URL: Issues, https://github.com/yyszh01/pyj/issues
7
+ Project-URL: Source, https://github.com/yyszh01/pyj
8
+ Author: yyszh01
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: braces,curly-braces,import-hook,python,syntax,transpiler,whitespace
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Compilers
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pyj — Python with braces
27
+
28
+ [![CI](https://github.com/yyszh01/pyj/actions/workflows/ci.yml/badge.svg)](https://github.com/yyszh01/pyj/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/pybrace)](https://pypi.org/project/pybrace/)
30
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
31
+ ![License](https://img.shields.io/badge/license-MIT-green)
32
+
33
+ English | [简体中文](https://github.com/yyszh01/pyj/blob/main/README.zh-CN.md)
34
+
35
+ Write Python like JavaScript: blocks in `{ }`, statements separated by `;` or newlines, and no significant whitespace. `.pyj` files are converted to standard Python transparently at import and run time, and errors point back to the original `.pyj` lines and columns.
36
+
37
+ ```python
38
+ def fib(n) { a, b = 0, 1; for _ in range(n) { a, b = b, a + b }; return a }
39
+
40
+ if n < 0 { print("negative") } else if n == 0 { print("zero") }
41
+ else { print("positive") }
42
+ ```
43
+
44
+ ## Features
45
+
46
+ - **Single file, zero dependencies**: the whole tool is one `pyj.py` that uses only the standard library.
47
+ - **Transparent execution**: once installed, `python app.pyj` just works, any program can `import` a `.pyj` module, and `.py` and `.pyj` files can be mixed freely.
48
+ - **Errors point to your source**: tracebacks, syntax errors and `inspect.getsource` all show `.pyj` lines and columns.
49
+ - **Dicts and sets work as usual**: pyj tells a block `{` apart from a literal `{`.
50
+ - **Cross-platform**: Python 3.10+, tested in CI on Linux, macOS and Windows.
51
+
52
+ ## Installation
53
+
54
+ ### With pip (recommended)
55
+
56
+ ```bash
57
+ pip install pybrace
58
+ ```
59
+
60
+ After installing, every Python process in that environment understands `.pyj` files. `pip uninstall pybrace` removes it completely. The package is named `pybrace` on PyPI, but the module and the command are both `pyj`.
61
+
62
+ ```bash
63
+ python app.pyj arg1 arg2 # run directly
64
+ python -c "import mymod" # import any .pyj module; packages can use __init__.pyj
65
+ pyj build app.pyj # convert to app.py
66
+ ```
67
+
68
+ - `.pyj` and `.py` modules can import each other. If both `foo.py` and `foo.pyj` exist, `foo.pyj` wins.
69
+ - Compiled code is cached in `__pycache__/<module>.pyj.cpython-XY.pyc`. The cache is invalidated when the source file or pyj itself changes.
70
+ - How `python app.pyj` works: at startup Python runs `pyj_autoload.pth` from site-packages. When it sees that the script is a `.pyj` file, it replaces the process with `python -m pyj app.pyj` (on Windows it starts a child process instead). Interpreter flags are preserved.
71
+
72
+ ### Single file, no pip
73
+
74
+ Put `pyj.py` anywhere and run:
75
+
76
+ ```bash
77
+ python pyj.py install # writes pyj_autoload.pth; same effect as pip install (inside a venv, only that venv)
78
+ python pyj.py uninstall # undo
79
+ ```
80
+
81
+ ### Without installing anything
82
+
83
+ ```bash
84
+ python pyj.py run app.pyj [args...] # or: python pyj.py app.pyj
85
+ python pyj.py build app.pyj # writes app.py
86
+ python pyj.py build app.pyj -o - # prints to stdout
87
+ ```
88
+
89
+ From Python code:
90
+
91
+ ```python
92
+ import pyj
93
+ pyj.install_import_hook() # .pyj modules become importable
94
+ print(pyj.transpile("if x { y() }"))
95
+ code = pyj.compile_pyj(src, "file.pyj") # code object with positions mapped to the .pyj source
96
+ ```
97
+
98
+ ## Error locations
99
+
100
+ Runtime exceptions, syntax errors, `inspect.getsource` and `linecache` all point to the exact **line and column** in the `.pyj` source. This holds even when several statements share a line or one expression spans several lines:
101
+
102
+ ```
103
+ File "util.pyj", line 3, in divide
104
+ return a /
105
+ ~~^
106
+ b }
107
+ ~
108
+ ZeroDivisionError: division by zero
109
+ ```
110
+
111
+ How it works: while converting, pyj records where each token came from in the source. It parses the generated code with `ast.parse`, rewrites every AST node's position back to the source position, and then compiles.
112
+
113
+ ## Syntax
114
+
115
+ | Rule | Example |
116
+ |---|---|
117
+ | Blocks use `{ }`; indentation is free-form | `while x { x -= 1 }` |
118
+ | The colon is optional | `def f(): { ... }` is the same as `def f() { ... }` |
119
+ | Statements are separated by `;` | `a = 1; b = 2` |
120
+ | A newline ends a statement when the statement is clearly complete | One statement per line needs no `;` |
121
+ | Newlines inside brackets, or after a trailing operator or comma, are ignored | `total = 1 +`⏎`2` |
122
+ | A line starting with `.` `,` `and` `or` etc. continues the previous line | `s = text`⏎`.strip()` |
123
+ | `else if` is shorthand for `elif` | `} else if x {` |
124
+ | Empty blocks get `pass` automatically | `class E {}` |
125
+ | Dicts and sets are written as usual | `if k in {1, 2} { d = {} }` |
126
+
127
+ **How does pyj tell blocks from dicts?** A `{` opens a block only when it ends the header of an `if/for/while/def/class/try/with/match/case…` statement, that is, when it follows a name, number, string, closing bracket, `:`, or `else/try/finally/except`. In Python an expression followed by `{` is never valid, so this is unambiguous.
128
+
129
+ ## Caveats
130
+
131
+ - A line starting with `(`, `[`, `-` or `*` does **not** continue the previous line. This avoids the JavaScript pitfall where `a`⏎`(b)` becomes a function call. To continue a line, put the operator at the end of the line or wrap the expression in parentheses.
132
+ - In a compound statement header written as `if x: {...}`, the `{` always opens a block. To put a dict expression after a one-line `if`, wrap it in a normal `{ }` block.
133
+ - Comments are preserved. A comment in the middle of an expression is moved to the end of that statement's line.
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ python -m unittest discover -s tests -v
139
+ ```
140
+
141
+ ## License
142
+
143
+ [MIT](https://github.com/yyszh01/pyj/blob/main/LICENSE)
@@ -0,0 +1,7 @@
1
+ pyj.py,sha256=wKOFdmB9eRohmVsTxUWMkuf5GCBoevz6LEI2OOTpyMk,26436
2
+ pyj_autoload.pth,sha256=pOzY3iL4YtC5HX7Iw3B2aKTh2uiAI2_wGoS6zm4xiWk,27
3
+ pybrace-0.1.0.dist-info/METADATA,sha256=IXCXrhaXRvZbUbR9YWogXVCDcZ9aaqOUbUU666qHbIw,6660
4
+ pybrace-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ pybrace-0.1.0.dist-info/entry_points.txt,sha256=smuchdCHjAv_r-4ccJ66aqtxFRIaNziPPNmjUx-_DHs,33
6
+ pybrace-0.1.0.dist-info/licenses/LICENSE,sha256=d_dHNXr5BNinqD9jJx8Onkh-Mukk8sKDSB2PY3Ly_6E,1064
7
+ pybrace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyj = pyj:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yyszh01
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pyj.py ADDED
@@ -0,0 +1,720 @@
1
+ """pyj: 用花括号和分号书写 Python,转译为标准 Python 代码。
2
+
3
+ 语法规则:
4
+ * 代码块用 { } 包裹,不再依赖缩进;冒号可写可不写:
5
+ if x > 0 { print(x) } else if x < 0 { print(-x) } else { pass }
6
+ * 语句之间用 ; 分隔;换行处如果语句明显已经结束,也会自动断句(类似 JS 的 ASI)。
7
+ * 括号内、或行尾是运算符/逗号时,换行会被忽略,可以随意折行。
8
+ * `else if` 是 `elif` 的语法糖。
9
+ * 字典/集合字面量照常使用 { }:只有出现在复合语句头部末尾的 { 才会被识别为代码块。
10
+ """
11
+
12
+ import bisect
13
+ import importlib.machinery
14
+ import importlib.util
15
+ import marshal
16
+ import os
17
+ import sys
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = ["transpile", "compile_pyj", "run_file", "PyjSyntaxError", "install_import_hook"]
22
+
23
+ INDENT = " "
24
+
25
+ # 以这些关键字开头的语句可以带代码块
26
+ COMPOUND = {"if", "elif", "else", "for", "while", "def", "class", "try",
27
+ "except", "finally", "with", "async", "match", "case"}
28
+ # 这些关键字后面可以直接跟代码块的 {
29
+ BLOCK_KW = {"else", "try", "finally", "except"}
30
+ # 以这些关键字结尾时语句不可能结束,换行不断句
31
+ NO_END_KW = {"and", "or", "not", "in", "is", "if", "elif", "else", "for", "while",
32
+ "with", "def", "class", "lambda", "import", "from", "as", "del",
33
+ "global", "nonlocal", "assert", "await", "async", "try", "except",
34
+ "finally"}
35
+ # 下一行以这些记号开头时,视为上一行的延续
36
+ CONT_START = {".", ",", ":", "=", "==", "!=", "<", ">", "<=", ">=", "+=", "-=",
37
+ "*=", "/=", "//=", "%=", "**=", "&=", "|=", "^=", ">>=", "<<=",
38
+ "@=", "->", ":=", "**", "//", "/", "%", "&", "|", "^", "<<", ">>",
39
+ "and", "or", "in", "is", "as"}
40
+
41
+ OPERATORS = sorted([
42
+ "**=", "//=", ">>=", "<<=", "...", "->", ":=", "==", "!=", "<=", ">=",
43
+ "**", "//", "<<", ">>", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=",
44
+ "@=", "+", "-", "*", "/", "%", "@", "&", "|", "^", "~", "<", ">", "(",
45
+ ")", "[", "]", "{", "}", ",", ":", ".", ";", "=", "!",
46
+ ], key=len, reverse=True)
47
+
48
+ STRING_PREFIXES = {"r", "u", "b", "f", "t", "br", "rb", "fr", "rf", "tr", "rt"}
49
+
50
+
51
+ class PyjSyntaxError(SyntaxError):
52
+ pass
53
+
54
+
55
+ class Token:
56
+ __slots__ = ("kind", "text", "start", "end", "line", "col", "nl_before")
57
+
58
+ def __init__(self, kind, text, start, end, line, col, nl_before):
59
+ self.kind = kind # NAME / NUMBER / STRING / OP / COMMENT
60
+ self.text = text
61
+ self.start = start
62
+ self.end = end
63
+ self.line = line
64
+ self.col = col
65
+ self.nl_before = nl_before
66
+
67
+ def __repr__(self):
68
+ return f"Token({self.kind}, {self.text!r}, {self.line}:{self.col})"
69
+
70
+
71
+ # ---------------------------------------------------------------- 词法分析
72
+
73
+ class Lexer:
74
+ def __init__(self, src, filename="<pyj>"):
75
+ self.src = src
76
+ self.filename = filename
77
+ self.pos = 0
78
+ self.line_starts = [0]
79
+ for i, ch in enumerate(src):
80
+ if ch == "\n":
81
+ self.line_starts.append(i + 1)
82
+
83
+ def loc(self, pos):
84
+ lo, hi = 0, len(self.line_starts) - 1
85
+ while lo < hi:
86
+ mid = (lo + hi + 1) // 2
87
+ if self.line_starts[mid] <= pos:
88
+ lo = mid
89
+ else:
90
+ hi = mid - 1
91
+ return lo + 1, pos - self.line_starts[lo] + 1
92
+
93
+ def error(self, msg, pos):
94
+ line, col = self.loc(pos)
95
+ text = self.src.splitlines()[line - 1] if self.src.splitlines() else ""
96
+ return PyjSyntaxError(msg, (self.filename, line, col, text))
97
+
98
+ def tokens(self):
99
+ src, n = self.src, len(self.src)
100
+ out = []
101
+ nl = True
102
+ while True:
103
+ # 跳过空白和续行符
104
+ while self.pos < n:
105
+ ch = src[self.pos]
106
+ if ch == "\n":
107
+ nl = True
108
+ self.pos += 1
109
+ elif ch in " \t\r\f":
110
+ self.pos += 1
111
+ elif ch == "\\" and src.startswith("\n", self.pos + 1):
112
+ self.pos += 2
113
+ elif ch == "\\" and src.startswith("\r\n", self.pos + 1):
114
+ self.pos += 3
115
+ else:
116
+ break
117
+ if self.pos >= n:
118
+ return out
119
+ start = self.pos
120
+ ch = src[start]
121
+ if ch == "#":
122
+ end = src.find("\n", start)
123
+ end = n if end == -1 else end
124
+ kind = "COMMENT"
125
+ self.pos = end
126
+ elif ch in "\"'":
127
+ self.scan_string(start, "")
128
+ kind = "STRING"
129
+ elif ch.isdigit() or (ch == "." and start + 1 < n and src[start + 1].isdigit()):
130
+ self.scan_number()
131
+ kind = "NUMBER"
132
+ elif ch == "_" or ch.isidentifier():
133
+ p = start + 1
134
+ while p < n and (src[p] == "_" or ("a" + src[p]).isidentifier()):
135
+ p += 1
136
+ word = src[start:p]
137
+ if p < n and src[p] in "\"'" and word.lower() in STRING_PREFIXES:
138
+ self.scan_string(p, word.lower())
139
+ kind = "STRING"
140
+ else:
141
+ self.pos = p
142
+ kind = "NAME"
143
+ else:
144
+ for op in OPERATORS:
145
+ if src.startswith(op, start):
146
+ break
147
+ else:
148
+ raise self.error(f"无法识别的字符 {ch!r}", start)
149
+ if op == "!":
150
+ raise self.error("不支持单独的 '!',请使用 not", start)
151
+ self.pos = start + len(op)
152
+ kind = "OP"
153
+ line, col = self.loc(start)
154
+ out.append(Token(kind, src[start:self.pos], start, self.pos, line, col, nl))
155
+ nl = False
156
+
157
+ def scan_number(self):
158
+ src, n, p = self.src, len(self.src), self.pos
159
+ while p < n:
160
+ c = src[p]
161
+ if c.isalnum() or c in "._":
162
+ p += 1
163
+ elif c in "+-" and src[p - 1] in "eE" and not src[self.pos:p].lower().startswith("0x"):
164
+ p += 1
165
+ else:
166
+ break
167
+ self.pos = p
168
+
169
+ def scan_string(self, q, prefix):
170
+ """q 指向开头引号;扫描结束后 self.pos 指向字符串之后。"""
171
+ src, n = self.src, len(self.src)
172
+ quote = src[q]
173
+ triple = src.startswith(quote * 3, q)
174
+ delim = quote * 3 if triple else quote
175
+ p = q + len(delim)
176
+ is_f = "f" in prefix or "t" in prefix
177
+ while True:
178
+ if p >= n:
179
+ raise self.error("字符串没有结束", q)
180
+ c = src[p]
181
+ if c == "\\":
182
+ p += 2
183
+ elif src.startswith(delim, p):
184
+ self.pos = p + len(delim)
185
+ return
186
+ elif c == "\n" and not triple:
187
+ raise self.error("单行字符串中出现换行", q)
188
+ elif is_f and c == "{":
189
+ if src.startswith("{{", p):
190
+ p += 2
191
+ else:
192
+ p = self.scan_fexpr(p + 1)
193
+ else:
194
+ p += 1
195
+
196
+ def scan_fexpr(self, p):
197
+ """扫描 f-string 中 {...} 的表达式部分,返回 } 之后的位置。"""
198
+ src, n = self.src, len(self.src)
199
+ depth = 1
200
+ while p < n:
201
+ c = src[p]
202
+ if c in "\"'":
203
+ self.scan_string(p, "")
204
+ p = self.pos
205
+ elif c == "_" or c.isidentifier():
206
+ s = p
207
+ while p < n and (src[p] == "_" or ("a" + src[p]).isidentifier()):
208
+ p += 1
209
+ if p < n and src[p] in "\"'" and src[s:p].lower() in STRING_PREFIXES:
210
+ self.scan_string(p, src[s:p].lower())
211
+ p = self.pos
212
+ elif c in "{[(":
213
+ depth += 1
214
+ p += 1
215
+ elif c in "}])":
216
+ depth -= 1
217
+ p += 1
218
+ if depth == 0:
219
+ return p
220
+ else:
221
+ p += 1
222
+ raise self.error("f-string 中的 { 没有闭合", p)
223
+
224
+
225
+ # ---------------------------------------------------------------- 转译
226
+
227
+ class Transpiler:
228
+ def __init__(self, src, filename="<pyj>"):
229
+ self.lexer = Lexer(src, filename)
230
+ self.src = src
231
+ self.out = []
232
+ self.spans = [] # (输出行号, 行内列, 输出长度, 源起点, 源终点)
233
+ self.indent = 0
234
+ self.stmt = [] # 当前语句的记号
235
+ self.comments = [] # 当前语句附带的注释
236
+ self.brackets = [] # 当前语句内未闭合的 ( [ {字面量
237
+ self.blocks = [] # 每层代码块:[开括号记号, 已输出语句数]
238
+
239
+ def error(self, msg, tok):
240
+ return self.lexer.error(msg, tok.start)
241
+
242
+ # -- 辅助判断
243
+
244
+ def is_compound(self):
245
+ return bool(self.stmt) and self.stmt[0].text in COMPOUND and self.stmt[0].kind == "NAME"
246
+
247
+ @staticmethod
248
+ def can_end(tok):
249
+ if tok.kind == "NAME":
250
+ return tok.text not in NO_END_KW
251
+ if tok.kind in ("NUMBER", "STRING"):
252
+ return True
253
+ return tok.text in (")", "]", "}", "...")
254
+
255
+ def opens_block(self):
256
+ if self.brackets or not self.is_compound():
257
+ return False
258
+ prev = self.stmt[-1]
259
+ if prev.kind == "NAME" and prev.text in BLOCK_KW:
260
+ return True
261
+ return prev.text == ":" or self.can_end(prev)
262
+
263
+ # -- 输出
264
+
265
+ def render(self, toks):
266
+ """拼接记号,返回 (文本, 每个记号在文本中的位置)。"""
267
+ parts, spans = [], []
268
+ col = 0
269
+ prev = None
270
+ for t in toks:
271
+ if prev is not None:
272
+ gap = self.src[prev.end:t.start]
273
+ if gap.strip(" \t") != "":
274
+ gap = "" if prev.text in ("(", "[", "{", ".") or t.text in (")", "]", "}", ",", ".") else " "
275
+ parts.append(gap)
276
+ col += len(gap)
277
+ parts.append(t.text)
278
+ spans.append((col, len(t.text), t.start, t.end))
279
+ col += len(t.text)
280
+ prev = t
281
+ return "".join(parts), spans
282
+
283
+ def emit(self, text, spans=(), count=True):
284
+ pad = INDENT * self.indent
285
+ idx = len(self.out)
286
+ self.out.append(pad + text)
287
+ for col, glen, ss, se in spans:
288
+ self.spans.append((idx, len(pad) + col, glen, ss, se))
289
+ if count and self.blocks:
290
+ self.blocks[-1][1] += 1
291
+
292
+ def take_comments(self):
293
+ text = " " + " ".join(self.comments) if self.comments else ""
294
+ self.comments = []
295
+ return text
296
+
297
+ def flush(self):
298
+ if not self.stmt:
299
+ for c in self.comments:
300
+ self.emit(c)
301
+ self.comments = []
302
+ return
303
+ line, spans = self.render(self.stmt)
304
+ self.emit(line + self.take_comments(), spans)
305
+ self.stmt = []
306
+
307
+ def open_block(self, tok):
308
+ header, spans = self.render(self.stmt)
309
+ if not header.endswith(":"):
310
+ header += ":"
311
+ self.emit(header + self.take_comments(), spans)
312
+ self.stmt = []
313
+ self.indent += 1
314
+ self.blocks.append([tok, 0])
315
+
316
+ def close_block(self, tok):
317
+ self.flush()
318
+ _, count = self.blocks.pop()
319
+ if count == 0:
320
+ # 空代码块补 pass,位置映射到 }
321
+ self.emit("pass", [(0, 4, tok.start, tok.end)], count=False)
322
+ self.indent -= 1
323
+
324
+ # -- 主循环
325
+
326
+ def run(self):
327
+ toks = self.lexer.tokens()
328
+ i = 0
329
+ while i < len(toks):
330
+ tok = toks[i]
331
+ text = tok.text
332
+
333
+ if tok.kind == "COMMENT":
334
+ prev = toks[i - 1] if i else None
335
+ if (not self.stmt and prev is not None and prev.text == "{"
336
+ and prev.line == tok.line and self.blocks and self.blocks[-1][0] is prev
337
+ and self.blocks[-1][1] == 0):
338
+ # `if x { # 注释` -> 注释留在头部那一行
339
+ self.out[-1] += " " + text
340
+ elif self.stmt:
341
+ self.comments.append(text)
342
+ else:
343
+ self.emit(text, count=False)
344
+ i += 1
345
+ continue
346
+
347
+ # 自动断句:换行 + 括号已闭合 + 上一个记号可以结尾 + 当前记号不是延续
348
+ if (tok.nl_before and self.stmt and not self.brackets
349
+ and self.can_end(self.stmt[-1])
350
+ and text not in CONT_START
351
+ and not (text == "{" and self.is_compound())):
352
+ self.flush()
353
+
354
+ if tok.kind == "OP":
355
+ if text == ";":
356
+ if self.brackets:
357
+ raise self.error("括号内不能出现 ';'", tok)
358
+ self.flush()
359
+ elif text == "{":
360
+ if self.opens_block():
361
+ self.open_block(tok)
362
+ else:
363
+ self.brackets.append(tok)
364
+ self.stmt.append(tok)
365
+ elif text in "([":
366
+ self.brackets.append(tok)
367
+ self.stmt.append(tok)
368
+ elif text in ")]}":
369
+ want = {")": "(", "]": "[", "}": "{"}[text]
370
+ if self.brackets:
371
+ if self.brackets[-1].text != want:
372
+ raise self.error(f"'{text}' 与 '{self.brackets[-1].text}' 不匹配", tok)
373
+ self.brackets.pop()
374
+ self.stmt.append(tok)
375
+ elif text == "}" and self.blocks:
376
+ self.close_block(tok)
377
+ else:
378
+ raise self.error(f"多余的 '{text}'", tok)
379
+ else:
380
+ self.stmt.append(tok)
381
+ elif (tok.kind == "NAME" and text == "else" and not self.stmt
382
+ and i + 1 < len(toks) and toks[i + 1].text == "if"):
383
+ # else if -> elif
384
+ toks[i + 1] = Token("NAME", "elif", tok.start, toks[i + 1].end,
385
+ tok.line, tok.col, tok.nl_before)
386
+ else:
387
+ self.stmt.append(tok)
388
+ i += 1
389
+
390
+ if self.brackets:
391
+ raise self.error(f"'{self.brackets[-1].text}' 没有闭合", self.brackets[-1])
392
+ if self.blocks:
393
+ raise self.error("代码块 '{' 没有闭合", self.blocks[-1][0])
394
+ self.flush()
395
+
396
+ gen = "\n".join(self.out) + "\n"
397
+ offsets, acc = [], 0
398
+ for line in self.out:
399
+ offsets.append(acc)
400
+ acc += len(line) + 1
401
+ spans = [(offsets[idx] + col, offsets[idx] + col + glen, ss, se)
402
+ for idx, col, glen, ss, se in self.spans]
403
+ return gen, SourceMap(gen, self.src, spans)
404
+
405
+
406
+ def _line_starts(text):
407
+ starts = [0]
408
+ for i, ch in enumerate(text):
409
+ if ch == "\n":
410
+ starts.append(i + 1)
411
+ return starts
412
+
413
+
414
+ class SourceMap:
415
+ """把生成代码中的位置映射回 .pyj 源码中的位置。"""
416
+
417
+ def __init__(self, gen, src, spans):
418
+ spans.sort()
419
+ self.spans = spans
420
+ self.gstarts = [s[0] for s in spans]
421
+ self.gen, self.src = gen, src
422
+ self.gen_starts = _line_starts(gen)
423
+ self.src_starts = _line_starts(src)
424
+
425
+ @staticmethod
426
+ def _line(text, starts, lineno):
427
+ s = starts[lineno - 1]
428
+ e = starts[lineno] - 1 if lineno < len(starts) else len(text)
429
+ return s, text[s:e]
430
+
431
+ def _src_offset(self, g, end):
432
+ # 起点找“起点 <= g 的最后一个记号”;终点找“起点 < g 的最后一个记号”,
433
+ # 这样紧挨着的两个记号(如 `a)`)不会把 a 的终点映射成 ) 的起点。
434
+ i = bisect.bisect_right(self.gstarts, g - 1 if end else g) - 1
435
+ if i < 0:
436
+ return self.spans[0][2] if self.spans else 0
437
+ gs, ge, ss, se = self.spans[i]
438
+ if g <= ge:
439
+ return min(ss + (g - gs), se)
440
+ return se
441
+
442
+ def map(self, lineno, col, end=False, byte_col=True):
443
+ """生成代码的 (行号, 列) -> 源码的 (行号, 列)。列默认是 UTF-8 字节偏移(AST 的约定)。"""
444
+ if lineno < 1 or lineno > len(self.gen_starts):
445
+ return lineno, col
446
+ s, line = self._line(self.gen, self.gen_starts, lineno)
447
+ if byte_col and not line.isascii():
448
+ col = len(line.encode()[:col].decode(errors="ignore"))
449
+ p = self._src_offset(s + col, end)
450
+ src_line = bisect.bisect_right(self.src_starts, p)
451
+ s, line = self._line(self.src, self.src_starts, src_line)
452
+ col = p - s
453
+ if byte_col and not line.isascii():
454
+ col = len(line[:col].encode())
455
+ return src_line, col
456
+
457
+
458
+ def transpile(src, filename="<pyj>"):
459
+ """把 pyj 源码转成标准 Python 源码。"""
460
+ return Transpiler(src, filename).run()[0]
461
+
462
+
463
+ def compile_pyj(src, filename="<pyj>", optimize=-1):
464
+ """把 pyj 源码编译成 code 对象;报错和 traceback 的行号、列号都指向 pyj 源码。"""
465
+ import ast
466
+ import linecache
467
+
468
+ if not os.path.isfile(filename):
469
+ # 让 traceback 能显示源码行
470
+ linecache.cache[filename] = (len(src), None, src.splitlines(True), filename)
471
+ gen, smap = Transpiler(src, filename).run()
472
+ try:
473
+ tree = ast.parse(gen, filename)
474
+ except SyntaxError as e:
475
+ raise _map_syntax_error(e, smap, src, filename) from None
476
+ for node in ast.walk(tree):
477
+ if "lineno" not in node._attributes or getattr(node, "lineno", None) is None:
478
+ continue
479
+ node.lineno, node.col_offset = smap.map(node.lineno, node.col_offset)
480
+ if getattr(node, "end_lineno", None) is not None:
481
+ node.end_lineno, node.end_col_offset = smap.map(
482
+ node.end_lineno, node.end_col_offset, end=True)
483
+ return compile(tree, filename, "exec", dont_inherit=True, optimize=optimize)
484
+
485
+
486
+ def _map_syntax_error(e, smap, src, filename):
487
+ lineno, offset = e.lineno or 1, (e.offset or 1)
488
+ line, col = smap.map(lineno, offset - 1, byte_col=False)
489
+ end_line = end_col = None
490
+ if e.end_lineno and e.end_offset:
491
+ end_line, end_col = smap.map(e.end_lineno, e.end_offset - 1, end=True, byte_col=False)
492
+ end_col += 1
493
+ lines = src.splitlines()
494
+ text = lines[line - 1] if 0 < line <= len(lines) else None
495
+ return SyntaxError(e.msg, (filename, line, col + 1, text, end_line, end_col))
496
+
497
+
498
+ # ---------------------------------------------------------------- import 钩子
499
+
500
+ class PyjLoader(importlib.machinery.SourceFileLoader):
501
+ """加载 .pyj 模块;编译结果缓存在 __pycache__/<name>.pyj.<tag>.pyc。"""
502
+
503
+ def get_code(self, fullname):
504
+ path = self.get_filename(fullname)
505
+ data = self.get_data(path)
506
+ key = importlib.util.source_hash(data + _fingerprint())
507
+ cache = _cache_path(path)
508
+ if cache:
509
+ try:
510
+ with open(cache, "rb") as f:
511
+ blob = f.read()
512
+ if blob[:4] == importlib.util.MAGIC_NUMBER and blob[4:12] == key:
513
+ return marshal.loads(blob[12:])
514
+ except (OSError, ValueError, EOFError):
515
+ pass
516
+ code = compile_pyj(importlib.util.decode_source(data), path)
517
+ if cache and not sys.dont_write_bytecode:
518
+ try:
519
+ os.makedirs(os.path.dirname(cache), exist_ok=True)
520
+ tmp = f"{cache}.{os.getpid()}.tmp"
521
+ with open(tmp, "wb") as f:
522
+ f.write(importlib.util.MAGIC_NUMBER + key + marshal.dumps(code))
523
+ os.replace(tmp, cache)
524
+ except OSError:
525
+ pass
526
+ return code
527
+
528
+
529
+ def _cache_path(path):
530
+ try:
531
+ return importlib.util.cache_from_source(path + ".py") # foo.pyj -> foo.pyj.<tag>.pyc
532
+ except NotImplementedError:
533
+ return None
534
+
535
+
536
+ _FINGERPRINT = None
537
+
538
+
539
+ def _fingerprint():
540
+ """pyj.py 本身变了,缓存也要失效。"""
541
+ global _FINGERPRINT
542
+ if _FINGERPRINT is None:
543
+ try:
544
+ with open(__file__, "rb") as f:
545
+ _FINGERPRINT = f.read()
546
+ except OSError:
547
+ _FINGERPRINT = b""
548
+ return _FINGERPRINT
549
+
550
+
551
+ def install_import_hook():
552
+ """安装后,sys.path 中所有 .pyj 文件都能直接 import(包括包里的 __init__.pyj)。"""
553
+ if any(getattr(h, "_pyj", False) for h in sys.path_hooks):
554
+ return
555
+ from importlib._bootstrap_external import _get_supported_file_loaders
556
+ hook = importlib.machinery.FileFinder.path_hook(
557
+ (PyjLoader, [".pyj"]), *_get_supported_file_loaders())
558
+ hook._pyj = True
559
+ sys.path_hooks.insert(0, hook)
560
+ sys.path_importer_cache.clear()
561
+
562
+
563
+ def _startup():
564
+ """由 .pth 文件在每个 Python 进程启动时调用。"""
565
+ install_import_hook()
566
+ argv = sys.argv
567
+ if argv and argv[0].endswith(".pyj") and os.path.isfile(argv[0]):
568
+ # `python app.pyj`:解释器会把 .pyj 当 Python 解析,这里改为执行 `python -m pyj app.pyj`
569
+ orig = getattr(sys, "orig_argv", None) or [sys.executable] + argv
570
+ n = len(argv)
571
+ new = [sys.executable] + orig[1:-n] + ["-m", "pyj"] + orig[-n:]
572
+ if os.name == "posix":
573
+ os.execv(sys.executable, new)
574
+ # Windows 没有真正的 exec:启动子进程并等它结束;Ctrl+C 交给子进程处理
575
+ import subprocess
576
+ proc = subprocess.Popen(new)
577
+ while True:
578
+ try:
579
+ os._exit(proc.wait())
580
+ except KeyboardInterrupt:
581
+ pass
582
+
583
+
584
+ # ---------------------------------------------------------------- 运行
585
+
586
+ def run_file(path, args=()):
587
+ """像 `python path` 一样把 .pyj 文件作为 __main__ 运行。"""
588
+ import builtins
589
+ import traceback
590
+ import types
591
+
592
+ path = os.path.abspath(path) if not os.path.isabs(path) else path
593
+ with open(path, "rb") as f:
594
+ src = importlib.util.decode_source(f.read())
595
+ sys.argv = [path] + list(args)
596
+ sys.path[0] = os.path.dirname(path)
597
+ install_import_hook()
598
+
599
+ mod = types.ModuleType("__main__")
600
+ mod.__file__ = path
601
+ mod.__builtins__ = builtins
602
+ mod.__loader__ = PyjLoader("__main__", path)
603
+ mod.__spec__ = None
604
+ sys.modules["__main__"] = mod
605
+ try:
606
+ exec(compile_pyj(src, path), mod.__dict__)
607
+ except SyntaxError as e:
608
+ traceback.print_exception(type(e), e, None)
609
+ return 1
610
+ except Exception as e:
611
+ # 去掉 pyj 自己的栈帧,traceback 看起来和直接运行 Python 一样
612
+ tb = e.__traceback__
613
+ here = os.path.normcase(os.path.abspath(__file__))
614
+ while tb is not None and os.path.normcase(os.path.abspath(tb.tb_frame.f_code.co_filename)) == here:
615
+ tb = tb.tb_next
616
+ sys.excepthook(type(e), e.with_traceback(tb), tb)
617
+ return 1
618
+ return 0
619
+
620
+
621
+ # ---------------------------------------------------------------- 安装
622
+
623
+ PTH_NAME = "pyj_autoload.pth"
624
+
625
+
626
+ def _site_dirs():
627
+ import site
628
+ import sysconfig
629
+ dirs = [sysconfig.get_paths()["purelib"]]
630
+ if sys.prefix == sys.base_prefix and site.ENABLE_USER_SITE:
631
+ dirs.insert(0, site.getusersitepackages())
632
+ return dirs
633
+
634
+
635
+ def install():
636
+ target = _site_dirs()[0]
637
+ os.makedirs(target, exist_ok=True)
638
+ pth = os.path.join(target, PTH_NAME)
639
+ here = os.path.dirname(os.path.abspath(__file__))
640
+ with open(pth, "w", encoding="utf-8") as f:
641
+ f.write(f"{here}\nimport pyj; pyj._startup()\n")
642
+ print(f"已安装: {pth}")
643
+ print("现在可以直接 `python3 app.pyj` 运行,任何 Python 程序都能 import .pyj 模块。")
644
+ return 0
645
+
646
+
647
+ def uninstall():
648
+ removed = False
649
+ for d in _site_dirs():
650
+ pth = os.path.join(d, PTH_NAME)
651
+ if os.path.exists(pth):
652
+ os.remove(pth)
653
+ print(f"已删除: {pth}")
654
+ removed = True
655
+ if not removed:
656
+ print("没有找到已安装的 pyj")
657
+ return 0
658
+
659
+
660
+ # ---------------------------------------------------------------- 命令行
661
+
662
+ def _print_syntax_error(e):
663
+ print(f"{e.filename}:{e.lineno}:{e.offset}: 语法错误: {e.msg}", file=sys.stderr)
664
+ if e.text:
665
+ print(" " + e.text, file=sys.stderr)
666
+ print(" " + " " * (e.offset - 1) + "^", file=sys.stderr)
667
+
668
+
669
+ def main(argv=None):
670
+ import argparse
671
+
672
+ argv = list(sys.argv[1:] if argv is None else argv)
673
+ if argv and argv[0].endswith(".pyj"):
674
+ return run_file(argv[0], argv[1:]) # pyj app.pyj ... 等同 pyj run app.pyj ...
675
+
676
+ parser = argparse.ArgumentParser(prog="pyj", description="用花括号书写 Python")
677
+ sub = parser.add_subparsers(dest="cmd", required=True)
678
+ p_run = sub.add_parser("run", help="转译并运行 .pyj 文件")
679
+ p_run.add_argument("file")
680
+ p_run.add_argument("args", nargs=argparse.REMAINDER)
681
+ p_build = sub.add_parser("build", help="转译为 .py 文件")
682
+ p_build.add_argument("file")
683
+ p_build.add_argument("-o", "--output", help="输出文件,默认同名 .py;'-' 表示标准输出")
684
+ sub.add_parser("install", help="安装到 site-packages:之后所有 Python 进程自动支持 .pyj")
685
+ sub.add_parser("uninstall", help="卸载自动支持")
686
+ args = parser.parse_args(argv)
687
+
688
+ if args.cmd == "install":
689
+ return install()
690
+ if args.cmd == "uninstall":
691
+ return uninstall()
692
+ if args.cmd == "run":
693
+ return run_file(args.file, args.args)
694
+
695
+ with open(args.file, encoding="utf-8") as f:
696
+ src = f.read()
697
+ try:
698
+ py = transpile(src, args.file)
699
+ except PyjSyntaxError as e:
700
+ _print_syntax_error(e)
701
+ return 1
702
+ out = args.output or os.path.splitext(args.file)[0] + ".py"
703
+ if out == "-":
704
+ sys.stdout.write(py)
705
+ else:
706
+ with open(out, "w", encoding="utf-8") as f:
707
+ f.write(py)
708
+ return 0
709
+
710
+
711
+ if __name__ == "__main__":
712
+ # 统一使用名为 pyj 的模块,避免 __main__ 和 pyj 两份副本各装一个钩子
713
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
714
+ try:
715
+ import pyj as _pyj
716
+ except ImportError:
717
+ _pyj = None
718
+ finally:
719
+ del sys.path[0]
720
+ sys.exit((_pyj.main if _pyj else main)())
pyj_autoload.pth ADDED
@@ -0,0 +1 @@
1
+ import pyj; pyj._startup()