snail-lang 0.5.2__cp310-abi3-macosx_11_0_arm64.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.
- snail/__init__.py +25 -0
- snail/_native.abi3.so +0 -0
- snail/cli.py +247 -0
- snail/runtime/__init__.py +167 -0
- snail/runtime/compact_try.py +13 -0
- snail/runtime/regex.py +37 -0
- snail/runtime/structured_accessor.py +74 -0
- snail/runtime/subprocess.py +66 -0
- snail_lang-0.5.2.dist-info/METADATA +259 -0
- snail_lang-0.5.2.dist-info/RECORD +13 -0
- snail_lang-0.5.2.dist-info/WHEEL +4 -0
- snail_lang-0.5.2.dist-info/entry_points.txt +2 -0
- snail_lang-0.5.2.dist-info/licenses/LICENSE +20 -0
snail/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from ._native import __build_info__, compile, compile_ast, exec, parse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _resolve_version() -> str:
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
|
|
8
|
+
return version("snail-lang")
|
|
9
|
+
except Exception: # pragma: no cover - during development
|
|
10
|
+
return "0.0.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name == "__version__":
|
|
15
|
+
value = _resolve_version()
|
|
16
|
+
globals()["__version__"] = value
|
|
17
|
+
return value
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def __dir__() -> list[str]:
|
|
22
|
+
return sorted(list(globals().keys()) + ["__version__"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["compile", "compile_ast", "exec", "parse", "__version__", "__build_info__"]
|
snail/_native.abi3.so
ADDED
|
Binary file
|
snail/cli.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __build_info__, compile_ast, exec
|
|
7
|
+
|
|
8
|
+
_USAGE = (
|
|
9
|
+
"snail [options] -f <file> [args]...\n"
|
|
10
|
+
" snail [options] <code> [args]..."
|
|
11
|
+
)
|
|
12
|
+
_DESCRIPTION = "Snail programming language interpreter"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _display_filename(filename: str) -> str:
|
|
16
|
+
if filename.startswith("snail:"):
|
|
17
|
+
return filename
|
|
18
|
+
return f"snail:{filename}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _trim_internal_prefix(stack, internal_files: set[str]) -> None:
|
|
22
|
+
if not stack:
|
|
23
|
+
return
|
|
24
|
+
trim_count = 0
|
|
25
|
+
for frame in stack:
|
|
26
|
+
filename = frame.filename
|
|
27
|
+
if filename.startswith("snail:"):
|
|
28
|
+
break
|
|
29
|
+
if filename in internal_files:
|
|
30
|
+
trim_count += 1
|
|
31
|
+
continue
|
|
32
|
+
if os.path.isabs(filename) and os.path.abspath(filename) in internal_files:
|
|
33
|
+
trim_count += 1
|
|
34
|
+
continue
|
|
35
|
+
break
|
|
36
|
+
if 0 < trim_count < len(stack):
|
|
37
|
+
del stack[:trim_count]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _trim_traceback_exception(tb_exc, internal_files: set[str]) -> None:
|
|
41
|
+
_trim_internal_prefix(tb_exc.stack, internal_files)
|
|
42
|
+
cause = getattr(tb_exc, "__cause__", None)
|
|
43
|
+
if cause is not None:
|
|
44
|
+
_trim_traceback_exception(cause, internal_files)
|
|
45
|
+
context = getattr(tb_exc, "__context__", None)
|
|
46
|
+
if context is not None:
|
|
47
|
+
_trim_traceback_exception(context, internal_files)
|
|
48
|
+
for group_exc in getattr(tb_exc, "exceptions", ()) or ():
|
|
49
|
+
_trim_traceback_exception(group_exc, internal_files)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _install_trimmed_excepthook() -> None:
|
|
53
|
+
entrypoint = os.path.abspath(sys.argv[0])
|
|
54
|
+
cli_path = os.path.abspath(__file__)
|
|
55
|
+
internal_files = {entrypoint, cli_path}
|
|
56
|
+
original_excepthook = sys.excepthook
|
|
57
|
+
|
|
58
|
+
def _snail_excepthook(
|
|
59
|
+
exc_type: type[BaseException],
|
|
60
|
+
exc: BaseException,
|
|
61
|
+
tb: object,
|
|
62
|
+
) -> None:
|
|
63
|
+
if exc_type is KeyboardInterrupt:
|
|
64
|
+
return original_excepthook(exc_type, exc, tb)
|
|
65
|
+
import traceback
|
|
66
|
+
|
|
67
|
+
tb_exc = traceback.TracebackException(
|
|
68
|
+
exc_type,
|
|
69
|
+
exc,
|
|
70
|
+
tb,
|
|
71
|
+
capture_locals=False,
|
|
72
|
+
)
|
|
73
|
+
_trim_traceback_exception(tb_exc, internal_files)
|
|
74
|
+
try:
|
|
75
|
+
import _colorize
|
|
76
|
+
|
|
77
|
+
colorize = _colorize.can_colorize(file=sys.stderr)
|
|
78
|
+
except Exception:
|
|
79
|
+
colorize = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
80
|
+
for line in tb_exc.format(colorize=colorize):
|
|
81
|
+
sys.stderr.write(line)
|
|
82
|
+
|
|
83
|
+
sys.excepthook = _snail_excepthook
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Args:
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.file: str | None = None
|
|
89
|
+
self.awk = False
|
|
90
|
+
self.no_print = False
|
|
91
|
+
self.no_auto_import = False
|
|
92
|
+
self.debug = False
|
|
93
|
+
self.version = False
|
|
94
|
+
self.help = False
|
|
95
|
+
self.args: list[str] = []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _print_help(file=sys.stdout) -> None:
|
|
99
|
+
print(f"usage: {_USAGE}", file=file)
|
|
100
|
+
print("", file=file)
|
|
101
|
+
print(_DESCRIPTION, file=file)
|
|
102
|
+
print("", file=file)
|
|
103
|
+
print("options:", file=file)
|
|
104
|
+
print(" -f <file> read Snail source from file", file=file)
|
|
105
|
+
print(" -a, --awk awk mode", file=file)
|
|
106
|
+
print(" -P, --no-print disable auto-print of last expression", file=file)
|
|
107
|
+
print(" -I, --no-auto-import disable auto-imports", file=file)
|
|
108
|
+
print(" --debug parse and compile, then print, do not run", file=file)
|
|
109
|
+
print(" -v, --version show version and exit", file=file)
|
|
110
|
+
print(" -h, --help show this help message and exit", file=file)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_args(argv: list[str]) -> _Args:
|
|
114
|
+
args = _Args()
|
|
115
|
+
idx = 0
|
|
116
|
+
while idx < len(argv):
|
|
117
|
+
token = argv[idx]
|
|
118
|
+
if token == "--":
|
|
119
|
+
args.args = argv[idx + 1 :]
|
|
120
|
+
return args
|
|
121
|
+
if token == "-" or not token.startswith("-"):
|
|
122
|
+
args.args = argv[idx:]
|
|
123
|
+
return args
|
|
124
|
+
if token in ("-h", "--help"):
|
|
125
|
+
args.help = True
|
|
126
|
+
return args
|
|
127
|
+
if token in ("-v", "--version"):
|
|
128
|
+
args.version = True
|
|
129
|
+
idx += 1
|
|
130
|
+
continue
|
|
131
|
+
if token in ("-a", "--awk"):
|
|
132
|
+
args.awk = True
|
|
133
|
+
idx += 1
|
|
134
|
+
continue
|
|
135
|
+
if token in ("-P", "--no-print"):
|
|
136
|
+
args.no_print = True
|
|
137
|
+
idx += 1
|
|
138
|
+
continue
|
|
139
|
+
if token in ("-I", "--no-auto-import"):
|
|
140
|
+
args.no_auto_import = True
|
|
141
|
+
idx += 1
|
|
142
|
+
continue
|
|
143
|
+
if token == "--debug":
|
|
144
|
+
args.debug = True
|
|
145
|
+
idx += 1
|
|
146
|
+
continue
|
|
147
|
+
if token == "-f":
|
|
148
|
+
if idx + 1 >= len(argv):
|
|
149
|
+
raise ValueError("option -f requires an argument")
|
|
150
|
+
args.file = argv[idx + 1]
|
|
151
|
+
idx += 2
|
|
152
|
+
continue
|
|
153
|
+
raise ValueError(f"unknown option: {token}")
|
|
154
|
+
return args
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _format_version(version: str, build_info: dict[str, object] | None) -> str:
|
|
158
|
+
display_version = version if version.startswith("v") else f"v{version}"
|
|
159
|
+
if not build_info:
|
|
160
|
+
return display_version
|
|
161
|
+
git_rev = build_info.get("git_rev")
|
|
162
|
+
if not git_rev:
|
|
163
|
+
return display_version
|
|
164
|
+
|
|
165
|
+
suffixes: list[str] = []
|
|
166
|
+
if build_info.get("dirty"):
|
|
167
|
+
suffixes.append("!dirty")
|
|
168
|
+
if build_info.get("untagged"):
|
|
169
|
+
suffixes.append("!untagged")
|
|
170
|
+
|
|
171
|
+
if suffixes:
|
|
172
|
+
return f"{display_version} ({git_rev}) {' '.join(suffixes)}"
|
|
173
|
+
return f"{display_version} ({git_rev})"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_version() -> str:
|
|
177
|
+
from . import __version__ as version
|
|
178
|
+
|
|
179
|
+
return version
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
if argv is None:
|
|
184
|
+
_install_trimmed_excepthook()
|
|
185
|
+
argv = sys.argv[1:]
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
namespace = _parse_args(argv)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
_print_help(file=sys.stderr)
|
|
191
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
|
|
194
|
+
if namespace.help:
|
|
195
|
+
_print_help()
|
|
196
|
+
return 0
|
|
197
|
+
if namespace.version:
|
|
198
|
+
print(_format_version(_get_version(), __build_info__))
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
mode = "awk" if namespace.awk else "snail"
|
|
202
|
+
|
|
203
|
+
if namespace.file:
|
|
204
|
+
from pathlib import Path
|
|
205
|
+
|
|
206
|
+
path = Path(namespace.file)
|
|
207
|
+
try:
|
|
208
|
+
source = path.read_text()
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
print(f"failed to read {path}: {exc}", file=sys.stderr)
|
|
211
|
+
return 1
|
|
212
|
+
filename = str(path)
|
|
213
|
+
args = [filename, *namespace.args]
|
|
214
|
+
else:
|
|
215
|
+
if not namespace.args:
|
|
216
|
+
print("no input provided", file=sys.stderr)
|
|
217
|
+
return 1
|
|
218
|
+
source = namespace.args[0]
|
|
219
|
+
filename = "<cmd>"
|
|
220
|
+
args = ["--", *namespace.args[1:]]
|
|
221
|
+
|
|
222
|
+
if namespace.debug:
|
|
223
|
+
import ast
|
|
224
|
+
import builtins
|
|
225
|
+
|
|
226
|
+
python_ast = compile_ast(
|
|
227
|
+
source,
|
|
228
|
+
mode=mode,
|
|
229
|
+
auto_print=not namespace.no_print,
|
|
230
|
+
filename=filename,
|
|
231
|
+
)
|
|
232
|
+
builtins.compile(python_ast, _display_filename(filename), "exec")
|
|
233
|
+
print(ast.unparse(python_ast))
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
return exec(
|
|
237
|
+
source,
|
|
238
|
+
argv=args,
|
|
239
|
+
mode=mode,
|
|
240
|
+
auto_print=not namespace.no_print,
|
|
241
|
+
auto_import=not namespace.no_auto_import,
|
|
242
|
+
filename=filename,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
__all__ = ["install_helpers", "AutoImportDict", "AUTO_IMPORT_NAMES"]
|
|
6
|
+
|
|
7
|
+
# Names that can be auto-imported when first referenced.
|
|
8
|
+
# Maps name -> (module, attribute) where attribute is None for whole-module imports.
|
|
9
|
+
AUTO_IMPORT_NAMES: dict[str, tuple[str, str | None]] = {
|
|
10
|
+
# Whole module imports: import X
|
|
11
|
+
"sys": ("sys", None),
|
|
12
|
+
"os": ("os", None),
|
|
13
|
+
# Attribute imports: from X import Y
|
|
14
|
+
"Path": ("pathlib", "Path"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoImportDict(dict):
|
|
19
|
+
"""A dict subclass that lazily imports allowed names on first access.
|
|
20
|
+
|
|
21
|
+
When a key lookup fails, if the key is in AUTO_IMPORT_NAMES,
|
|
22
|
+
the corresponding module/attribute is imported and stored in the dict.
|
|
23
|
+
Supports both whole-module imports (import sys) and attribute imports
|
|
24
|
+
(from pathlib import Path).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __missing__(self, key: str) -> object:
|
|
28
|
+
if key in AUTO_IMPORT_NAMES:
|
|
29
|
+
module_name, attr_name = AUTO_IMPORT_NAMES[key]
|
|
30
|
+
module = importlib.import_module(module_name)
|
|
31
|
+
value = getattr(module, attr_name) if attr_name else module
|
|
32
|
+
self[key] = value
|
|
33
|
+
return value
|
|
34
|
+
raise KeyError(key)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_compact_try = None
|
|
38
|
+
_regex_search = None
|
|
39
|
+
_regex_compile = None
|
|
40
|
+
_subprocess_capture = None
|
|
41
|
+
_subprocess_status = None
|
|
42
|
+
_jmespath_query = None
|
|
43
|
+
_js = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_compact_try():
|
|
47
|
+
global _compact_try
|
|
48
|
+
if _compact_try is None:
|
|
49
|
+
from .compact_try import compact_try
|
|
50
|
+
|
|
51
|
+
_compact_try = compact_try
|
|
52
|
+
return _compact_try
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_regex_search():
|
|
56
|
+
global _regex_search
|
|
57
|
+
if _regex_search is None:
|
|
58
|
+
from .regex import regex_search
|
|
59
|
+
|
|
60
|
+
_regex_search = regex_search
|
|
61
|
+
return _regex_search
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_regex_compile():
|
|
65
|
+
global _regex_compile
|
|
66
|
+
if _regex_compile is None:
|
|
67
|
+
from .regex import regex_compile
|
|
68
|
+
|
|
69
|
+
_regex_compile = regex_compile
|
|
70
|
+
return _regex_compile
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_subprocess_capture():
|
|
74
|
+
global _subprocess_capture
|
|
75
|
+
if _subprocess_capture is None:
|
|
76
|
+
from .subprocess import SubprocessCapture
|
|
77
|
+
|
|
78
|
+
_subprocess_capture = SubprocessCapture
|
|
79
|
+
return _subprocess_capture
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_subprocess_status():
|
|
83
|
+
global _subprocess_status
|
|
84
|
+
if _subprocess_status is None:
|
|
85
|
+
from .subprocess import SubprocessStatus
|
|
86
|
+
|
|
87
|
+
_subprocess_status = SubprocessStatus
|
|
88
|
+
return _subprocess_status
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_jmespath_query():
|
|
92
|
+
global _jmespath_query
|
|
93
|
+
if _jmespath_query is None:
|
|
94
|
+
from .structured_accessor import __snail_jmespath_query
|
|
95
|
+
|
|
96
|
+
_jmespath_query = __snail_jmespath_query
|
|
97
|
+
return _jmespath_query
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_js():
|
|
101
|
+
global _js
|
|
102
|
+
if _js is None:
|
|
103
|
+
from .structured_accessor import js
|
|
104
|
+
|
|
105
|
+
_js = js
|
|
106
|
+
return _js
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _lazy_compact_try(expr_fn, fallback_fn=None):
|
|
110
|
+
return _get_compact_try()(expr_fn, fallback_fn)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _lazy_regex_search(value, pattern):
|
|
114
|
+
return _get_regex_search()(value, pattern)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lazy_regex_compile(pattern):
|
|
118
|
+
return _get_regex_compile()(pattern)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _lazy_subprocess_capture(cmd: str):
|
|
122
|
+
return _get_subprocess_capture()(cmd)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _lazy_subprocess_status(cmd: str):
|
|
126
|
+
return _get_subprocess_status()(cmd)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _lazy_jmespath_query(query: str):
|
|
130
|
+
return _get_jmespath_query()(query)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _lazy_js(input_data=None):
|
|
134
|
+
return _get_js()(input_data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def __snail_partial(func, /, *args, **kwargs):
|
|
138
|
+
import functools
|
|
139
|
+
|
|
140
|
+
return functools.partial(func, *args, **kwargs)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def __snail_contains__(left, right):
|
|
144
|
+
method = getattr(right, "__snail_contains__", None)
|
|
145
|
+
if method is not None:
|
|
146
|
+
return method(left)
|
|
147
|
+
return left in right
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def __snail_contains_not__(left, right):
|
|
151
|
+
method = getattr(right, "__snail_contains__", None)
|
|
152
|
+
if method is not None:
|
|
153
|
+
return not bool(method(left))
|
|
154
|
+
return left not in right
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def install_helpers(globals_dict: dict) -> None:
|
|
158
|
+
globals_dict["__snail_compact_try"] = _lazy_compact_try
|
|
159
|
+
globals_dict["__snail_regex_search"] = _lazy_regex_search
|
|
160
|
+
globals_dict["__snail_regex_compile"] = _lazy_regex_compile
|
|
161
|
+
globals_dict["__SnailSubprocessCapture"] = _lazy_subprocess_capture
|
|
162
|
+
globals_dict["__SnailSubprocessStatus"] = _lazy_subprocess_status
|
|
163
|
+
globals_dict["__snail_jmespath_query"] = _lazy_jmespath_query
|
|
164
|
+
globals_dict["__snail_partial"] = __snail_partial
|
|
165
|
+
globals_dict["__snail_contains__"] = __snail_contains__
|
|
166
|
+
globals_dict["__snail_contains_not__"] = __snail_contains_not__
|
|
167
|
+
globals_dict["js"] = _lazy_js
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def compact_try(expr_fn, fallback_fn=None):
|
|
5
|
+
try:
|
|
6
|
+
return expr_fn()
|
|
7
|
+
except Exception as exc:
|
|
8
|
+
if fallback_fn is None:
|
|
9
|
+
fallback_member = getattr(exc, "__fallback__", None)
|
|
10
|
+
if callable(fallback_member):
|
|
11
|
+
return fallback_member()
|
|
12
|
+
return None
|
|
13
|
+
return fallback_fn(exc)
|
snail/runtime/regex.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SnailRegex:
|
|
7
|
+
def __init__(self, pattern: str) -> None:
|
|
8
|
+
self.pattern = pattern
|
|
9
|
+
self._regex = re.compile(pattern)
|
|
10
|
+
|
|
11
|
+
def search(self, value):
|
|
12
|
+
return regex_search(value, self._regex)
|
|
13
|
+
|
|
14
|
+
def __contains__(self, value):
|
|
15
|
+
return bool(self.__snail_contains__(value))
|
|
16
|
+
|
|
17
|
+
def __snail_contains__(self, value):
|
|
18
|
+
return self.search(value)
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"/{self.pattern}/"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def regex_search(value, pattern):
|
|
25
|
+
if isinstance(pattern, SnailRegex):
|
|
26
|
+
return pattern.search(value)
|
|
27
|
+
if hasattr(pattern, "search"):
|
|
28
|
+
match = pattern.search(value)
|
|
29
|
+
else:
|
|
30
|
+
match = re.search(pattern, value)
|
|
31
|
+
if match is None:
|
|
32
|
+
return ()
|
|
33
|
+
return (match.group(0),) + match.groups()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def regex_compile(pattern):
|
|
37
|
+
return SnailRegex(pattern)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import os as _os
|
|
5
|
+
import sys as _sys
|
|
6
|
+
|
|
7
|
+
def __snail_jmespath_query(query: str):
|
|
8
|
+
"""Create a callable that applies JMESPath query.
|
|
9
|
+
|
|
10
|
+
Used by the $[query] syntax which lowers to __snail_jmespath_query(query).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import jmespath as _jmespath
|
|
14
|
+
|
|
15
|
+
def apply(data):
|
|
16
|
+
return _jmespath.search(query, data)
|
|
17
|
+
|
|
18
|
+
return apply
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_jsonl(content: str):
|
|
22
|
+
lines = [line for line in content.splitlines() if line.strip()]
|
|
23
|
+
if not lines:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
items = []
|
|
27
|
+
for line in lines:
|
|
28
|
+
try:
|
|
29
|
+
items.append(_json.loads(line))
|
|
30
|
+
except _json.JSONDecodeError as exc:
|
|
31
|
+
raise _json.JSONDecodeError(
|
|
32
|
+
f"Invalid JSONL line: {exc.msg}",
|
|
33
|
+
line,
|
|
34
|
+
exc.pos,
|
|
35
|
+
) from exc
|
|
36
|
+
return items
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def js(input_data=None):
|
|
40
|
+
"""Parse JSON from various input sources.
|
|
41
|
+
|
|
42
|
+
Returns the parsed Python object (dict, list, etc.) directly.
|
|
43
|
+
If called with no arguments, reads from stdin.
|
|
44
|
+
"""
|
|
45
|
+
if input_data is None:
|
|
46
|
+
input_data = _sys.stdin
|
|
47
|
+
|
|
48
|
+
if isinstance(input_data, str):
|
|
49
|
+
try:
|
|
50
|
+
return _json.loads(input_data)
|
|
51
|
+
except _json.JSONDecodeError:
|
|
52
|
+
if _os.path.exists(input_data):
|
|
53
|
+
with open(input_data, "r", encoding="utf-8") as handle:
|
|
54
|
+
content = handle.read()
|
|
55
|
+
try:
|
|
56
|
+
return _json.loads(content)
|
|
57
|
+
except _json.JSONDecodeError:
|
|
58
|
+
return _parse_jsonl(content)
|
|
59
|
+
else:
|
|
60
|
+
return _parse_jsonl(input_data)
|
|
61
|
+
elif hasattr(input_data, "read"):
|
|
62
|
+
content = input_data.read()
|
|
63
|
+
if isinstance(content, bytes):
|
|
64
|
+
content = content.decode("utf-8")
|
|
65
|
+
try:
|
|
66
|
+
return _json.loads(content)
|
|
67
|
+
except _json.JSONDecodeError:
|
|
68
|
+
return _parse_jsonl(content)
|
|
69
|
+
elif isinstance(input_data, (dict, list, int, float, bool)) or input_data is None:
|
|
70
|
+
return input_data
|
|
71
|
+
else:
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"js() input must be JSON-compatible, got {type(input_data).__name__}"
|
|
74
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SubprocessCapture:
|
|
7
|
+
def __init__(self, cmd: str) -> None:
|
|
8
|
+
self.cmd = cmd
|
|
9
|
+
|
|
10
|
+
def __call__(self, input_data=None):
|
|
11
|
+
try:
|
|
12
|
+
if input_data is None:
|
|
13
|
+
completed = subprocess.run(
|
|
14
|
+
self.cmd,
|
|
15
|
+
shell=True,
|
|
16
|
+
check=True,
|
|
17
|
+
text=True,
|
|
18
|
+
stdout=subprocess.PIPE,
|
|
19
|
+
)
|
|
20
|
+
else:
|
|
21
|
+
if not isinstance(input_data, (str, bytes)):
|
|
22
|
+
input_data = str(input_data)
|
|
23
|
+
completed = subprocess.run(
|
|
24
|
+
self.cmd,
|
|
25
|
+
shell=True,
|
|
26
|
+
check=True,
|
|
27
|
+
text=True,
|
|
28
|
+
input=input_data,
|
|
29
|
+
stdout=subprocess.PIPE,
|
|
30
|
+
)
|
|
31
|
+
return completed.stdout.rstrip("\n")
|
|
32
|
+
except subprocess.CalledProcessError as exc:
|
|
33
|
+
|
|
34
|
+
def __fallback(exc=exc):
|
|
35
|
+
raise exc
|
|
36
|
+
|
|
37
|
+
exc.__fallback__ = __fallback
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SubprocessStatus:
|
|
42
|
+
def __init__(self, cmd: str) -> None:
|
|
43
|
+
self.cmd = cmd
|
|
44
|
+
|
|
45
|
+
def __call__(self, input_data=None):
|
|
46
|
+
try:
|
|
47
|
+
if input_data is None:
|
|
48
|
+
subprocess.run(self.cmd, shell=True, check=True)
|
|
49
|
+
else:
|
|
50
|
+
if not isinstance(input_data, (str, bytes)):
|
|
51
|
+
input_data = str(input_data)
|
|
52
|
+
subprocess.run(
|
|
53
|
+
self.cmd,
|
|
54
|
+
shell=True,
|
|
55
|
+
check=True,
|
|
56
|
+
text=True,
|
|
57
|
+
input=input_data,
|
|
58
|
+
)
|
|
59
|
+
return 0
|
|
60
|
+
except subprocess.CalledProcessError as exc:
|
|
61
|
+
|
|
62
|
+
def __fallback(exc=exc):
|
|
63
|
+
return exc.returncode
|
|
64
|
+
|
|
65
|
+
exc.__fallback__ = __fallback
|
|
66
|
+
raise
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snail-lang
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Requires-Dist: jmespath>=1.0.1
|
|
5
|
+
Requires-Dist: maturin>=1.5 ; extra == 'dev'
|
|
6
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Summary: Snail programming language interpreter
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="logo.png" alt="Snail logo" width="200">
|
|
15
|
+
</p>
|
|
16
|
+
<p align="center"><em>What do you get when you shove a snake in a shell?</em></p>
|
|
17
|
+
|
|
18
|
+
<h1 align="center">Snail</h1>
|
|
19
|
+
|
|
20
|
+
**Snail** is a programming language that compiles to Python, combining Python's familiarity and extensive libraries with Perl/awk-inspired syntax for quick scripts and one-liners.
|
|
21
|
+
|
|
22
|
+
## Installing Snail
|
|
23
|
+
|
|
24
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and then run:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv tool install -p 3.12 snail-lang
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That installs the `snail` CLI for your user; try it with `snail "print('hello')"` once the install completes.
|
|
31
|
+
|
|
32
|
+
## ✨ What Makes Snail Unique
|
|
33
|
+
|
|
34
|
+
### Curly Braces, Not Indentation
|
|
35
|
+
|
|
36
|
+
Write Python logic without worrying about whitespace:
|
|
37
|
+
|
|
38
|
+
```snail
|
|
39
|
+
def process(items) {
|
|
40
|
+
for item in items {
|
|
41
|
+
if item > 0 { print(item) }
|
|
42
|
+
else { continue }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Note, since it is jarring to write python with semicolons everywhere,
|
|
48
|
+
semicolons are optional. You can separate statements with newlines.
|
|
49
|
+
|
|
50
|
+
### Awk Mode
|
|
51
|
+
|
|
52
|
+
Process files line-by-line with familiar awk semantics:
|
|
53
|
+
|
|
54
|
+
```snail-awk("5\n4\n3\n2\n1\nbanana\n")
|
|
55
|
+
BEGIN { total = 0 }
|
|
56
|
+
/^[0-9]+/ { total = total + int($1) }
|
|
57
|
+
END { print("Sum:", total); assert total == 15}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Built-in variables: `$0` (line), `$1`, `$2` etc (access fields), `$n` (line number), `$fn` (per-file line number), `$p` (file path), `$m` (last match).
|
|
61
|
+
|
|
62
|
+
### Compact Error Handling
|
|
63
|
+
|
|
64
|
+
The `?` operator makes error handling terse yet expressive:
|
|
65
|
+
|
|
66
|
+
```snail
|
|
67
|
+
# Swallow exception, return None
|
|
68
|
+
err = risky()?
|
|
69
|
+
|
|
70
|
+
# Swallow exception, return exception object
|
|
71
|
+
err = risky():$e?
|
|
72
|
+
|
|
73
|
+
# Provide a fallback value (exception available as $e)
|
|
74
|
+
value = js("malformed json"):{"error": "invalid json"}?
|
|
75
|
+
details = fetch_url("foo.com"):"default html"?
|
|
76
|
+
exception_info = fetch_url("example.com"):$e.http_response_code?
|
|
77
|
+
|
|
78
|
+
# Access attributes directly
|
|
79
|
+
name = risky("")?.__class__.__name__
|
|
80
|
+
args = risky("becomes a list"):[1,2,3]?[0]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Destructuring + `if let` / `while let`
|
|
84
|
+
|
|
85
|
+
Unpack tuples and lists directly, including Python-style rest bindings:
|
|
86
|
+
|
|
87
|
+
```snail
|
|
88
|
+
x, *xs = [1, 2, 3]
|
|
89
|
+
|
|
90
|
+
if let [head, *tail] = [1, 2, 3]; head > 0 {
|
|
91
|
+
print(head, tail)
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`if let`/`while let` only enter the block when the destructuring succeeds. A guard
|
|
96
|
+
after `;` lets you add a boolean check that runs after the bindings are created.
|
|
97
|
+
|
|
98
|
+
Note that this syntax is more powerful than the walrus operator as that does
|
|
99
|
+
not allow for destructuring.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
### Pipeline Operator
|
|
103
|
+
|
|
104
|
+
The `|` operator enables data pipelining as syntactic sugar for nested
|
|
105
|
+
function calls. `x | y | z` becomes `z(y(x))`. This lets you stay in a
|
|
106
|
+
shell mindset.
|
|
107
|
+
|
|
108
|
+
```snail
|
|
109
|
+
# Pipe data to subprocess stdin
|
|
110
|
+
result = "hello\nworld" | $(grep hello)
|
|
111
|
+
|
|
112
|
+
# Chain multiple transformations
|
|
113
|
+
output = "foo\nbar" | $(grep foo) | $(wc -l)
|
|
114
|
+
|
|
115
|
+
# Custom pipeline handlers
|
|
116
|
+
class Doubler {
|
|
117
|
+
def __call__(self, x) { return x * 2 }
|
|
118
|
+
}
|
|
119
|
+
doubled = 21 | Doubler() # yields 42
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Arbitrary callables make up pipelines, even if they have multiple parameters.
|
|
123
|
+
Snail supports this via placeholders.
|
|
124
|
+
```snail
|
|
125
|
+
greeting = "World" | greet("Hello ", _) # greet("Hello ", "World")
|
|
126
|
+
excited = "World" | greet(_, "!") # greet("World", "!")
|
|
127
|
+
formal = "World" | greet("Hello ", suffix=_) # greet("Hello ", "World")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
When a pipeline targets a call expression, the left-hand value is passed to the
|
|
131
|
+
resulting callable. If the call includes a single `_` placeholder, Snail substitutes
|
|
132
|
+
the piped value at that position (including keyword arguments). Only one
|
|
133
|
+
placeholder is allowed in a piped call. Outside of pipeline calls, `_` remains a
|
|
134
|
+
normal identifier.
|
|
135
|
+
|
|
136
|
+
### Built-in Subprocess
|
|
137
|
+
|
|
138
|
+
Shell commands are first-class citizens with capturing and non-capturing
|
|
139
|
+
forms.
|
|
140
|
+
|
|
141
|
+
```snail
|
|
142
|
+
# Capture command output with interpolation
|
|
143
|
+
greeting = $(echo hello {name})
|
|
144
|
+
|
|
145
|
+
# Pipe data through commands
|
|
146
|
+
result = "foo\nbar\nbaz" | $(grep bar) | $(cat -n)
|
|
147
|
+
|
|
148
|
+
# Check command status
|
|
149
|
+
@(make build)? # returns exit code on failure instead of raising
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
### Regex Literals
|
|
154
|
+
|
|
155
|
+
Snail supports first class patterns. Think of them as an infinte set.
|
|
156
|
+
|
|
157
|
+
```snail
|
|
158
|
+
if bad_email in /^[\w.]+@[\w.]+$/ {
|
|
159
|
+
print("Valid email")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Compiled regex for reuse
|
|
163
|
+
pattern = /\d{3}-\d{4}/
|
|
164
|
+
match = pattern.search(phone)
|
|
165
|
+
match2 = "555-1212" in pattern
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Snail regexes don't return a match object, rather they return a tuple
|
|
169
|
+
containing all of the match groups, including group 0. Both `search` and `in`
|
|
170
|
+
return the same tuple (or `()` when there is no match).
|
|
171
|
+
|
|
172
|
+
### JSON Queries with JMESPath
|
|
173
|
+
|
|
174
|
+
Parse and query JSON data with the `js()` function and structured pipeline accessor:
|
|
175
|
+
|
|
176
|
+
```snail
|
|
177
|
+
# Parse JSON and query with $[jmespath]
|
|
178
|
+
|
|
179
|
+
# JSON query with JMESPath
|
|
180
|
+
data = js($(curl -s https://api.github.com/repos/sudonym1/snail))
|
|
181
|
+
counts = data | $[stargazers_count]
|
|
182
|
+
|
|
183
|
+
# Inline parsing and querying
|
|
184
|
+
result = js('{{"foo": 12}}') | $[foo]
|
|
185
|
+
|
|
186
|
+
# JSONL parsing returns a list
|
|
187
|
+
names = js('{{"name": "Ada"}}\n{{"name": "Lin"}}') | $[[*].name]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Full Python Interoperability
|
|
191
|
+
|
|
192
|
+
Snail compiles to Python AST—import any Python module, use any library, in any
|
|
193
|
+
environment. Assuming that you are using Python 3.10 or later.
|
|
194
|
+
|
|
195
|
+
## 🚀 Quick Start
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
# One-liner: arithmetic + interpolation
|
|
199
|
+
snail 'name="Snail"; print("{name} says: {6 * 7}")'
|
|
200
|
+
|
|
201
|
+
# JSON query with JMESPath
|
|
202
|
+
snail 'js($(curl -s https://api.github.com/repos/sudonym1/snail)) | $[stargazers_count]'
|
|
203
|
+
|
|
204
|
+
# Compact error handling with fallback
|
|
205
|
+
snail 'result = int("oops"):"bad int {$e}"?; print(result)'
|
|
206
|
+
|
|
207
|
+
# Regex match and capture
|
|
208
|
+
snail 'if let [_, user, domain] = "user@example.com" in /^[\w.]+@([\w.]+)$/ { print(domain) }'
|
|
209
|
+
|
|
210
|
+
# Awk mode: print line numbers for matches
|
|
211
|
+
rg -n "TODO" README.md | snail --awk '/TODO/ { print("{$n}: {$0}") }'
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## 📚 Documentation
|
|
215
|
+
|
|
216
|
+
Documentation is WIP
|
|
217
|
+
|
|
218
|
+
- **[Language Reference](docs/REFERENCE.md)** — Complete syntax and semantics
|
|
219
|
+
- **[examples/all_syntax.snail](examples/all_syntax.snail)** — Every feature in one file
|
|
220
|
+
- **[examples/awk.snail](examples/awk.snail)** — Awk mode examples
|
|
221
|
+
|
|
222
|
+
## 🔌 Editor Support
|
|
223
|
+
|
|
224
|
+
Vim/Neovim plugin with syntax highlighting, formatting, and run commands:
|
|
225
|
+
|
|
226
|
+
```vim
|
|
227
|
+
Plug 'sudonym1/snail', { 'rtp': 'extras/vim' }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
See [extras/vim/README.md](extras/vim/README.md) for details. Tree-sitter grammar available in `extras/tree-sitter-snail/`.
|
|
231
|
+
|
|
232
|
+
## 🛠️ Building from Source
|
|
233
|
+
|
|
234
|
+
### Prerequisites
|
|
235
|
+
|
|
236
|
+
**Python 3.10+** (required at runtime)
|
|
237
|
+
|
|
238
|
+
Snail runs in-process via a Pyo3 extension module, so it uses the active Python environment.
|
|
239
|
+
|
|
240
|
+
Installation per platform:
|
|
241
|
+
- **Ubuntu/Debian**: `sudo apt install python3 python3-dev`
|
|
242
|
+
- **Fedora/RHEL**: `sudo dnf install python3 python3-devel`
|
|
243
|
+
- **macOS**: `brew install python@3.12` (or use the system Python 3)
|
|
244
|
+
- **Windows**: Download from [python.org](https://www.python.org/downloads/)
|
|
245
|
+
|
|
246
|
+
### Build, Test, and Install
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
# Clone the repository
|
|
250
|
+
git clone https://github.com/sudonym1/snail.git
|
|
251
|
+
cd snail
|
|
252
|
+
|
|
253
|
+
make test
|
|
254
|
+
make install
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
**Note on Proptests**: The `snail-proptest` crate contains property-based tests that are skipped by default to keep development iteration fast.
|
|
259
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
snail/__init__.py,sha256=wuFoVD7mdEm8z-zzbU5bBb2svxKRcuPfWx_hWcdxXFU,695
|
|
2
|
+
snail/_native.abi3.so,sha256=p41cfjm7gqRUT9Polr6xDvrIhu2sjNxD3QUrOK_Y18I,1461008
|
|
3
|
+
snail/cli.py,sha256=aB1PopuCP0ReJ-Odks-MVotf_GOpT-o5--gbmitsFIM,7200
|
|
4
|
+
snail/runtime/__init__.py,sha256=P9bRT8oaXEwJPFbYbpPLSjHzU0eix1G1A808RGdLeo8,4461
|
|
5
|
+
snail/runtime/compact_try.py,sha256=LFT41W1XPNamZfCqG6PpiHz4oKd3s24XvzjYo0G7Iok,380
|
|
6
|
+
snail/runtime/regex.py,sha256=ciy6_HDkOHAJL2lip9T-OMqkzISYc37Jx4lm444RAJg,879
|
|
7
|
+
snail/runtime/structured_accessor.py,sha256=nj5jWZ3K5tEjqxMxgTz5WUNIykLLvFEsmEZkJn7izbQ,2163
|
|
8
|
+
snail/runtime/subprocess.py,sha256=-SrFrj5mXCjfSaqcm_93kG1WxWNc6jPKGpW7RKjpxJ8,1889
|
|
9
|
+
snail_lang-0.5.2.dist-info/METADATA,sha256=zSVaOL9EvqFuUs86suni8G6OuOAW9FdVBsQRl00wDZY,7363
|
|
10
|
+
snail_lang-0.5.2.dist-info/WHEEL,sha256=vZ12AMAE5CVtd8oYbYGrz3omfHuIZCNO_3P50V00s00,104
|
|
11
|
+
snail_lang-0.5.2.dist-info/entry_points.txt,sha256=8u6pjHZtKGdaZQlr6yKbftplObC07caDZ44j5PIPU6M,39
|
|
12
|
+
snail_lang-0.5.2.dist-info/licenses/LICENSE,sha256=erKVJCZPZ4jVaEjq4ZDw2OCXmmby5r1Z6xCpn2Akktc,1062
|
|
13
|
+
snail_lang-0.5.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Seamus Connor
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
20
|
+
|