Compare commits

...

265 Commits

Author SHA1 Message Date
62612bd8db Merge pull request 'Properly check variable assignment' (#36) from feat/variable-is-defined into main
All checks were successful
Tests / tests (push) Successful in 6s
Reviewed-on: #36
2026-07-08 17:30:40 +00:00
4ed78d3d7e chore: add new parameter to docstring
All checks were successful
Tests / tests (pull_request) Successful in 5s
2026-07-08 19:30:15 +02:00
ab7012c538 fix(checker): leaking for-loop iterator target
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-08 19:21:19 +02:00
06f71f2945 tests: add test for variable assignment 2026-07-08 19:09:59 +02:00
1b5691dca7 fix(resolver): properly check if variable is defined 2026-07-08 18:42:28 +02:00
2118c260ab Merge pull request 'Complete documentation' (#32) from feat/complete-docs into main
All checks were successful
Tests / tests (push) Successful in 6s
Reviewed-on: #32
2026-07-08 15:41:00 +00:00
403238da09 docs: add commands in manual
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-08 17:39:06 +02:00
c00dfe9910 chore: pin python version for test ci
All checks were successful
Tests / tests (pull_request) Successful in 23s
2026-07-08 16:46:53 +02:00
597ae6bb4c tests: add tests summary
Some checks failed
Tests / tests (pull_request) Failing after 5s
2026-07-08 16:45:03 +02:00
1162716a25 tests: exit with error code if failure when running all tests
Some checks failed
Tests / tests (pull_request) Failing after 5s
2026-07-08 16:20:44 +02:00
c2ebf885ac chore: remove uv from test workflow
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-08 16:19:20 +02:00
7cddc62aaa chore: add some files in gitignore
Some checks failed
Tests / tests (pull_request) Failing after 1m32s
2026-07-08 16:11:51 +02:00
49e00d9fbc chore: add workflow to run tests 2026-07-08 16:09:53 +02:00
1d2f98419e chore: allow passing commit hash through inputs 2026-07-08 15:48:48 +02:00
2a73dc3fef chore: add ci to compile manual 2026-07-08 15:48:47 +02:00
ede7396f9b chore: update README 2026-07-08 15:48:46 +02:00
6ca778dbfa Merge pull request 'Remove complex type' (#35) from feat/remove-complex-type into main
Reviewed-on: #35
2026-07-08 13:44:58 +00:00
672c9c0fa1 docs: remove complex type from syntax definition 2026-07-08 15:44:07 +02:00
dd2f3d6f6a tests: fix frame ops with filtered groupby columns
see 205d19fb72
2026-07-08 15:37:45 +02:00
ef9dd95844 tests: rewrite test with complex types 2026-07-08 15:35:13 +02:00
725e030374 feat: remove complex and extension types 2026-07-08 15:34:40 +02:00
db986d5242 Merge pull request 'Update syntax definitions' (#34) from feat/update-syntax into main
Reviewed-on: #34
2026-07-08 12:48:14 +00:00
3c97e75db6 feat(parser): allow subscript in type annotations 2026-07-08 14:43:52 +02:00
e0a468a2c2 docs: update annotation syntax definitions 2026-07-08 14:43:10 +02:00
6740344eba docs: update Midas EBNF 2026-07-08 14:08:00 +02:00
4f9099a4c4 chore: update VSCode syntax definition
the TextMate language definition was completely rewritten by Claude from my Sublime Syntax definition
some tests against Midas files show that it seems on par with the other definition

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-08 13:41:47 +02:00
5c66c4b645 docs: update syntax railroad diagrams 2026-07-08 13:32:32 +02:00
c0896d2b9b Merge pull request 'Minor improvements' (#33) from fix/weather-pipeline into main
Reviewed-on: #33
2026-07-08 08:13:08 +00:00
607ff53987 feat(checker): handle single string literal in groupby 2026-07-08 10:12:54 +02:00
3268783cbe chore: improve weather pipeline 2026-07-08 10:04:16 +02:00
a48182a4e3 fix(checker): allow calling methods on TopType and UnknownType 2026-07-08 10:03:51 +02:00
205d19fb72 feat(checker): try to filter groupby columns 2026-07-08 10:03:14 +02:00
10c6ea7dda feat(checker): add context to reports 2026-07-08 09:39:11 +02:00
1acf33f376 chore: fix weather pipeline example 2026-07-08 09:32:39 +02:00
aae481776f fix(checker): check ConstraintType's constraint type 2026-07-08 09:32:13 +02:00
e855a09a6b Merge pull request 'Various features for example pipeline' (#31) from feat/complete-example into main
Reviewed-on: #31
2026-07-07 15:30:21 +00:00
094554cb72 tests: update with new aggregate return types 2026-07-07 17:14:30 +02:00
40bda81c32 docs: update docstring with new formula 2026-07-07 17:11:50 +02:00
25c11c3a53 fix(checker): compute aggregation of column groupby 2026-07-07 17:11:10 +02:00
f3dec414cc fix(cli): tweak highlight diagnostic tooltip 2026-07-07 16:01:55 +02:00
48be2d454c feat(checker): add formula to compute aggregation type 2026-07-07 15:46:15 +02:00
5958e3612b chore: add comments to weather example 2026-07-07 15:13:39 +02:00
83eecd612e chore: add weather pipeline example 2026-07-07 14:31:04 +02:00
5311307a6f tests: update with unsupported flag 2026-07-07 13:51:46 +02:00
fe34c77e4c tests: update with integer literals 2026-07-07 13:51:25 +02:00
c2f41eb392 fix: make Frame/Column inherit from pandas classes 2026-07-07 13:43:57 +02:00
ed07b01563 docs: add docstrings to generator submodule 2026-07-07 12:09:44 +02:00
c6b8c0a145 feat(checker): add sort_values on frames and columns 2026-07-07 11:28:06 +02:00
f48ebd49d1 feat(checker): add unsupported parameter flag 2026-07-07 11:24:23 +02:00
5051e155c0 fix(gen): only generate length assertion for non-scalar ops 2026-07-07 11:22:07 +02:00
1098e33d07 feat(checker): handle scalar ops on frames and columns 2026-07-07 10:27:20 +02:00
9277bd2cd0 fix(gen): generate assertion definitions 2026-07-07 10:03:48 +02:00
5e87ba2cd1 fix(checker): report predicate argument errors 2026-07-07 10:02:52 +02:00
4eb133ce17 fix(checker): handle setting unknown as column 2026-07-07 10:01:59 +02:00
56987f6cff fix(parser): parse int literals as integers
avoid casting to float when the literal is an integer
2026-07-07 00:10:33 +02:00
af6056a83f fix(checker): handle all unary ops in evaluator 2026-07-07 00:09:39 +02:00
aab4641584 fix(checker): properly check parameter defaults 2026-07-06 17:15:59 +02:00
164648e8df feat: handle not operator 2026-07-06 17:06:21 +02:00
a640b8b3dd feat(checker): add copy and info methods 2026-07-06 15:56:57 +02:00
8bc35f7754 fix: add __name__ to preamble 2026-07-06 15:28:10 +02:00
16c19cca75 fix(checker): handle application of column type 2026-07-06 15:27:50 +02:00
3de36e5bd5 feat: handle import statements
import statements now introduce variables with unknown types to avoid "Unknown variable" warnings
2026-07-06 15:21:56 +02:00
7075d011b8 Merge pull request 'Column subscripts' (#30) from feat/column-subscript into main
Reviewed-on: #30
2026-07-06 10:05:14 +00:00
d97a9c0209 feat(checker): handle subscript on column types 2026-07-06 11:58:37 +02:00
a418fe3eaf Merge pull request 'Add docstrings, rebrand parameters and refactor printers' (#29) from feat/add-docstrings into main
Reviewed-on: #29
2026-07-06 09:29:43 +00:00
aae3073744 docs: add docstrings to types 2026-07-06 11:14:23 +02:00
b11a9bb8c6 docs: add docstrings to frame classes 2026-07-06 11:03:31 +02:00
bac0e334d5 docs: add docstrings for type checkers 2026-07-06 00:32:48 +02:00
30aef99c08 docs: add docstrings for most files in checker module 2026-07-05 23:54:03 +02:00
9764484fd9 docs: add docstrings to midas parser 2026-07-04 01:30:14 +02:00
5b9e322c91 docs: add some docstrings in lexer classes 2026-07-03 22:41:21 +02:00
c18d9c18de tests: update with new parameter spec 2026-07-03 19:31:17 +02:00
9229f00375 refactor: rebrand function parameters and unify spec
rename function arguments to parameters where it was wrong, and add ParamSpec for Python AST, like for Midas
2026-07-03 19:24:30 +02:00
6b7a682dc5 docs: add some docstrings 2026-07-03 17:36:45 +02:00
35b97fd17b refactor(ast): restructure printers 2026-07-03 17:26:28 +02:00
03bc32400b Merge pull request 'Frame / columns in manual' (#28) from feat/frame-columns-in-manual into main
Reviewed-on: #28
2026-07-03 14:38:44 +00:00
4a93ee45d9 docs: add section about Frame type annotations 2026-07-03 16:32:35 +02:00
8197131d8d docs: add Column and Frame to manual 2026-07-03 13:31:56 +02:00
cf91187b7a fix(checker): remove bool as subtype of int 2026-07-03 12:56:47 +02:00
1b2bdf0b79 docs: add alias statements to manual 2026-07-03 12:56:20 +02:00
c6cc38bfeb Merge pull request 'Frame / column operations' (#27) from feat/simple-frame-ops into main
Reviewed-on: #27
2026-07-03 10:29:32 +00:00
4d3e3f44a1 fix(checker): correctly check length of frame/column 2026-07-03 12:28:39 +02:00
ec80b1e92e feat(checker): add head/tail methods 2026-07-03 12:13:30 +02:00
4ea15519f3 feate(checker): add some frame/column attributes 2026-07-03 12:07:36 +02:00
7a6e01cff8 fix(checker): delegate frame aggregate methods to columns 2026-07-03 11:42:35 +02:00
733c8736b8 feat(checker): add aggregation ops on column groupby 2026-07-03 11:25:06 +02:00
20173a0b07 feat(tests): add colors and run all tests in base module 2026-07-03 10:58:28 +02:00
a143972ef1 feat(checker): add aggregation ops on frame groupby 2026-07-03 02:20:51 +02:00
0c70048b62 feat(checker): add statistical ops on columns 2026-07-03 01:34:58 +02:00
1c0c917873 feat(checker): add statistical ops on frames 2026-07-03 01:27:16 +02:00
1f6189daa4 feat(checker): add comparison binary ops on columns 2026-07-03 01:05:24 +02:00
66b585c3d6 fix(checker): recursively check builtin subtypes 2026-07-03 01:04:45 +02:00
819ab3c2bf tests: add dataframe operations test 2026-07-03 00:58:29 +02:00
d8c0b17512 feat(checker): add comparison binary ops on frames 2026-07-03 00:57:27 +02:00
6e06f9078e fix(checker): improve unknown method message 2026-07-03 00:57:10 +02:00
ece2e3a6a3 feat(checker): add arithmetic binary ops on columns 2026-07-03 00:42:00 +02:00
74c07c9afb feat(checker): add arithmetic binary ops on frames 2026-07-03 00:38:56 +02:00
be2fd4c837 feat(checker): delegate element operation to inner type
delegate element-wise binary operation on columns to their inner types
2026-07-03 00:05:40 +02:00
1bc4c704c3 feat(checker): delegate element operation to columns
delegate element-wise binary operation on frames to columns
2026-07-02 23:41:08 +02:00
0288a05901 feat(checker): handle assignment to multiple columns 2026-07-02 23:29:10 +02:00
b14f46d405 feat(checker): handle calls on group-bys 2026-07-02 19:53:58 +02:00
8e8ed62266 feat(checker): add add/mean/groupby on columns 2026-07-02 19:30:43 +02:00
2fce2f4bfc feat(checker): add column method registry 2026-07-02 19:23:23 +02:00
640f2d1771 feat(checker): support unification of frames and columns 2026-07-02 19:22:28 +02:00
b48dfe5301 refactor: make MethodRegistry generic on Call 2026-07-02 18:27:26 +02:00
0d5840a4ce refactor: restructure frame method registry in submodule 2026-07-02 18:20:10 +02:00
3c92f0867d feat(types): add ColumnGroupBy 2026-07-02 18:00:25 +02:00
b5acae4078 feat(types): add FrameGroupBy type 2026-07-02 17:45:18 +02:00
5d20f8ec3e docs: mention eager evaluation in manual 2026-07-02 17:22:28 +02:00
955c2233ed feat(checker): statically evaluate casts to Any and None 2026-07-02 17:14:30 +02:00
ff69b65171 feat(checker): add same length assertion on frames
safely adding two dataframes is only possible if the sizes are the same, or null values could be added dynamically to pad the shortest dataframe
2026-07-02 17:14:05 +02:00
8df01afd8c feat(gen): materialize assertions from collector 2026-07-02 17:10:27 +02:00
47b2dfdd73 feat(gen): add assertion collector to TypedAST 2026-07-02 17:09:50 +02:00
bd4d793ce0 feat(gen): add Assertion class 2026-07-02 17:08:43 +02:00
f7a36f61b6 fix(checker): pass AST expression to method registry 2026-07-01 22:34:02 +02:00
ad2fabf471 feat(checker): add assertion collector 2026-07-01 22:32:13 +02:00
a59a58d21a feat(gen): generate alias stubs 2026-07-01 14:43:30 +02:00
3260ae4a1e Merge pull request 'Call dispatcher' (#26) from feat/call-dispatcher into main
Reviewed-on: #26
2026-07-01 12:22:11 +00:00
bd1c9581c7 fix(checker): use dispatcher in frame method registry 2026-07-01 14:17:10 +02:00
663642ea6c fix(tests): serialize alias statements 2026-07-01 14:13:27 +02:00
e2abc04fe4 feat(checker): define min/max in preamble 2026-07-01 14:10:19 +02:00
a4016b55ce feat(checker): handle calls to AppliedType 2026-07-01 14:10:19 +02:00
1ea5da7024 feat(parser): parse binary operations in Midas 2026-07-01 14:10:18 +02:00
a017a8cf1f feat(checker): catch errors when evaluating constraint 2026-07-01 14:10:17 +02:00
8fc5ab623e feat(checker): evaluate literal cast to list/dict 2026-07-01 14:10:16 +02:00
14007db846 feat(checker): evaluate unary op on literals 2026-07-01 14:10:15 +02:00
6ad2ce4b68 feat(checker): improve function unwrapping 2026-07-01 14:10:15 +02:00
9a276c34c7 refactor: reuse CallDispatcher 2026-07-01 11:32:41 +02:00
6e717a3f9e refactor: use CallDispatcher in Midas typer 2026-07-01 11:24:09 +02:00
77aadfa264 refactor: extract function call methods to CallDispatcher 2026-07-01 11:14:08 +02:00
c81287df7f Merge pull request 'Initial dataframe implementation' (#25) from feat/dataframes into main
Reviewed-on: #25
2026-07-01 08:24:36 +00:00
ffccc1bedd feat(cli): generate stubs in build dir when compiling 2026-07-01 10:16:13 +02:00
d14f208897 feat(gen): add tuple expr to generator 2026-07-01 10:16:13 +02:00
293953a078 tests: update with multi-parameter generics 2026-07-01 10:16:12 +02:00
bccc96e4d0 fix: minor fixes 2026-07-01 10:16:11 +02:00
9db56adf56 feat: add Python tuple expression 2026-07-01 10:16:10 +02:00
3f99563ac8 feat: handle multi-parameter generic in Python 2026-07-01 10:16:10 +02:00
b36896cc7b feat(checker): add len() 2026-07-01 10:16:09 +02:00
cb75878ae9 fix(checker): allow some assignments to unknown 2026-07-01 10:16:08 +02:00
a5fe985eb2 feat(checker): add methods on str 2026-07-01 10:16:08 +02:00
e324f414e6 feat(checker): type check tuple instantiation in Midas 2026-07-01 10:16:07 +02:00
256536562f fix(parser): parse empty calls 2026-07-01 10:16:06 +02:00
64f4314f0d fix(gen): prevent empty loop for column asserts 2026-07-01 10:16:06 +02:00
6f6245d283 fix(checker): allow iterating on unknown 2026-07-01 10:16:05 +02:00
3392bc347d fix(checker): allow subtypes and unknown as if test 2026-07-01 10:16:04 +02:00
7e0319906a feat(gen): assertions for column values 2026-07-01 10:16:03 +02:00
75bd203d4a fix(checker): allow calling unknown method on dataframes 2026-07-01 10:15:16 +02:00
db40198357 feat(gen): generate asserts for dataframes and columns 2026-07-01 10:15:16 +02:00
d79e1dee18 fix(checker): change heterogeneous errors to warnings 2026-07-01 10:15:15 +02:00
4ea400265c feat(checker): add mean method on frames 2026-07-01 10:15:14 +02:00
24bffdabd4 fix(checker): type check None literal 2026-07-01 10:15:13 +02:00
d7bb6326de feat(checker): lookup dunders on dataframes 2026-07-01 10:15:12 +02:00
dbf6f9e2db tests: update with reordered argument typing 2026-07-01 10:15:12 +02:00
3cdc9031d3 refactor: use metaclass to collect frame methods 2026-07-01 10:15:11 +02:00
00e2ca8fe3 refactor: add MethodResolver class 2026-07-01 10:15:10 +02:00
4efb01285c feat: add dummy classes for typing frames and columns 2026-07-01 10:15:10 +02:00
f84a19159f fix(checker): improve heterogeneous error message 2026-07-01 10:15:09 +02:00
946b2e0d2e feat(checker): lookup dataframe methods 2026-07-01 10:15:08 +02:00
08dd7408ec feat(checker): defined add method of dataframes 2026-07-01 10:15:07 +02:00
b33fadf768 feat(checker): add structural subtyping rule for dataframes 2026-07-01 10:15:06 +02:00
7219109e5d feat(cli): print context for multiline diagnostics 2026-07-01 10:14:48 +02:00
cdf1725c26 feat(checker): process frame type definitions 2026-07-01 10:14:48 +02:00
7074b074bc feat(cli): add frame type to highlighter 2026-07-01 10:14:17 +02:00
ede7272c09 feat(parser): add frame type to midas syntax 2026-07-01 10:14:16 +02:00
87d5e286d2 feat(gen): add support for tuples and dataframes 2026-07-01 10:14:16 +02:00
c91b206791 feat(checker): handle setting dataframe column 2026-07-01 10:13:30 +02:00
a31d295eb1 feat(checker): type check subscript on dataframes 2026-07-01 10:13:30 +02:00
0d20993f02 feat(types): add TupleType 2026-07-01 10:13:28 +02:00
5357ca8e58 fix(types): add str methods to dataframe types 2026-07-01 10:13:28 +02:00
556765fd35 feat(types): add DataFrameType and ColumnType 2026-07-01 10:13:27 +02:00
d039a8e4b3 Merge pull request 'Type aliases vs. Derived types' (#24) from feat/subtypes-and-aliases into main
Reviewed-on: #24
2026-07-01 08:09:13 +00:00
c4533421eb feat(checker): process alias definitions 2026-07-01 09:59:58 +02:00
73769b42c1 feat(parser): add alias keyword and statement 2026-07-01 09:30:09 +02:00
087f6b4669 refactor(types): rename AliasType to DerivedType 2026-06-30 16:28:16 +02:00
d582df5927 Merge pull request 'User manual' (#23) from feat/manual into main
Reviewed-on: #23
2026-06-30 14:11:45 +00:00
6a0401833c feat(manual): add strings to midas syntax def 2026-06-30 14:10:32 +02:00
e15607b763 fix(manual): end syntax highlighting of extend body 2026-06-30 14:03:42 +02:00
e28f324a85 fix(manual): typos 2026-06-28 22:30:09 +02:00
31e696c938 feat(manual): add listings outline and tweak template 2026-06-28 22:28:13 +02:00
759b416bf3 feat(manual): wrap all code in figures 2026-06-28 22:20:15 +02:00
4b2b0fe476 feat(manual): document supported Python syntax 2026-06-28 21:41:39 +02:00
4c39504750 feat(manual): document predicate and constraints 2026-06-28 14:12:41 +02:00
f9f3ade6c7 feat(manual): document type statement 2026-06-28 12:37:44 +02:00
386018b956 feat(manual): add sublime syntax for Midas 2026-06-28 12:36:02 +02:00
bd47d33355 feat(manual): complete introduction and quick start 2026-06-26 17:52:54 +02:00
93ddb28802 docs: setup user manual 2026-06-24 15:53:52 +02:00
f7c43837b5 Merge pull request 'CLI tweaks' (#22) from fix/cli-tweaks into main
Reviewed-on: #22
2026-06-24 12:18:07 +00:00
32ed62a6f1 fix(cli): show summary of diagnostic counts 2026-06-24 14:11:39 +02:00
66f39acec0 fix(cli): show all diagnostics in types command
combine type checker diagnostics with judgements info diagnostics
2026-06-24 14:11:15 +02:00
6c04e2fee4 feat(cli): add compile option to ignore errors 2026-06-24 14:10:30 +02:00
2bb2e0a684 Merge pull request 'Unsafe cast' (#21) from feat/unsafe-cast into main
Reviewed-on: #21
2026-06-24 12:00:03 +00:00
5630320d21 chore: use unsafe_cast in demo script 2026-06-24 13:57:38 +02:00
9f05ba3224 feat: handle unsafe casts 2026-06-24 13:51:14 +02:00
5fbe965919 feat(checker): add typing submodule with cast functions 2026-06-24 13:40:23 +02:00
252a5abdfd Merge pull request 'Static evalution of casts on literals' (#20) from feat/literal-static-constraints into main
Reviewed-on: #20
2026-06-24 09:32:54 +00:00
55fba6a088 tests: update test without evaluated casts 2026-06-24 11:28:44 +02:00
70ce263ea2 feat(gen): skip assertions for evaluated casts
avoid generating a runtime assertion for a cast which has already been checked statically
2026-06-24 11:28:43 +02:00
e1d5eac8b8 feat(checker): evaluate constraints statically on literals 2026-06-24 11:10:09 +02:00
82666a4918 feat(checker): add evaluator
add an evaluator class to evaluate expressions using literal values
2026-06-24 11:08:15 +02:00
45f84a2f23 feat(checker): add debug diagnostics 2026-06-24 11:07:42 +02:00
dedfcb4dbb feat(checker): store builtin python functions in preamble 2026-06-24 11:05:36 +02:00
d9ea6365ea tests: update with cast expression judgement 2026-06-23 16:49:38 +02:00
9c7a93412c Merge pull request 'Fixes and small demo' (#19) from feat/demonstration into main
Reviewed-on: #19
2026-06-23 08:15:56 +00:00
d6b8fbfb60 chore: improve demo example 2026-06-23 10:03:24 +02:00
b290c59ac4 fix(gen): add bases for ConstraintType and TypeVar 2026-06-23 00:25:43 +02:00
093f2bc477 fix(checker): lookup member on typevar bound 2026-06-23 00:24:37 +02:00
7c771c4070 feat(checker): add input function to preamble 2026-06-23 00:22:38 +02:00
a50a207385 fix(gen): don't generate stubs for builtin types 2026-06-22 15:40:31 +02:00
7e5ea5e414 chore: add example to demonstrate some features 2026-06-22 15:29:39 +02:00
0ba0266bae fix(checker): check general subtype case for AppliedType
this adds the case where we check whether AppliedType <: Type, and delegates to the body

this may not be a legitimate rule, or may need to be refined
2026-06-22 15:27:06 +02:00
216c80f08c fix(checker): produce judgement for expression in cast 2026-06-22 15:24:51 +02:00
f75d7722a1 fix(checker): look up members on constraint type 2026-06-22 15:24:18 +02:00
2f29c47274 fix(gen): assert type var bound 2026-06-22 15:23:53 +02:00
80af2b9048 fix(checker): handle is_subtype of TypeVar 2026-06-22 14:44:51 +02:00
577454ee7e fix(checker): make UnknownType a top type for subtyping 2026-06-22 14:15:18 +02:00
878693383e feat(cli): add watch option to stubs command 2026-06-22 14:14:05 +02:00
0b91de75a8 feat(checker): handle type vars in python functions 2026-06-22 14:13:25 +02:00
739871c101 Merge pull request 'Generic call unification' (#18) from feat/unification into main
Reviewed-on: #18
2026-06-21 11:41:48 +00:00
4395e9339b fix(checker): abort unification on conflict 2026-06-21 13:36:07 +02:00
29e601128d tests: add unification test 2026-06-21 13:19:17 +02:00
b591f5508f fix(checker): make map definition generic 2026-06-21 13:17:35 +02:00
41d0c84bbe feat(checker): add unifier
add unifier class to infer type parameters from local call context
2026-06-21 13:12:27 +02:00
cccf2f8f9f Merge pull request 'Stubs generator' (#17) from feat/stubs-gen into main
Reviewed-on: #17
2026-06-20 15:44:34 +00:00
3f48c2138f chore: add stubs command to README 2026-06-20 17:44:15 +02:00
e4ab27673d fix(gen): handle TypeVar variance in stubs generator 2026-06-20 17:34:40 +02:00
b02ecc6326 fix(gen): handle ConstraintType in stubs generator 2026-06-20 17:34:22 +02:00
9e83079910 fix(cli): add missing methods to highlighter 2026-06-20 17:23:18 +02:00
ec468dd982 feat(cli): add stubs command 2026-06-20 17:10:25 +02:00
3edc25d778 feat(gen): add base for stubs generator 2026-06-20 17:10:24 +02:00
451e54b009 fix(checker): handle calls to AliasType 2026-06-20 17:10:24 +02:00
0dc14f67aa fix(checker): allow substitutyping type vars in GenericType and TopType 2026-06-20 17:10:23 +02:00
ff79f25628 fix(checker): store member kind in registry 2026-06-20 17:10:23 +02:00
12782dda1e Merge pull request 'Variance inference and subtyping' (#16) from feat/variance into main
Reviewed-on: #16
2026-06-20 14:55:01 +00:00
48a20b4aa0 tests: add tests for variance inference and subtyping 2026-06-20 16:48:19 +02:00
9467187313 feat(checker): use variance in subtype check 2026-06-20 16:30:30 +02:00
cd8f14153d feat(checker): infer type variables variance 2026-06-20 13:39:32 +02:00
6eea0c02e0 Merge pull request 'Constraint types' (#15) from feat/constraint-type into main
Reviewed-on: #15
2026-06-19 20:21:04 +00:00
3205e7b961 fix(checker): change back warning to errors 2026-06-19 22:13:10 +02:00
0aba134290 tests: add predicates and constraints test 2026-06-19 22:13:10 +02:00
1f0bcab2ca fix(checker) minor tweaks 2026-06-19 22:13:09 +02:00
db8d88ef35 feat(parser): parse strings in Midas files 2026-06-19 22:13:09 +02:00
7695d50537 fix(parser): correctly parse keyword arguments 2026-06-19 22:13:08 +02:00
8461d05fa6 fix(checker): handle all operations and calls in predicates 2026-06-19 22:13:08 +02:00
43d2118db7 fix(checker): lookup predicate variables in preamble 2026-06-19 22:13:07 +02:00
6a87b5396f feat(cli): print predicate with dump-registry 2026-06-19 22:13:07 +02:00
e6a581ba6e fix(checker): typo in docstring 2026-06-19 22:13:07 +02:00
2a7aac69ed fix(checker): change some diagnostics to warnings
temporarily change type errors in predicates to warnings until operations are fully type checked
2026-06-19 22:13:06 +02:00
eb5bf19c61 feat(gen): generate type hints for functions 2026-06-19 22:13:06 +02:00
657406ea01 feat(gen): handle predicate aliases
handle cases where a predicate is defined as an alias, i.e. without any parameters
2026-06-19 22:13:05 +02:00
2974386110 fix(parser): fix call expr location span 2026-06-19 22:13:05 +02:00
92ca6b6732 feat(types): detect constraint base subtyping 2026-06-19 22:13:04 +02:00
6aacdb98b7 feat(checker): type check predicate body 2026-06-19 22:13:04 +02:00
1b100b6ceb fix(gen): remove id from named predicate function 2026-06-19 22:13:03 +02:00
6b4c7d27bc fix(tests): update generator tester 2026-06-19 22:13:03 +02:00
2523d638f7 feat(gen): generate predicate functions 2026-06-19 22:13:02 +02:00
5fc7461e29 feat(gen): generate basic constraint assertion 2026-06-19 22:13:02 +02:00
c5154bde81 feat(types): add ConstraintType 2026-06-19 22:13:02 +02:00
d07e8ac0ca refactor: ensure exhaustiveness in some match/case 2026-06-19 22:13:01 +02:00
3380995082 tests: update with new predicate AST representation 2026-06-19 22:13:01 +02:00
7efc44c496 fix(tests): correctly serialize param name 2026-06-19 22:13:00 +02:00
ca94443699 feat(midas): generalize param spec of predicate and parse 2026-06-19 22:12:59 +02:00
c513a85cf2 feat(midas): add CallExpr 2026-06-19 22:12:59 +02:00
2a106c5d07 refactor: add param spec for FunctionType 2026-06-19 22:12:58 +02:00
9672dfd588 Merge pull request 'Update README' (#14) from fix/update-readme into main
Reviewed-on: #14
2026-06-19 13:25:09 +00:00
7639ccc94d chore: update README with new commands 2026-06-19 15:23:49 +02:00
109 changed files with 37000 additions and 4563 deletions

View File

@@ -0,0 +1,22 @@
name: Tests
on:
push:
branches:
- main
- master
pull_request:
branches:
- "**"
jobs:
tests:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python3 -m tests

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ venv
uv.lock uv.lock
.python-version .python-version
/out /out
/examples/**/build/
/examples/**/*.pyi

107
README.md
View File

@@ -1,4 +1,4 @@
# Midas <h1>Midas</h1>
*Midas* is a type system to _Maintain Integrity of Data with Annotated Structures_. In Greek mythology, [Midas](https://en.wikipedia.org/wiki/Midas) was a Phrygian king who was blessed with the gift of turning everything he touched into gold. *Midas* is a type system to _Maintain Integrity of Data with Annotated Structures_. In Greek mythology, [Midas](https://en.wikipedia.org/wiki/Midas) was a Phrygian king who was blessed with the gift of turning everything he touched into gold.
@@ -6,6 +6,25 @@
This framework is being developed as part of a Bachelor's Thesis by Louis Heredero at HEI Sion. This framework is being developed as part of a Bachelor's Thesis by Louis Heredero at HEI Sion.
<details>
<summary><strong>Table of Contents</strong></summary>
- [Requirements](#requirements)
- [Installation](#installation)
- [Commands](#commands)
- [Type Checking](#type-checking)
- [Compiling](#compiling)
- [Formatting](#formatting)
- [Highlighting](#highlighting)
- [Dumping the AST](#dumping-the-ast)
- [Dumping the Registry](#dumping-the-registry)
- [Generating Stubs](#generating-stubs)
- [Showing Type Judgements](#showing-type-judgements)
- [Validating Definitions](#validating-definitions)
- [Tests](#tests)
</details>
## Requirements ## Requirements
- Python 3.11+ - Python 3.11+
@@ -32,10 +51,17 @@ This framework is being developed as part of a Bachelor's Thesis by Louis Herede
## Commands ## Commands
### Compiling Hereafter is a description of the commands you can use with Midas. For a full description, refer to [the manual](./docs/manual.pdf) or `midas <subcommand> --help`.
> [!NOTE] ### Type Checking
> In the current state of the project, the `compile` command doesn't generate any runnable code, it only runs the parsers and type checker on the provided files
```shell
midas check -t types.midas source.py
```
This command parses the given files and run the type checkers against the Midas definitions and Python program. Diagnostics are then printed showing warnings and errors.
### Compiling
```shell ```shell
midas compile -t types.midas source.py midas compile -t types.midas source.py
@@ -43,14 +69,26 @@ midas compile -t types.midas source.py
With the `compile` command, you can process a source Python file, with any number of custom type definition files (`-t FILE` option), and the type checker will verify the coherence of your program and generate the runnable code with valid syntax and runtime assertions. With the `compile` command, you can process a source Python file, with any number of custom type definition files (`-t FILE` option), and the type checker will verify the coherence of your program and generate the runnable code with valid syntax and runtime assertions.
The optional `-l FILE` option lets you produce a highlighted version of the source code showing diagnostics from the type checker (see [Highlighting](#highlighting)) > [!WARNING]
> By default, any type checking error aborts the compilation and the generator is not run. You can bypass this behaviour with the `--ignore-errors` flag.
> Only use this flag if you know what you are doing as it will produce a possibly unsafe program and goes against the whole purpose of Midas
### Formatting
```shell
midas format types.midas
midas format types.midas -o formatted.midas
```
This command parses the given Midas file and outputs a pretty printed file from the AST.
### Highlighting ### Highlighting
```shell ```shell
midas utils highlight source.py midas highlight source.py
# or midas highlight source.py -o highlighted.html
midas utils highlight types.midas midas highlight types.midas
midas highlight types.midas -o highlighted.html
``` ```
The `highlight` command takes in a source file (Python or Midas), runs the appropriate parser and outputs an HTML file containing the source code with added highlighting. This highlighting takes the form of hoverable annotations showing some of the parsed structures (e.g. a function definition, an assignment, a generic type, etc.) The `highlight` command takes in a source file (Python or Midas), runs the appropriate parser and outputs an HTML file containing the source code with added highlighting. This highlighting takes the form of hoverable annotations showing some of the parsed structures (e.g. a function definition, an assignment, a generic type, etc.)
@@ -60,26 +98,65 @@ The optional `-o FILE` option can be used to specify an output path. By default,
### Dumping the AST ### Dumping the AST
```shell ```shell
midas utils dump-ast source.py midas parse source.py
# or midas parse types.midas
midas utils dump-ast types.midas
``` ```
For debugging purposes, you can output the AST parsed from a Python or Midas file. For Python files, the `-p` flags lets you toggle the custom AST parsing. Without `-p`, the raw AST is returned, as produced by the builtin `ast` module. This flag has no effect on Midas files. For debugging purposes, you can output the AST parsed from a Python or Midas file. For Python files, the `--raw` flags lets you toggle the custom AST parsing. With `--raw`, the raw AST is returned, as produced by the builtin `ast` module. This flag has no effect on Midas files.
The optional `-o FILE` option can be used to specify an output path. By default, the file is printed in stdout (equivalent to `-o -`). ### Dumping the Registry
```shell
midas dump-registry -t types.midas
```
This command processes the given Midas definitions and dumps the contents of the types registry.
### Generating Stubs
```shell
midas stubs types.midas -o stubs.pyi
```
This command generates Python stubs from a Midas definition file
### Showing Type Judgements
```shell
midas types -t types.midas source.py
```
This command type checks the given Python source file and logs all typing judgements made by the type checker.
### Validating Definitions
```shell
midas validate types.midas
```
This command lets you validate a Midas definition file by running the parser and type checker, verifying syntax and references.
## Tests ## Tests
Several snapshot tests are available to assert the good behaviour of the parsers and type checker. They can be run as follows: Several snapshot tests are available to assert the good behaviour of the parser, type checker and generator. They can be run as follows:
```shell ```shell
uv run -m tests.midas run -a uv run -m tests.midas run -a
uv run -m tests.python run -a uv run -m tests.python run -a
uv run -m tests.checker run -a uv run -m tests.checker run -a
uv run -m tests.generator run -a
``` ```
**Available subcommands:** Alternatively, you can run all tests by executing the `tests` module directly:
```shell
uv run -m tests
```
When running only one test group, you may use one of the following subcommands.\
Not specifying any subcommand is equivalent to running `run -a`
**Available subcommands**:
- Run all tests: `run -a` - Run all tests: `run -a`
- Run specific tests: `run tests/cases/test1.py tests/cases/test2.py ...` - Run specific tests: `run tests/cases/test1.py tests/cases/test2.py ...`
- Update all tests: `update -a` - Update all tests: `update -a`

117
assets/icon.svg Normal file
View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:export-filename="logo.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.4.4 (1:1.4.4+202605061436+dcaf3e7d9e)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="1.9332778"
inkscape:cx="-8.2760999"
inkscape:cy="112.2446"
inkscape:window-width="2584"
inkscape:window-height="1028"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="4"
spacingy="4"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="4"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4689"
id="linearGradient1478"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.562541,0,0,0.567972,-9.399749,-5.305317)"
x1="26.648937"
y1="20.603781"
x2="135.66525"
y2="114.39767" />
<linearGradient
id="linearGradient4689">
<stop
style="stop-color:#e1be1e;stop-opacity:1;"
offset="0"
id="stop4691" />
<stop
style="stop-color:#ffeb82;stop-opacity:1;"
offset="1"
id="stop4693" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4671"
id="linearGradient1475"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.562541,0,0,0.567972,-9.399749,-5.305317)"
x1="150.96111"
y1="192.35176"
x2="112.03144"
y2="137.27299" />
<linearGradient
id="linearGradient4671">
<stop
style="stop-color:#ffdc21;stop-opacity:1;"
offset="0"
id="stop4673" />
<stop
style="stop-color:#ffeb82;stop-opacity:1;"
offset="1"
id="stop4675" />
</linearGradient>
</defs>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="translate(2.911719,3.414527)">
<path
style="fill:url(#linearGradient1478);fill-opacity:1"
d="m 60.510156,6.3979729 c -4.583653,0.021298 -8.960939,0.4122177 -12.8125,1.09375 C 36.35144,9.4962267 34.291407,13.691825 34.291406,21.429223 v 10.21875 h 26.8125 v 3.40625 h -26.8125 -10.0625 c -7.792459,0 -14.6157592,4.683717 -16.7500002,13.59375 -2.46182,10.212966 -2.5710151,16.586023 0,27.25 1.9059283,7.937852 6.4575432,13.593748 14.2500002,13.59375 h 9.21875 v -12.25 c 0,-8.849902 7.657144,-16.656248 16.75,-16.65625 h 26.78125 c 7.454951,0 13.406253,-6.138164 13.40625,-13.625 v -25.53125 c 0,-7.266339 -6.12998,-12.7247775 -13.40625,-13.9375001 -4.605987,-0.7667253 -9.385097,-1.1150483 -13.96875,-1.09375 z m -14.5,8.2187501 c 2.769547,0 5.03125,2.298646 5.03125,5.125 -2e-6,2.816336 -2.261703,5.09375 -5.03125,5.09375 -2.779476,-1e-6 -5.03125,-2.277415 -5.03125,-5.09375 -1e-6,-2.826353 2.251774,-5.125 5.03125,-5.125 z"
id="path1948" />
<path
style="fill:url(#linearGradient1475);fill-opacity:1"
d="m 91.228906,35.054223 v 11.90625 c 0,9.230755 -7.825895,16.999999 -16.75,17 h -26.78125 c -7.335833,0 -13.406249,6.278483 -13.40625,13.625 v 25.531247 c 0,7.26634 6.318588,11.54032 13.40625,13.625 8.487331,2.49561 16.626237,2.94663 26.78125,0 6.750155,-1.95439 13.406253,-5.88761 13.40625,-13.625 V 92.897973 h -26.78125 v -3.40625 h 26.78125 13.406254 c 7.79246,0 10.69625,-5.435408 13.40624,-13.59375 2.79933,-8.398886 2.68022,-16.475776 0,-27.25 -1.92578,-7.757441 -5.60387,-13.59375 -13.40624,-13.59375 z m -15.0625,64.65625 c 2.779478,3e-6 5.03125,2.277417 5.03125,5.093747 -2e-6,2.82635 -2.251775,5.125 -5.03125,5.125 -2.76955,0 -5.03125,-2.29865 -5.03125,-5.125 2e-6,-2.81633 2.261697,-5.093747 5.03125,-5.093747 z"
id="path1950" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

2
docs/fonts.yaml Normal file
View File

@@ -0,0 +1,2 @@
Source Sans Pro:
Source Sans 3:

12698
docs/manual.pdf Normal file

File diff suppressed because it is too large Load Diff

1015
docs/manual.typ Normal file

File diff suppressed because it is too large Load Diff

211
docs/midas.sublime-syntax Normal file
View File

@@ -0,0 +1,211 @@
%YAML 1.2
---
name: Midas
file_extensions:
- midas
scope: source.midas
variables:
identifier: "[a-zA-Z_][a-zA-Z0-9_]*"
contexts:
prototype:
- include: comments
main:
- include: keywords
- include: types
comments:
- match: "//"
scope: punctuation.definition.comment.midas
push:
- meta_scope: comment.line.midas
- match: $
pop: true
- match: /\*
scope: punctuation.definition.comment.midas
push:
- meta_scope: comment.block.midas
- match: \*/
pop: true
string:
- meta_include_prototype: false
- meta_scope: string.quoted.double.c
- match: '"'
pop: true
keywords:
- match: \balias\b
scope: keyword.declaration.midas
push: alias-stmt
- match: \btype\b
scope: keyword.declaration.midas
push: type-stmt
- match: \bextend\b
scope: keyword.declaration.midas
push: extend-stmt
- match: \bpredicate\b
scope: keyword.declaration.midas
push: predicate-stmt
alias-stmt:
- match: "{{identifier}}"
scope: entity.name.type
- match: "="
scope: keyword.operator.equal.midas
push: type-expr
- match: $
pop: true
type-stmt:
- match: "{{identifier}}"
scope: entity.name.type
- match: \[
push: type-params
- match: "="
scope: keyword.operator.equal.midas
push: type-expr
- match: $
pop: true
type-expr:
- match: \b(fn)\s*(\()
captures:
1: keyword.other.midas
2: punctuation.section.group.begin
push: fn-params
- match: \b(where)\b
scope: keyword.other.midas
set: constraint
- match: "Frame"
scope: entity.name.type
push:
- match: \[
push: frame-schema
- match: $
pop: true
- match: "{{identifier}}"
scope: entity.name.type
- match: $
pop: 2
fn-params:
- match: "({{identifier}})(:)"
captures:
1: variable.parameter.midas
2: punctuation.separator.annotation.midas
push:
- include: type-expr
- match: \?
scope: keyword.operator.qmark.midas
- match: "(?=,)"
scope: punctuation.separator.midas
pop: true
- match: '(?=\))'
pop: true
- include: type-expr
- match: '\)'
set:
- match: "->"
scope: keyword.operator.arrow.midas
set: type-expr
constraint:
- match: $
pop: 2
- match: \d+(\.\d+)?
scope: constant.numeric.midas
- match: \b(true|false|none)\b
scope: constant.language.midas
- match: '"'
push: string
- match: (<=|>=|<|>|==|!=|&)
scope: keyword.operator
- match: _
scope: variable.language.midas
- match: '{{identifier}}(?=\s*\()'
scope: variable.function.midas
- match: "{{identifier}}"
scope: variable.other.readwrite.midas
type-params:
- match: "<:"
scope: keyword.operator.subtype.midas
- match: "[a-zA-Z][a-zA-Z_0-9]*"
scope: entity.name.type
- match: "]"
pop: true
extend-stmt:
- match: "{{identifier}}"
scope: entity.name.type
- match: \[
push: type-params
- match: \{
scope: punctuation.section.block.begin
set: extend-body
extend-body:
- include: member-stmt
- match: \}
scope: punctuation.section.block.end
pop: true
member-stmt:
- match: \b(prop|def)\b
scope: keyword.other.midas
push:
- match: "{{identifier}}"
scope: variable.other.member
- match: ":"
push: type-expr
- match: $
pop: true
predicate-stmt:
- match: "{{identifier}}"
scope: entity.name.function.midas
- match: '\('
push: predicate-params
- match: "="
scope: keyword.operator.equal.midas
set: constraint
- match: $
pop: true
predicate-params:
- match: "({{identifier}})(:)"
captures:
1: variable.parameter.midas
2: punctuation.separator.annotation.midas
push:
- include: type-expr
- match: "(?=,)"
scope: punctuation.separator.midas
pop: true
- match: '(?=\))'
pop: true
- match: '\)'
pop: true
frame-schema:
- include: frame-column
- match: \]
# scope: punctuation.section.block.end
pop: true
frame-column:
- match: "{{identifier}}"
scope: variable.other.member
- match: ":"
push: type-expr

6
docs/requirements.json Normal file
View File

@@ -0,0 +1,6 @@
{
"preview": {},
"local": {
"codly": "https://github.com/LordBaryhobal/codly-fix-v15/archive/refs/tags/v1.3.1-fix.zip"
}
}

147
docs/template.typ Normal file
View File

@@ -0,0 +1,147 @@
#import "@preview/modpattern:0.2.0": modpattern
#let TODO = block(
width: 6em,
height: 3em,
stroke: red,
fill: modpattern(
size: (10pt, 10pt),
line(
start: (0%, 0%),
end: (100%, 100%),
stroke: gray.transparentize(60%) + 2pt,
),
),
align(
center + horizon,
text(fill: red, size: 1.5em)[*TODO*],
),
)
#let _render-header(version, hash) = {
let last-heading = query(heading.where(level: 1).before(here())).last(default: none)
let next-heading = query(heading.where(level: 1).after(here())).first(default: none)
let current-heading = if next-heading != none and next-heading.location().page() == here().page() {
next-heading
} else if last-heading != none {
last-heading
} else { none }
let chapter = if current-heading != none {
let body = current-heading.body
if current-heading.numbering != none {
let num = counter(heading).display(current-heading.numbering, at: current-heading.location())
body = [#num #body]
}
body
} else []
grid(
columns: (1fr, auto, 1fr),
align: (left, center, right),
document.title, [v#version - #hash], chapter,
)
}
#let _unshift-prefix(prefix, content) = context {
pad(left: -measure(prefix).width, prefix + content)
}
#let project(
title: none,
author: none,
version: "0.0.1",
hash: none,
icon-path: none,
doc,
) = {
assert(title != none, message: "Please provide a title")
set document(
title: title,
author: author,
)
set text(
font: "Source Sans 3",
)
set raw(syntaxes: path("midas.sublime-syntax"))
let front-page() = {
let version-name = [v#version]
if hash != none {
version-name = [#version-name - #hash]
}
align(center)[
#{
set text(size: 1.5em)
std.title()
}
#version-name
#if icon-path != none {
v(1cm)
image(icon-path)
}
]
pagebreak()
}
let outlines() = {
outline()
pagebreak()
outline(
title: [List of Listings],
target: figure.where(kind: raw),
)
outline(
title: [List of Tables],
target: figure.where(kind: table),
)
}
let main() = {
// Adapted from https://github.com/hei-templates/hei-synd-thesis/blob/7d2b941197babae0bf3afd4e5914754e09a64001/lib/template-thesis.typ#L242-L261
show heading.where(level: 1): it => {
pagebreak()
set text(size: 1.5em)
set block(above: 1.2em, below: 1.2em)
if it.numbering != none {
let num = numbering(it.numbering, ..counter(heading).at(it.location()))
let prefix = num + h(1em)
_unshift-prefix(prefix, it.body)
} else {
it
}
}
show heading.where(level: 2): it => {
if it.numbering != none {
let num = numbering(it.numbering, ..counter(heading).at(it.location()))
_unshift-prefix(num + h(0.8em), it.body)
} else {
it
}
}
set page(
header: context _render-header(version, hash),
footer: context if page.numbering != none {
align(center, counter(page).display(page.numbering, both: true))
},
numbering: "1 / 1",
)
show heading: set heading(numbering: "I.1.")
counter(page).update(1)
doc
}
front-page()
outlines()
main()
}

View File

@@ -0,0 +1,15 @@
predicate in_range(min: float, max: float)(v: float) = min <= v & v <= max
predicate is_ratio = in_range(0, 1)
type Currency = float
type Price[T <: Currency] = T where _ >= 0
extend Price[T <: Currency] {
def __add__: fn(Price[T], /) -> Price[T]
}
type EUR = Currency
type USD = Currency
type CHF = Currency
type Discount = float where is_ratio(_)

View File

@@ -0,0 +1,35 @@
from typing import TypeVar
from demo_stubs import CHF, EUR, USD, Currency, Discount, Price
from midas.typing import cast, unsafe_cast
T = TypeVar("T", bound=Currency)
def apply_discount(amount: Price[T], discount: Discount) -> Price[T]:
return cast(Price[T], (1.0 - discount) * amount)
a1 = cast(Price[EUR], 3.2)
a2 = cast(Price[USD], 10.4)
r1 = cast(Discount, 0.2)
print(apply_discount(a1, r1))
print(apply_discount(a2, r1))
a3 = a1 + a1
a4 = a1 + a2 # cannot add euros and dollars
a3 = a2 # cannot change variable type
dyn_price = float(input("Price (CHF): "))
dyn_discount = float(input("Discount (0.0-1.0): "))
discounted = apply_discount(
cast(Price[CHF], dyn_price),
cast(Discount, dyn_discount),
)
print(f"Discounted: CHF {discounted}")
large_data = [i * 10 for i in range(100)]
prices = unsafe_cast(list[Price[EUR]], large_data)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Generic, TypeVar
class Currency(float): ...
_T0 = TypeVar("_T0", bound=Currency, covariant=True)
class Price(Currency, Generic[_T0]):
def __add__(self, _0: Price[_T0], /) -> Price[_T0]: ...
class EUR(Currency): ...
class USD(Currency): ...
class CHF(Currency): ...
class Discount(float): ...

View File

@@ -0,0 +1,65 @@
predicate in_range(min: float, max: float)(v: float) = min <= v & v <= max
predicate is_percentage = in_range(0.0, 100.0)
type Celsius = float
type Kelvin = float where _ >= 0
type Hectopascal = float
type Temperature = Celsius where in_range(-30.0, 100.0)(_)
type Pressure = Hectopascal where in_range(800.0, 1100.0)(_)
type Humidity = float where is_percentage(_)
type HeatIndex = float
type StationID = str where len(_) == 3 & _.isupper()
type Mean[T <: float] = float
extend Celsius {
def __add__: fn(Celsius, /) -> Celsius
def __sub__: fn(Celsius, /) -> Celsius
}
extend Kelvin {
def __add__: fn(Kelvin, /) -> Kelvin
}
extend Hectopascal {
def __add__: fn(Hectopascal, /) -> Hectopascal
def __sub__: fn(Hectopascal, /) -> Hectopascal
}
alias RawData = Frame[
station_id: str,
timestamp: str,
temperature: float,
pressure: float,
humidity: float,
]
alias Data = Frame[
station_id: StationID,
timestamp: Any,
temperature: Temperature,
pressure: Pressure,
humidity: Humidity,
]
alias DataWithHI = Frame[
station_id: StationID,
timestamp: Any,
temperature: Temperature,
pressure: Pressure,
humidity: Humidity,
heat_index: HeatIndex,
]
alias DailyAverages = Frame[
timestamp: Any,
temperature: Mean[Temperature],
pressure: Mean[Pressure],
humidity: Mean[Humidity],
heat_index: Mean[HeatIndex],
]
// predicate limit_amplitude(max_amp: float)(ls: list[float]) = max(ls) - min(ls) <= max_amp
// type LowAmplitudeWave = list[float where _ >= 1] where limit_amplitude(10)(_)

View File

@@ -0,0 +1,43 @@
import datetime
import random
import pandas as pd
stations = ["SIO", "AIG", "ZER"]
start_ts = datetime.datetime(2026, 1, 1)
end_ts = datetime.datetime(2027, 1, 1)
delta = end_ts - start_ts
min_temp, max_temp = -30.0, 100.0
min_pres, max_pres = 800.0, 1100.0
min_hum, max_hum = 0.0, 100.0
N = 3000
rows: list[tuple[str, datetime.datetime, float, float, float]] = []
for _ in range(N):
ts = random.random() * delta + start_ts
rows.append(
(
random.choice(stations),
ts,
random.random() * (max_temp - min_temp) + min_temp,
random.random() * (max_pres - min_pres) + min_pres,
random.random() * (max_hum - min_hum) + min_hum,
)
)
df = pd.DataFrame(
rows,
columns=[
"station_id",
"timestamp",
"temperature",
"pressure",
"humidity",
],
)
df = df.sort_values(by=["timestamp", "station_id"])
df.to_csv("data.csv", index=False)

View File

@@ -0,0 +1,81 @@
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
from custom_types import DailyAverages, Data, DataWithHI, HeatIndex, RawData
from midas.typing import Column, cast, unsafe_cast
def load_data(path: Path) -> RawData:
# Check base types and dataframe structure
return cast(RawData, pd.read_csv(path))
def convert_data(raw_df: RawData) -> Data:
new_df = raw_df.copy()
new_df["timestamp"] = cast(
Column[object],
pd.to_datetime(new_df["timestamp"]),
)
# Check types and constraints at runtime, catches out-of-range values and
# invalid types / malformed data
return cast(Data, new_df)
def compute_heat_index(df: Data):
# The computation's result can only be typed as `Column[float]`
# Casting is necessary to bring back semantic
df["heat_index"] = cast(
Column[HeatIndex],
(
df["temperature"] * 2.0
+ df["humidity"] * 10.0
- df["temperature"] * df["humidity"] * 0.2
),
)
return df
def daily_avg(df: DataWithHI):
# Group-by and aggregation methods keep the structure of the dataframe but
# may erase the exact types
# The type checker is still very conservative and often the result of most
# aggregation methods as `Column[Any]`
return cast(
DailyAverages,
df.groupby(
by=[
"station_id",
df["timestamp"].dt.day.rename("day"),
],
)
.mean()
.sort_values(by="timestamp"),
)
def plot(df: DailyAverages):
# Some operations are not implemented in Midas but the user can still use
# them, they will just not be fully type-checked
# `unsafe_cast` can also be used to avoid trivial, redundant or costly checks
stations = unsafe_cast(list[str], list(df.index.get_level_values(0).unique()))
for station in stations:
sub_df = unsafe_cast(DailyAverages, df.loc[station])
plt.plot(sub_df["timestamp"], sub_df["heat_index"])
plt.show()
def main():
# Assigning to annotated variables help catch errors
raw_df: RawData = load_data(Path("data.csv"))
df: Data = convert_data(raw_df)
with_hi = compute_heat_index(df)
dailies = daily_avg(with_hi)
plot(dailies)
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,9 @@
"""
Helper script to generate AST nodes for Midas and Python.
Takes in simple templates and generates full dataclasses and a visitor interface
"""
import re import re
from pathlib import Path from pathlib import Path

View File

@@ -26,6 +26,14 @@ class MemberKind(Enum):
METHOD = auto() METHOD = auto()
@dataclass(frozen=True, kw_only=True)
class ParamSpec:
l_paren: Token
pos: list[FunctionType.Parameter]
mixed: list[FunctionType.Parameter]
kw: list[FunctionType.Parameter]
###< ###<
@@ -36,6 +44,11 @@ class TypeStmt:
type: Type type: Type
class AliasStmt:
name: Token
type: Type
class MemberStmt: class MemberStmt:
name: Token name: Token
type: Type type: Type
@@ -50,9 +63,8 @@ class ExtendStmt:
class PredicateStmt: class PredicateStmt:
name: Token name: Token
subject: Token params: list[ParamSpec]
type: Type body: Expr
condition: Expr
###< ###<
@@ -78,6 +90,12 @@ class UnaryExpr:
right: Expr right: Expr
class CallExpr:
callee: Expr
arguments: list[Expr]
keywords: dict[str, Expr]
class GetExpr: class GetExpr:
expr: Expr expr: Expr
name: Token name: Token
@@ -118,27 +136,26 @@ class ConstraintType:
constraint: Expr constraint: Expr
class ComplexType:
members: list[MemberStmt]
class ExtensionType:
base: Type
extension: ComplexType
class FunctionType: class FunctionType:
pos_args: list[Argument] params: ParamSpec
args: list[Argument]
kw_args: list[Argument]
returns: Type returns: Type
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Argument: class Parameter:
location: Optional[Location] = None location: Optional[Location] = None
name: Optional[Token] name: Optional[Token]
type: Type type: Type
required: bool required: bool
class FrameType:
columns: list[Column]
@dataclass(frozen=True, kw_only=True)
class Column:
location: Optional[Location] = None
name: Token
type: Type
###< ###<

View File

@@ -12,10 +12,36 @@ from midas.ast.location import Location
###< ###<
###> Preamble
@dataclass(frozen=True, kw_only=True)
class ParamSpec:
pos: list[Function.Parameter]
mixed: list[Function.Parameter]
kw: list[Function.Parameter]
@property
def all(self) -> list[Function.Parameter]:
return self.pos + self.mixed + self.kw
@dataclass(frozen=True, kw_only=True)
class ImportAlias:
location: Location
name: str
alias: Optional[str] = None
@property
def imported_name(self) -> str:
return self.alias if self.alias is not None else self.name
###<
###> MidasType | Type annotations | node ###> MidasType | Type annotations | node
class BaseType: class BaseType:
base: str base: str
param: Optional[MidasType] args: tuple[MidasType, ...]
class ConstraintType: class ConstraintType:
@@ -42,25 +68,17 @@ class ExpressionStmt:
class Function: class Function:
name: str name: str
posonlyargs: list[Argument] params: ParamSpec
args: list[Argument]
sink: Optional[Argument]
kwonlyargs: list[Argument]
kw_sink: Optional[Argument]
returns: Optional[MidasType] returns: Optional[MidasType]
body: list[Stmt] body: list[Stmt]
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Argument: class Parameter:
location: Optional[Location] = None location: Optional[Location] = None
name: str name: str
type: Optional[MidasType] type: Optional[MidasType]
default: Optional[Expr] default: Optional[Expr]
@property
def all_args(self) -> list[Argument]:
return self.posonlyargs + self.args + self.kwonlyargs
class TypeAssign: class TypeAssign:
name: str name: str
@@ -92,6 +110,16 @@ class ForStmt:
body: list[Stmt] body: list[Stmt]
class ImportStmt:
imports: list[ImportAlias]
class FromImportStmt:
module: Optional[str]
imports: list[ImportAlias]
level: int
class RawStmt: class RawStmt:
stmt: ast.stmt stmt: ast.stmt
@@ -145,6 +173,7 @@ class LogicalExpr:
class CastExpr: class CastExpr:
type: MidasType type: MidasType
expr: Expr expr: Expr
unsafe: bool
class TernaryExpr: class TernaryExpr:
@@ -173,6 +202,10 @@ class SliceExpr:
step: Optional[Expr] step: Optional[Expr]
class TupleExpr:
items: tuple[Expr, ...]
class RawExpr: class RawExpr:
expr: ast.expr expr: ast.expr

View File

@@ -13,6 +13,8 @@ class HasLocation(Protocol):
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Location: class Location:
"""Information about the location of an AST node"""
lineno: int lineno: int
col_offset: int col_offset: int
end_lineno: Optional[int] end_lineno: Optional[int]
@@ -29,6 +31,16 @@ class Location:
@staticmethod @staticmethod
def span(start: Location, end: Location) -> Location: def span(start: Location, end: Location) -> Location:
"""Create a new location spanning from one location to another
Args:
start (Location): the starting location
end (Location): the end location
Returns:
Location: a new location spanning from the start of `start`
to the end of `end`
"""
return Location( return Location(
lineno=start.lineno, lineno=start.lineno,
col_offset=start.col_offset, col_offset=start.col_offset,

View File

@@ -27,6 +27,14 @@ class MemberKind(Enum):
METHOD = auto() METHOD = auto()
@dataclass(frozen=True, kw_only=True)
class ParamSpec:
l_paren: Token
pos: list[FunctionType.Parameter]
mixed: list[FunctionType.Parameter]
kw: list[FunctionType.Parameter]
############## ##############
# Statements # # Statements #
############## ##############
@@ -43,6 +51,9 @@ class Stmt(ABC):
@abstractmethod @abstractmethod
def visit_type_stmt(self, stmt: TypeStmt) -> T: ... def visit_type_stmt(self, stmt: TypeStmt) -> T: ...
@abstractmethod
def visit_alias_stmt(self, stmt: AliasStmt) -> T: ...
@abstractmethod @abstractmethod
def visit_member_stmt(self, stmt: MemberStmt) -> T: ... def visit_member_stmt(self, stmt: MemberStmt) -> T: ...
@@ -63,6 +74,15 @@ class TypeStmt(Stmt):
return visitor.visit_type_stmt(self) return visitor.visit_type_stmt(self)
@dataclass(frozen=True)
class AliasStmt(Stmt):
name: Token
type: Type
def accept(self, visitor: Stmt.Visitor[T]) -> T:
return visitor.visit_alias_stmt(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class MemberStmt(Stmt): class MemberStmt(Stmt):
name: Token name: Token
@@ -86,9 +106,8 @@ class ExtendStmt(Stmt):
@dataclass(frozen=True) @dataclass(frozen=True)
class PredicateStmt(Stmt): class PredicateStmt(Stmt):
name: Token name: Token
subject: Token params: list[ParamSpec]
type: Type body: Expr
condition: Expr
def accept(self, visitor: Stmt.Visitor[T]) -> T: def accept(self, visitor: Stmt.Visitor[T]) -> T:
return visitor.visit_predicate_stmt(self) return visitor.visit_predicate_stmt(self)
@@ -116,6 +135,9 @@ class Expr(ABC):
@abstractmethod @abstractmethod
def visit_unary_expr(self, expr: UnaryExpr) -> T: ... def visit_unary_expr(self, expr: UnaryExpr) -> T: ...
@abstractmethod
def visit_call_expr(self, expr: CallExpr) -> T: ...
@abstractmethod @abstractmethod
def visit_get_expr(self, expr: GetExpr) -> T: ... def visit_get_expr(self, expr: GetExpr) -> T: ...
@@ -161,6 +183,16 @@ class UnaryExpr(Expr):
return visitor.visit_unary_expr(self) return visitor.visit_unary_expr(self)
@dataclass(frozen=True)
class CallExpr(Expr):
callee: Expr
arguments: list[Expr]
keywords: dict[str, Expr]
def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_call_expr(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class GetExpr(Expr): class GetExpr(Expr):
expr: Expr expr: Expr
@@ -224,15 +256,12 @@ class Type(ABC):
@abstractmethod @abstractmethod
def visit_constraint_type(self, type: ConstraintType) -> T: ... def visit_constraint_type(self, type: ConstraintType) -> T: ...
@abstractmethod
def visit_complex_type(self, type: ComplexType) -> T: ...
@abstractmethod
def visit_extension_type(self, type: ExtensionType) -> T: ...
@abstractmethod @abstractmethod
def visit_function_type(self, type: FunctionType) -> T: ... def visit_function_type(self, type: FunctionType) -> T: ...
@abstractmethod
def visit_frame_type(self, type: FrameType) -> T: ...
@dataclass(frozen=True) @dataclass(frozen=True)
class NamedType(Type): class NamedType(Type):
@@ -260,32 +289,13 @@ class ConstraintType(Type):
return visitor.visit_constraint_type(self) return visitor.visit_constraint_type(self)
@dataclass(frozen=True)
class ComplexType(Type):
members: list[MemberStmt]
def accept(self, visitor: Type.Visitor[T]) -> T:
return visitor.visit_complex_type(self)
@dataclass(frozen=True)
class ExtensionType(Type):
base: Type
extension: ComplexType
def accept(self, visitor: Type.Visitor[T]) -> T:
return visitor.visit_extension_type(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class FunctionType(Type): class FunctionType(Type):
pos_args: list[Argument] params: ParamSpec
args: list[Argument]
kw_args: list[Argument]
returns: Type returns: Type
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Argument: class Parameter:
location: Optional[Location] = None location: Optional[Location] = None
name: Optional[Token] name: Optional[Token]
type: Type type: Type
@@ -293,3 +303,17 @@ class FunctionType(Type):
def accept(self, visitor: Type.Visitor[T]) -> T: def accept(self, visitor: Type.Visitor[T]) -> T:
return visitor.visit_function_type(self) return visitor.visit_function_type(self)
@dataclass(frozen=True)
class FrameType(Type):
columns: list[Column]
@dataclass(frozen=True, kw_only=True)
class Column:
location: Optional[Location] = None
name: Token
type: Type
def accept(self, visitor: Type.Visitor[T]) -> T:
return visitor.visit_frame_type(self)

View File

@@ -1,789 +0,0 @@
from __future__ import annotations
import ast
import io
from contextlib import contextmanager
from enum import Enum, auto
from typing import Generator, Generic, Optional, Protocol, TypeVar
import midas.ast.midas as m
import midas.ast.python as p
class _Level(Enum):
EMPTY = auto()
ACTIVE = auto()
LAST = auto()
class Expr(Protocol):
def accept(self, printer: AstPrinter) -> None: ...
T = TypeVar("T", bound=Expr)
class AstPrinter(Generic[T]):
LAST_CHILD = "└── "
CHILD = "├── "
VERTICAL = ""
EMPTY = " "
def __init__(self):
self._levels: list[_Level] = []
self._idx: Optional[int] = None
self._buf: io.StringIO = io.StringIO()
def print(self, expr: T):
self._buf = io.StringIO()
expr.accept(self)
return self._buf.getvalue()
@contextmanager
def _child_level(self, single: bool = False) -> Generator[None, None, None]:
self._levels.append(_Level.LAST if single else _Level.ACTIVE)
try:
yield
finally:
self._levels.pop()
def _mark_last(self):
if self._levels:
self._levels[-1] = _Level.LAST
def _write_line(self, text: str, *, last: bool = False):
if last:
self._mark_last()
indent: str = self._build_indent()
if self._idx is not None:
text = f"[{self._idx}] {text}"
self._idx = None
self._buf.write(indent + text + "\n")
def _build_indent(self) -> str:
parts: list[str] = []
for level in self._levels[:-1]:
parts.append(self.EMPTY if level == _Level.EMPTY else self.VERTICAL)
if self._levels:
if self._levels[-1] == _Level.LAST:
parts.append(self.LAST_CHILD)
self._levels[-1] = _Level.EMPTY
else:
parts.append(self.CHILD)
return "".join(parts)
def _write_optional_child(
self, label: str, child: Optional[T], *, last: bool = False
):
if last:
self._mark_last()
if child is None:
self._write_line(f"{label}: None")
else:
self._write_line(label)
with self._child_level(single=True):
child.accept(self)
class MidasAstPrinter(
AstPrinter, m.Expr.Visitor[None], m.Stmt.Visitor[None], m.Type.Visitor[None]
):
# Statements
def visit_type_stmt(self, stmt: m.TypeStmt) -> None:
self._write_line("TypeStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line("params")
with self._child_level():
for i, param in enumerate(stmt.params):
self._idx = i
if i == len(stmt.params) - 1:
self._mark_last()
self._print_type_param(param)
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def _print_type_param(self, param: m.TypeParam) -> None:
self._write_line("Param")
with self._child_level():
self._write_line(f'name: "{param.name.lexeme}"')
self._write_optional_child("bound", param.bound, last=True)
def visit_member_stmt(self, stmt: m.MemberStmt):
self._write_line("MemberStmt")
with self._child_level():
self._write_line(f"kind: {stmt.kind.name}")
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def visit_extend_stmt(self, stmt: m.ExtendStmt) -> None:
self._write_line("ExtendStmt")
with self._child_level():
self._write_line("params")
with self._child_level():
for i, param in enumerate(stmt.params):
self._idx = i
if i == len(stmt.params) - 1:
self._mark_last()
self._print_type_param(param)
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line("params")
with self._child_level():
for i, param in enumerate(stmt.params):
self._idx = i
if i == len(stmt.params) - 1:
self._mark_last()
self._print_type_param(param)
self._write_line("members", last=True)
with self._child_level():
for i, member in enumerate(stmt.members):
self._idx = i
if i == len(stmt.members) - 1:
self._mark_last()
member.accept(self)
def visit_predicate_stmt(self, stmt: m.PredicateStmt):
self._write_line("PredicateStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line(f'subject: "{stmt.subject.lexeme}"')
self._write_line("type")
with self._child_level(single=True):
stmt.type.accept(self)
self._write_line("condition", last=True)
with self._child_level(single=True):
stmt.condition.accept(self)
# Expressions
def visit_logical_expr(self, expr: m.LogicalExpr):
self._write_line("LogicalExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_binary_expr(self, expr: m.BinaryExpr):
self._write_line("BinaryExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_unary_expr(self, expr: m.UnaryExpr):
self._write_line("UnaryExpr")
with self._child_level():
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_get_expr(self, expr: m.GetExpr):
self._write_line("GetExpr")
with self._child_level():
self._write_line("expr")
with self._child_level(single=True):
expr.expr.accept(self)
self._write_line(f'name: "{expr.name.lexeme}"', last=True)
def visit_variable_expr(self, expr: m.VariableExpr):
self._write_line("VariableExpr")
with self._child_level():
self._write_line(f'name: "{expr.name.lexeme}"', last=True)
def visit_grouping_expr(self, expr: m.GroupingExpr):
self._write_line("GroupingExpr")
with self._child_level():
self._write_line("expr", last=True)
with self._child_level(single=True):
expr.expr.accept(self)
def visit_literal_expr(self, expr: m.LiteralExpr) -> None:
self._write_line("LiteralExpr")
with self._child_level():
self._write_line(f"value: {expr.value}", last=True)
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> None:
self._write_line("WildcardExpr")
def visit_named_type(self, type: m.NamedType) -> None:
self._write_line("NamedType")
with self._child_level():
self._write_line(f'name: "{type.name.lexeme}"', last=True)
def visit_generic_type(self, type: m.GenericType) -> None:
self._write_line("GenericType")
with self._child_level():
self._write_line("type")
with self._child_level():
type.type.accept(self)
self._write_line("args", last=True)
with self._child_level():
for i, param in enumerate(type.args):
self._idx = i
if i == len(type.args) - 1:
self._mark_last()
param.accept(self)
def visit_constraint_type(self, type: m.ConstraintType) -> None:
self._write_line("ConstraintType")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
type.type.accept(self)
self._write_line("constraint", last=True)
with self._child_level(single=True):
type.constraint.accept(self)
def visit_complex_type(self, type: m.ComplexType) -> None:
self._write_line("ComplexType")
with self._child_level():
self._write_line("members", last=True)
with self._child_level():
for i, member in enumerate(type.members):
self._idx = i
if i == len(type.members) - 1:
self._mark_last()
member.accept(self)
def visit_extension_type(self, type: m.ExtensionType) -> None:
self._write_line("ExtensionType")
with self._child_level():
self._write_line("base")
with self._child_level(single=True):
type.base.accept(self)
self._write_line("extension", last=True)
with self._child_level(single=True):
type.extension.accept(self)
def visit_function_type(self, type: m.FunctionType) -> None:
self._write_line("FunctionType")
with self._child_level():
self._write_line("pos_args")
with self._child_level():
for i, arg in enumerate(type.pos_args):
self._idx = i
if i == len(type.pos_args) - 1:
self._mark_last()
self._print_function_arg(arg)
self._write_line("args")
with self._child_level():
for i, arg in enumerate(type.args):
self._idx = i
if i == len(type.args) - 1:
self._mark_last()
self._print_function_arg(arg)
self._write_line("kw_args")
with self._child_level():
for i, arg in enumerate(type.kw_args):
self._idx = i
if i == len(type.kw_args) - 1:
self._mark_last()
self._print_function_arg(arg)
self._write_line("returns", last=True)
with self._child_level(single=True):
type.returns.accept(self)
def _print_function_arg(self, arg: m.FunctionType.Argument) -> None:
self._write_line("Argument")
with self._child_level():
name: str = "None"
if arg.name is not None:
name = f'"{arg.name.lexeme}"'
self._write_line(f"name: {name}")
self._write_line("type")
with self._child_level(single=True):
arg.type.accept(self)
self._write_line(f"required: {arg.required}", last=True)
class MidasPrinter(m.Expr.Visitor[str], m.Stmt.Visitor[str], m.Type.Visitor[str]):
def __init__(self, indent: int = 4):
self.indent: int = indent
self.level: int = 0
def indented(self, text: str) -> str:
return " " * (self.level * self.indent) + text
def print(self, expr: m.Expr | m.Stmt | m.Type) -> str:
self.level = 0
return expr.accept(self)
def visit_type_stmt(self, stmt: m.TypeStmt) -> str:
template: str = ""
if len(stmt.params) != 0:
params: list[str] = [self._print_type_param(param) for param in stmt.params]
template = f"[{', '.join(params)}]"
res: str = f"type {stmt.name.lexeme}{template} = {stmt.type.accept(self)}"
return self.indented(res)
def _print_type_param(self, param: m.TypeParam) -> str:
res: str = param.name.lexeme
if param.bound is not None:
res += "<:" + param.bound.accept(self)
return res
def visit_member_stmt(self, stmt: m.MemberStmt):
keyword: str = {
m.MemberKind.PROPERTY: "prop",
m.MemberKind.METHOD: "def",
}.get(stmt.kind, "")
res: str = f"{keyword} {stmt.name.lexeme}: {stmt.type.accept(self)}"
return self.indented(res)
def visit_extend_stmt(self, stmt: m.ExtendStmt):
template: str = ""
if len(stmt.params) != 0:
params: list[str] = [self._print_type_param(param) for param in stmt.params]
template = f"[{', '.join(params)}]"
res: str = self.indented(f"extend {stmt.name.lexeme}{template}")
res += " {\n"
self.level += 1
for member in stmt.members:
res += member.accept(self) + "\n"
self.level -= 1
res += self.indented("}")
return res
def visit_predicate_stmt(self, stmt: m.PredicateStmt):
name: str = stmt.name.lexeme
subject: str = stmt.subject.lexeme
type: str = stmt.type.accept(self)
condition: str = stmt.condition.accept(self)
return self.indented(f"predicate {name}({subject}: {type}) = {condition}")
def visit_logical_expr(self, expr: m.LogicalExpr):
left: str = expr.left.accept(self)
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{left} {operator} {right}"
def visit_binary_expr(self, expr: m.BinaryExpr):
left: str = expr.left.accept(self)
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{left} {operator} {right}"
def visit_unary_expr(self, expr: m.UnaryExpr):
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{operator}{right}"
def visit_get_expr(self, expr: m.GetExpr):
expr_: str = expr.expr.accept(self)
name: str = expr.name.lexeme
return f"{expr_}.{name}"
def visit_variable_expr(self, expr: m.VariableExpr):
return expr.name.lexeme
def visit_grouping_expr(self, expr: m.GroupingExpr):
expr_: str = expr.expr.accept(self)
return f"({expr_})"
def visit_literal_expr(self, expr: m.LiteralExpr):
return str(expr.value)
def visit_wildcard_expr(self, expr: m.WildcardExpr):
return "_"
def visit_named_type(self, type: m.NamedType) -> str:
return type.name.lexeme
def visit_generic_type(self, type: m.GenericType) -> str:
res: str = type.type.accept(self)
if len(type.args) != 0:
args: list[str] = [param.accept(self) for param in type.args]
res += f"[{', '.join(args)}]"
return res
def visit_constraint_type(self, type: m.ConstraintType) -> str:
res: str = type.type.accept(self)
res += " where " + type.constraint.accept(self)
return res
def visit_complex_type(self, type: m.ComplexType) -> str:
res: str = "{\n"
self.level += 1
for member in type.members:
res += member.accept(self)
res += "\n"
self.level -= 1
res += self.indented("}")
return res
def visit_extension_type(self, type: m.ExtensionType) -> str:
return f"{type.base.accept(self)} & {type.extension.accept(self)}"
def visit_function_type(self, type: m.FunctionType) -> str:
pos_args: list[str] = [self._print_arg(arg) for arg in type.pos_args]
mixed_args: list[str] = [self._print_arg(arg) for arg in type.args]
kw_args: list[str] = [self._print_arg(arg) for arg in type.kw_args]
args: list[str] = pos_args
if len(pos_args) != 0:
args.append("/")
args += mixed_args
if len(kw_args) != 0:
args.append("*")
args += kw_args
return f"fn ({', '.join(args)}) -> {type.returns.accept(self)}"
def _print_arg(self, arg: m.FunctionType.Argument) -> str:
res: str = ""
if arg.name is not None:
res += arg.name.lexeme
res += ": "
res += arg.type.accept(self)
if not arg.required:
res += "?"
return res
class PythonAstPrinter(
AstPrinter,
p.MidasType.Visitor[None],
p.Stmt.Visitor[None],
p.Expr.Visitor[None],
):
def visit_base_type(self, node: p.BaseType) -> None:
self._write_line("BaseType")
with self._child_level():
self._write_line(f"base: {node.base}")
self._write_optional_child("param", node.param, last=True)
def visit_constraint_type(self, node: p.ConstraintType) -> None:
self._write_line("ConstraintType")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
node.type.accept(self)
self._write_line(f"constraint: {ast.unparse(node.constraint)}", last=True)
def visit_frame_column(self, node: p.FrameColumn) -> None:
self._write_line("FrameColumn")
with self._child_level():
self._write_line(f"name: {node.name}")
self._write_optional_child("type", node.type, last=True)
def visit_frame_type(self, node: p.FrameType) -> None:
self._write_line("FrameType")
with self._child_level():
self._write_line("columns", last=True)
with self._child_level():
for i, col in enumerate(node.columns):
self._idx = i
if i == len(node.columns) - 1:
self._mark_last()
col.accept(self)
def visit_expression_stmt(self, stmt: p.ExpressionStmt) -> None:
stmt.expr.accept(self)
def visit_function(self, stmt: p.Function) -> None:
self._write_line("Function")
with self._child_level():
self._write_line(f"name: {stmt.name}")
self._write_line("posonlyargs")
with self._child_level():
for i, arg in enumerate(stmt.posonlyargs):
self._idx = i
if i == len(stmt.posonlyargs) - 1:
self._mark_last()
self._print_argument(arg)
self._write_line("args")
with self._child_level():
for i, arg in enumerate(stmt.args):
self._idx = i
if i == len(stmt.args) - 1:
self._mark_last()
self._print_argument(arg)
self._write_line("kwonlyargs")
with self._child_level():
for i, arg in enumerate(stmt.kwonlyargs):
self._idx = i
if i == len(stmt.kwonlyargs) - 1:
self._mark_last()
self._print_argument(arg)
self._write_optional_child("returns", stmt.returns)
self._write_line("body", last=True)
with self._child_level():
for i, body_stmt in enumerate(stmt.body):
self._idx = i
if i == len(stmt.body) - 1:
self._mark_last()
body_stmt.accept(self)
def _print_argument(self, arg: p.Function.Argument) -> None:
self._write_line("FunctionArgument")
with self._child_level():
self._write_line(f"name: {arg.name}")
self._write_optional_child("type", arg.type, last=True)
def visit_type_assign(self, stmt: p.TypeAssign) -> None:
self._write_line("TypeAssign")
with self._child_level():
self._write_line(f"name: {stmt.name}")
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def visit_assign_stmt(self, stmt: p.AssignStmt) -> None:
self._write_line("AssignStmt")
with self._child_level():
self._write_line("targets")
with self._child_level():
for i, target in enumerate(stmt.targets):
self._idx = i
if i == len(stmt.targets) - 1:
self._mark_last()
target.accept(self)
self._write_line("value", last=True)
with self._child_level(single=True):
stmt.value.accept(self)
def visit_return_stmt(self, stmt: p.ReturnStmt) -> None:
self._write_line("ReturnStmt")
with self._child_level():
self._write_optional_child("value", stmt.value, last=True)
def visit_if_stmt(self, stmt: p.IfStmt) -> None:
self._write_line("IfStmt")
with self._child_level():
self._write_line("test")
with self._child_level(single=True):
stmt.test.accept(self)
self._write_line("body")
with self._child_level():
for i, body_stmt in enumerate(stmt.body):
self._idx = i
if i == len(stmt.body) - 1:
self._mark_last()
body_stmt.accept(self)
self._write_line("orelse", last=True)
with self._child_level():
for i, else_stmt in enumerate(stmt.orelse):
self._idx = i
if i == len(stmt.orelse) - 1:
self._mark_last()
else_stmt.accept(self)
def visit_pass(self, stmt: p.Pass) -> None:
self._write_line("Pass")
def visit_for_stmt(self, stmt: p.ForStmt) -> None:
self._write_line("ForStmt")
with self._child_level():
self._write_line("target")
with self._child_level(single=True):
stmt.target.accept(self)
self._write_line("iterator")
with self._child_level(single=True):
stmt.iterator.accept(self)
self._write_line("body", last=True)
with self._child_level():
for i, body_stmt in enumerate(stmt.body):
self._idx = i
if i == len(stmt.body) - 1:
self._mark_last()
body_stmt.accept(self)
def visit_raw_stmt(self, stmt: p.RawStmt) -> None:
self._write_line("RawStmt")
with self._child_level(single=True):
self._write_line(f"stmt: {ast.unparse(stmt.stmt)}")
def visit_binary_expr(self, expr: p.BinaryExpr) -> None:
self._write_line("BinaryExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_compare_expr(self, expr: p.CompareExpr) -> None:
self._write_line("CompareExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_unary_expr(self, expr: p.UnaryExpr) -> None:
self._write_line("UnaryExpr")
with self._child_level():
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_call_expr(self, expr: p.CallExpr) -> None:
self._write_line("CallExpr")
with self._child_level():
self._write_line("callee")
with self._child_level(single=True):
expr.callee.accept(self)
self._write_line("arguments")
with self._child_level():
for i, arg in enumerate(expr.arguments):
self._idx = i
if i == len(expr.arguments) - 1:
self._mark_last()
arg.accept(self)
self._write_line("keywords", last=True)
with self._child_level():
for i, (name, arg) in enumerate(expr.keywords.items()):
self._idx = i
if i == len(expr.keywords) - 1:
self._mark_last()
self._write_line(name)
with self._child_level(single=True):
arg.accept(self)
def visit_get_expr(self, expr: p.GetExpr) -> None:
self._write_line("GetExpr")
with self._child_level():
self._write_line("object")
with self._child_level(single=True):
expr.object.accept(self)
self._write_line(f"name: {expr.name}", last=True)
def visit_literal_expr(self, expr: p.LiteralExpr) -> None:
self._write_line("LiteralExpr")
with self._child_level(single=True):
self._write_line(f"value: {expr.value!r}")
def visit_variable_expr(self, expr: p.VariableExpr) -> None:
self._write_line("VariableExpr")
with self._child_level(single=True):
self._write_line(f"name: {expr.name}")
def visit_logical_expr(self, expr: p.LogicalExpr) -> None:
self._write_line("LogicalExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_cast_expr(self, expr: p.CastExpr) -> None:
self._write_line("CastExpr")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
expr.type.accept(self)
self._write_line("expr", last=True)
with self._child_level(single=True):
expr.expr.accept(self)
def visit_ternary_expr(self, expr: p.TernaryExpr) -> None:
self._write_line("TernaryExpr")
with self._child_level():
self._write_line("test")
with self._child_level(single=True):
expr.test.accept(self)
self._write_line("if_true")
with self._child_level(single=True):
expr.if_true.accept(self)
self._write_line("if_false", last=True)
with self._child_level(single=True):
expr.if_false.accept(self)
def visit_list_expr(self, expr: p.ListExpr) -> None:
self._write_line("ListExpr")
with self._child_level():
self._write_line("items", last=True)
with self._child_level():
for i, item in enumerate(expr.items):
self._idx = i
if i == len(expr.items) - 1:
self._mark_last()
item.accept(self)
def visit_dict_expr(self, expr: p.DictExpr) -> None:
self._write_line("DictExpr")
with self._child_level():
self._write_line("keys")
with self._child_level():
for i, key in enumerate(expr.keys):
self._idx = i
if i == len(expr.keys) - 1:
self._mark_last()
if key is None:
self._write_line("None")
else:
key.accept(self)
self._write_line("values", last=True)
with self._child_level():
for i, value in enumerate(expr.values):
self._idx = i
if i == len(expr.values) - 1:
self._mark_last()
value.accept(self)
def visit_subscript_expr(self, expr: p.SubscriptExpr) -> None:
self._write_line("SubscriptExpr")
with self._child_level():
self._write_line("object")
with self._child_level(single=True):
expr.object.accept(self)
self._write_line("index", last=True)
with self._child_level(single=True):
expr.index.accept(self)
def visit_slice_expr(self, expr: p.SliceExpr) -> None:
self._write_line("SliceExpr")
with self._child_level():
self._write_optional_child("lower", expr.lower)
self._write_optional_child("upper", expr.upper)
self._write_optional_child("step", expr.step, last=True)
def visit_raw_expr(self, expr: p.RawExpr) -> None:
self._write_line("RawExpr")
with self._child_level(single=True):
self._write_line(f"expr: {ast.unparse(expr.expr)}")

View File

@@ -0,0 +1,3 @@
from .midas import MidasPrinter as MidasPrinter
from .midas_ast import MidasAstPrinter as MidasAstPrinter
from .python_ast import PythonAstPrinter as PythonAstPrinter

103
midas/ast/printer/base.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import io
from contextlib import contextmanager
from enum import Enum, auto
from typing import Callable, Generator, Generic, Optional, Protocol, Sequence, TypeVar
class _Level(Enum):
EMPTY = auto()
ACTIVE = auto()
LAST = auto()
class Expr(Protocol):
def accept(self, printer: AstPrinter) -> None: ...
T = TypeVar("T", bound=Expr)
class AstPrinter(Generic[T]):
LAST_CHILD = "└── "
CHILD = "├── "
VERTICAL = ""
EMPTY = " "
def __init__(self):
self._levels: list[_Level] = []
self._idx: Optional[int] = None
self._buf: io.StringIO = io.StringIO()
def print(self, expr: T):
self._buf = io.StringIO()
expr.accept(self)
return self._buf.getvalue()
@contextmanager
def _child_level(self, single: bool = False) -> Generator[None, None, None]:
self._levels.append(_Level.LAST if single else _Level.ACTIVE)
try:
yield
finally:
self._levels.pop()
def _mark_last(self):
if self._levels:
self._levels[-1] = _Level.LAST
def _write_line(self, text: str, *, last: bool = False):
if last:
self._mark_last()
indent: str = self._build_indent()
if self._idx is not None:
text = f"[{self._idx}] {text}"
self._idx = None
self._buf.write(indent + text + "\n")
def _build_indent(self) -> str:
parts: list[str] = []
for level in self._levels[:-1]:
parts.append(self.EMPTY if level == _Level.EMPTY else self.VERTICAL)
if self._levels:
if self._levels[-1] == _Level.LAST:
parts.append(self.LAST_CHILD)
self._levels[-1] = _Level.EMPTY
else:
parts.append(self.CHILD)
return "".join(parts)
def _write_optional_child(
self, label: str, child: Optional[T], *, last: bool = False
):
if last:
self._mark_last()
if child is None:
self._write_line(f"{label}: None")
else:
self._write_line(label)
with self._child_level(single=True):
child.accept(self)
def _write_sequence(
self,
label: str,
list_: Sequence[T],
*,
last: bool = False,
print_func: Optional[Callable[[T], None]] = None,
):
if last:
self._mark_last()
self._write_line(label)
with self._child_level():
for i, item in enumerate(list_):
self._idx = i
if i == len(list_) - 1:
self._mark_last()
if print_func is not None:
print_func(item)
else:
item.accept(self)

170
midas/ast/printer/midas.py Normal file
View File

@@ -0,0 +1,170 @@
import midas.ast.midas as m
class MidasPrinter(
m.Expr.Visitor[str],
m.Stmt.Visitor[str],
m.Type.Visitor[str],
):
def __init__(self, indent: int = 4):
self.indent: int = indent
self.level: int = 0
def indented(self, text: str) -> str:
return " " * (self.level * self.indent) + text
def print(self, expr: m.Expr | m.Stmt | m.Type) -> str:
self.level = 0
return expr.accept(self)
# Statements
def visit_type_stmt(self, stmt: m.TypeStmt) -> str:
template: str = ""
if len(stmt.params) != 0:
params: list[str] = [self._print_type_param(param) for param in stmt.params]
template = f"[{', '.join(params)}]"
res: str = f"type {stmt.name.lexeme}{template} = {stmt.type.accept(self)}"
return self.indented(res)
def visit_alias_stmt(self, stmt: m.AliasStmt) -> str:
return self.indented(f"alias {stmt.name.lexeme} = {stmt.type.accept(self)}")
def _print_type_param(self, param: m.TypeParam) -> str:
res: str = param.name.lexeme
if param.bound is not None:
res += "<:" + param.bound.accept(self)
return res
def visit_member_stmt(self, stmt: m.MemberStmt):
keyword: str = {
m.MemberKind.PROPERTY: "prop",
m.MemberKind.METHOD: "def",
}.get(stmt.kind, "")
res: str = f"{keyword} {stmt.name.lexeme}: {stmt.type.accept(self)}"
return self.indented(res)
def visit_extend_stmt(self, stmt: m.ExtendStmt):
template: str = ""
if len(stmt.params) != 0:
params: list[str] = [self._print_type_param(param) for param in stmt.params]
template = f"[{', '.join(params)}]"
res: str = self.indented(f"extend {stmt.name.lexeme}{template}")
res += " {\n"
self.level += 1
for member in stmt.members:
res += member.accept(self) + "\n"
self.level -= 1
res += self.indented("}")
return res
def visit_predicate_stmt(self, stmt: m.PredicateStmt):
name: str = stmt.name.lexeme
sig: str = "".join(self._visit_param_spec(spec) for spec in stmt.params)
body: str = stmt.body.accept(self)
return self.indented(f"predicate {name}{sig} = {body}")
# Expressions
def visit_logical_expr(self, expr: m.LogicalExpr):
left: str = expr.left.accept(self)
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{left} {operator} {right}"
def visit_binary_expr(self, expr: m.BinaryExpr):
left: str = expr.left.accept(self)
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{left} {operator} {right}"
def visit_unary_expr(self, expr: m.UnaryExpr):
operator: str = expr.operator.lexeme
right: str = expr.right.accept(self)
return f"{operator}{right}"
def visit_call_expr(self, expr: m.CallExpr) -> str:
args: list[str] = [arg.accept(self) for arg in expr.arguments] + [
f"{name}={arg.accept(self)}" for name, arg in expr.keywords.items()
]
return f"{expr.callee.accept(self)}({', '.join(args)})"
def visit_get_expr(self, expr: m.GetExpr):
expr_: str = expr.expr.accept(self)
name: str = expr.name.lexeme
return f"{expr_}.{name}"
def visit_variable_expr(self, expr: m.VariableExpr):
return expr.name.lexeme
def visit_grouping_expr(self, expr: m.GroupingExpr):
expr_: str = expr.expr.accept(self)
return f"({expr_})"
def visit_literal_expr(self, expr: m.LiteralExpr):
return str(expr.value)
def visit_wildcard_expr(self, expr: m.WildcardExpr):
return "_"
# Types
def visit_named_type(self, type: m.NamedType) -> str:
return type.name.lexeme
def visit_generic_type(self, type: m.GenericType) -> str:
res: str = type.type.accept(self)
if len(type.args) != 0:
args: list[str] = [param.accept(self) for param in type.args]
res += f"[{', '.join(args)}]"
return res
def visit_constraint_type(self, type: m.ConstraintType) -> str:
res: str = type.type.accept(self)
res += " where " + type.constraint.accept(self)
return res
def visit_function_type(self, type: m.FunctionType) -> str:
spec: str = self._visit_param_spec(type.params)
return f"fn {spec} -> {type.returns.accept(self)}"
def _visit_param_spec(self, spec: m.ParamSpec) -> str:
pos: list[str] = [self._print_param(param) for param in spec.pos]
mixed: list[str] = [self._print_param(param) for param in spec.mixed]
kw: list[str] = [self._print_param(param) for param in spec.kw]
params: list[str] = pos
if len(pos) != 0:
params.append("/")
params += mixed
if len(kw) != 0:
params.append("*")
params += kw
return f"({', '.join(params)})"
def _print_param(self, param: m.FunctionType.Parameter) -> str:
res: str = ""
if param.name is not None:
res += param.name.lexeme
res += ": "
res += param.type.accept(self)
if not param.required:
res += "?"
return res
def visit_frame_type(self, type: m.FrameType) -> str:
res: str = self.indented("Frame[")
if len(type.columns) != 0:
res += "\n"
self.level += 1
columns: list[str] = []
for column in type.columns:
columns.append(self.indented(self._print_frame_column(column)))
res += ",\n".join(columns)
self.level -= 1
res += "\n"
res += "]"
return res
def _print_frame_column(self, column: m.FrameType.Column) -> str:
return f"{column.name.lexeme}: {column.type.accept(self)}"

View File

@@ -0,0 +1,238 @@
import midas.ast.midas as m
from midas.ast.printer.base import AstPrinter
class MidasAstPrinter(
AstPrinter,
m.Expr.Visitor[None],
m.Stmt.Visitor[None],
m.Type.Visitor[None],
):
# Statements
def visit_type_stmt(self, stmt: m.TypeStmt) -> None:
self._write_line("TypeStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_sequence(
"params",
stmt.params,
print_func=self._print_type_param,
)
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def visit_alias_stmt(self, stmt: m.AliasStmt) -> None:
self._write_line("AliasStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def _print_type_param(self, param: m.TypeParam) -> None:
self._write_line("Param")
with self._child_level():
self._write_line(f'name: "{param.name.lexeme}"')
self._write_optional_child("bound", param.bound, last=True)
def visit_member_stmt(self, stmt: m.MemberStmt):
self._write_line("MemberStmt")
with self._child_level():
self._write_line(f"kind: {stmt.kind.name}")
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def visit_extend_stmt(self, stmt: m.ExtendStmt) -> None:
self._write_line("ExtendStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_sequence(
"params",
stmt.params,
print_func=self._print_type_param,
)
self._write_sequence("members", stmt.members, last=True)
def visit_predicate_stmt(self, stmt: m.PredicateStmt):
self._write_line("PredicateStmt")
with self._child_level():
self._write_line(f'name: "{stmt.name.lexeme}"')
self._write_sequence(
"params",
stmt.params,
print_func=self._visit_param_spec,
)
self._write_line("body", last=True)
with self._child_level(single=True):
stmt.body.accept(self)
# Expressions
def visit_logical_expr(self, expr: m.LogicalExpr):
self._write_line("LogicalExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_binary_expr(self, expr: m.BinaryExpr):
self._write_line("BinaryExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_unary_expr(self, expr: m.UnaryExpr):
self._write_line("UnaryExpr")
with self._child_level():
self._write_line(f"operator: {expr.operator.lexeme}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_call_expr(self, expr: m.CallExpr) -> None:
self._write_line("CallExpr")
with self._child_level():
self._write_line("callee")
with self._child_level(single=True):
expr.callee.accept(self)
self._write_sequence("arguments", expr.arguments)
self._write_line("keywords", last=True)
with self._child_level():
for i, (name, arg) in enumerate(expr.keywords.items()):
self._idx = i
if i == len(expr.keywords) - 1:
self._mark_last()
self._write_line(name)
with self._child_level(single=True):
arg.accept(self)
def visit_get_expr(self, expr: m.GetExpr):
self._write_line("GetExpr")
with self._child_level():
self._write_line("expr")
with self._child_level(single=True):
expr.expr.accept(self)
self._write_line(f'name: "{expr.name.lexeme}"', last=True)
def visit_variable_expr(self, expr: m.VariableExpr):
self._write_line("VariableExpr")
with self._child_level():
self._write_line(f'name: "{expr.name.lexeme}"', last=True)
def visit_grouping_expr(self, expr: m.GroupingExpr):
self._write_line("GroupingExpr")
with self._child_level():
self._write_line("expr", last=True)
with self._child_level(single=True):
expr.expr.accept(self)
def visit_literal_expr(self, expr: m.LiteralExpr) -> None:
self._write_line("LiteralExpr")
with self._child_level():
self._write_line(f"value: {expr.value}", last=True)
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> None:
self._write_line("WildcardExpr")
# Types
def visit_named_type(self, type: m.NamedType) -> None:
self._write_line("NamedType")
with self._child_level():
self._write_line(f'name: "{type.name.lexeme}"', last=True)
def visit_generic_type(self, type: m.GenericType) -> None:
self._write_line("GenericType")
with self._child_level():
self._write_line("type")
with self._child_level():
type.type.accept(self)
self._write_sequence("args", type.args, last=True)
def visit_constraint_type(self, type: m.ConstraintType) -> None:
self._write_line("ConstraintType")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
type.type.accept(self)
self._write_line("constraint", last=True)
with self._child_level(single=True):
type.constraint.accept(self)
def visit_function_type(self, type: m.FunctionType) -> None:
self._write_line("FunctionType")
with self._child_level():
self._write_line("params")
with self._child_level(single=True):
self._visit_param_spec(type.params)
self._write_line("returns", last=True)
with self._child_level(single=True):
type.returns.accept(self)
def _visit_param_spec(self, spec: m.ParamSpec) -> None:
self._write_line("ParamSpec")
with self._child_level():
self._write_sequence(
"pos",
spec.pos,
print_func=self._print_param,
)
self._write_sequence(
"mixed",
spec.mixed,
print_func=self._print_param,
)
self._write_sequence(
"kw",
spec.kw,
print_func=self._print_param,
last=True,
)
def _print_param(self, param: m.FunctionType.Parameter) -> None:
self._write_line("Parameter")
with self._child_level():
name: str = "None"
if param.name is not None:
name = f'"{param.name.lexeme}"'
self._write_line(f"name: {name}")
self._write_line("type")
with self._child_level(single=True):
param.type.accept(self)
self._write_line(f"required: {param.required}", last=True)
def visit_frame_type(self, type: m.FrameType) -> None:
self._write_line("FrameType")
with self._child_level(single=True):
self._write_sequence(
"columns",
type.columns,
print_func=self._print_frame_column,
)
def _print_frame_column(self, column: m.FrameType.Column) -> None:
self._write_line("Column")
with self._child_level():
self._write_line(f'name: "{column.name.lexeme}"')
self._write_line("type")
with self._child_level(single=True):
column.type.accept(self)

View File

@@ -0,0 +1,285 @@
import ast
import midas.ast.python as p
from midas.ast.printer.base import AstPrinter
class PythonAstPrinter(
AstPrinter,
p.MidasType.Visitor[None],
p.Stmt.Visitor[None],
p.Expr.Visitor[None],
):
# Types
def visit_base_type(self, node: p.BaseType) -> None:
self._write_line("BaseType")
with self._child_level():
self._write_line(f"base: {node.base}")
self._write_sequence("args", node.args, last=True)
def visit_constraint_type(self, node: p.ConstraintType) -> None:
self._write_line("ConstraintType")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
node.type.accept(self)
self._write_line(f"constraint: {ast.unparse(node.constraint)}", last=True)
def visit_frame_column(self, node: p.FrameColumn) -> None:
self._write_line("FrameColumn")
with self._child_level():
self._write_line(f"name: {node.name}")
self._write_optional_child("type", node.type, last=True)
def visit_frame_type(self, node: p.FrameType) -> None:
self._write_line("FrameType")
with self._child_level(single=True):
self._write_sequence("columns", node.columns)
# Statements
def visit_expression_stmt(self, stmt: p.ExpressionStmt) -> None:
stmt.expr.accept(self)
def visit_function(self, stmt: p.Function) -> None:
self._write_line("Function")
with self._child_level():
self._write_line(f"name: {stmt.name}")
self._write_line("params")
with self._child_level():
self._print_param_spec(stmt.params)
self._write_optional_child("returns", stmt.returns)
self._write_sequence("body", stmt.body, last=True)
def _print_param_spec(self, spec: p.ParamSpec) -> None:
self._write_line("ParamSpec")
with self._child_level():
self._write_sequence(
"pos",
spec.pos,
print_func=self._print_param,
)
self._write_sequence(
"mixed",
spec.mixed,
print_func=self._print_param,
)
self._write_sequence(
"kw",
spec.kw,
print_func=self._print_param,
last=True,
)
def _print_param(self, param: p.Function.Parameter) -> None:
self._write_line("Parameter")
with self._child_level():
self._write_line(f"name: {param.name}")
self._write_optional_child("type", param.type, last=True)
def visit_type_assign(self, stmt: p.TypeAssign) -> None:
self._write_line("TypeAssign")
with self._child_level():
self._write_line(f"name: {stmt.name}")
self._write_line("type", last=True)
with self._child_level(single=True):
stmt.type.accept(self)
def visit_assign_stmt(self, stmt: p.AssignStmt) -> None:
self._write_line("AssignStmt")
with self._child_level():
self._write_sequence("targets", stmt.targets)
self._write_line("value", last=True)
with self._child_level(single=True):
stmt.value.accept(self)
def visit_return_stmt(self, stmt: p.ReturnStmt) -> None:
self._write_line("ReturnStmt")
with self._child_level():
self._write_optional_child("value", stmt.value, last=True)
def visit_if_stmt(self, stmt: p.IfStmt) -> None:
self._write_line("IfStmt")
with self._child_level():
self._write_line("test")
with self._child_level(single=True):
stmt.test.accept(self)
self._write_sequence("body", stmt.body)
self._write_sequence("orelse", stmt.orelse, last=True)
def visit_pass(self, stmt: p.Pass) -> None:
self._write_line("Pass")
def visit_for_stmt(self, stmt: p.ForStmt) -> None:
self._write_line("ForStmt")
with self._child_level():
self._write_line("target")
with self._child_level(single=True):
stmt.target.accept(self)
self._write_line("iterator")
with self._child_level(single=True):
stmt.iterator.accept(self)
self._write_sequence("body", stmt.body, last=True)
def visit_raw_stmt(self, stmt: p.RawStmt) -> None:
self._write_line("RawStmt")
with self._child_level(single=True):
self._write_line(f"stmt: {ast.unparse(stmt.stmt)}")
# Expressions
def visit_binary_expr(self, expr: p.BinaryExpr) -> None:
self._write_line("BinaryExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_compare_expr(self, expr: p.CompareExpr) -> None:
self._write_line("CompareExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_unary_expr(self, expr: p.UnaryExpr) -> None:
self._write_line("UnaryExpr")
with self._child_level():
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_call_expr(self, expr: p.CallExpr) -> None:
self._write_line("CallExpr")
with self._child_level():
self._write_line("callee")
with self._child_level(single=True):
expr.callee.accept(self)
self._write_sequence("arguments", expr.arguments)
self._write_line("keywords", last=True)
with self._child_level():
for i, (name, arg) in enumerate(expr.keywords.items()):
self._idx = i
if i == len(expr.keywords) - 1:
self._mark_last()
self._write_line(name)
with self._child_level(single=True):
arg.accept(self)
def visit_get_expr(self, expr: p.GetExpr) -> None:
self._write_line("GetExpr")
with self._child_level():
self._write_line("object")
with self._child_level(single=True):
expr.object.accept(self)
self._write_line(f"name: {expr.name}", last=True)
def visit_literal_expr(self, expr: p.LiteralExpr) -> None:
self._write_line("LiteralExpr")
with self._child_level(single=True):
self._write_line(f"value: {expr.value!r}")
def visit_variable_expr(self, expr: p.VariableExpr) -> None:
self._write_line("VariableExpr")
with self._child_level(single=True):
self._write_line(f"name: {expr.name}")
def visit_logical_expr(self, expr: p.LogicalExpr) -> None:
self._write_line("LogicalExpr")
with self._child_level():
self._write_line("left")
with self._child_level(single=True):
expr.left.accept(self)
self._write_line(f"operator: {expr.operator.__class__.__name__}")
self._write_line("right", last=True)
with self._child_level(single=True):
expr.right.accept(self)
def visit_cast_expr(self, expr: p.CastExpr) -> None:
self._write_line("CastExpr")
with self._child_level():
self._write_line("type")
with self._child_level(single=True):
expr.type.accept(self)
self._write_line("expr")
with self._child_level(single=True):
expr.expr.accept(self)
self._write_line(f"unsafe: {expr.unsafe}", last=True)
def visit_ternary_expr(self, expr: p.TernaryExpr) -> None:
self._write_line("TernaryExpr")
with self._child_level():
self._write_line("test")
with self._child_level(single=True):
expr.test.accept(self)
self._write_line("if_true")
with self._child_level(single=True):
expr.if_true.accept(self)
self._write_line("if_false", last=True)
with self._child_level(single=True):
expr.if_false.accept(self)
def visit_list_expr(self, expr: p.ListExpr) -> None:
self._write_line("ListExpr")
with self._child_level():
self._write_sequence("items", expr.items, last=True)
def visit_dict_expr(self, expr: p.DictExpr) -> None:
self._write_line("DictExpr")
with self._child_level():
self._write_sequence(
"keys",
expr.keys,
print_func=lambda k: (
self._write_line("None") if k is None else k.accept(self)
),
)
self._write_sequence("values", expr.values, last=True)
def visit_subscript_expr(self, expr: p.SubscriptExpr) -> None:
self._write_line("SubscriptExpr")
with self._child_level():
self._write_line("object")
with self._child_level(single=True):
expr.object.accept(self)
self._write_line("index", last=True)
with self._child_level(single=True):
expr.index.accept(self)
def visit_slice_expr(self, expr: p.SliceExpr) -> None:
self._write_line("SliceExpr")
with self._child_level():
self._write_optional_child("lower", expr.lower)
self._write_optional_child("upper", expr.upper)
self._write_optional_child("step", expr.step, last=True)
def visit_tuple_expr(self, expr: p.TupleExpr) -> None:
self._write_line("TupleExpr")
with self._child_level():
self._write_sequence("items", expr.items, last=True)
def visit_raw_expr(self, expr: p.RawExpr) -> None:
self._write_line("RawExpr")
with self._child_level(single=True):
self._write_line(f"expr: {ast.unparse(expr.expr)}")

View File

@@ -14,6 +14,27 @@ from midas.ast.location import Location
T = TypeVar("T") T = TypeVar("T")
@dataclass(frozen=True, kw_only=True)
class ParamSpec:
pos: list[Function.Parameter]
mixed: list[Function.Parameter]
kw: list[Function.Parameter]
@property
def all(self) -> list[Function.Parameter]:
return self.pos + self.mixed + self.kw
@dataclass(frozen=True, kw_only=True)
class ImportAlias:
location: Location
name: str
alias: Optional[str] = None
@property
def imported_name(self) -> str:
return self.alias if self.alias is not None else self.name
#################### ####################
# Type annotations # # Type annotations #
@@ -44,7 +65,7 @@ class MidasType(ABC):
@dataclass(frozen=True) @dataclass(frozen=True)
class BaseType(MidasType): class BaseType(MidasType):
base: str base: str
param: Optional[MidasType] args: tuple[MidasType, ...]
def accept(self, visitor: MidasType.Visitor[T]) -> T: def accept(self, visitor: MidasType.Visitor[T]) -> T:
return visitor.visit_base_type(self) return visitor.visit_base_type(self)
@@ -113,6 +134,12 @@ class Stmt(ABC):
@abstractmethod @abstractmethod
def visit_for_stmt(self, stmt: ForStmt) -> T: ... def visit_for_stmt(self, stmt: ForStmt) -> T: ...
@abstractmethod
def visit_import_stmt(self, stmt: ImportStmt) -> T: ...
@abstractmethod
def visit_from_import_stmt(self, stmt: FromImportStmt) -> T: ...
@abstractmethod @abstractmethod
def visit_raw_stmt(self, stmt: RawStmt) -> T: ... def visit_raw_stmt(self, stmt: RawStmt) -> T: ...
@@ -128,25 +155,17 @@ class ExpressionStmt(Stmt):
@dataclass(frozen=True) @dataclass(frozen=True)
class Function(Stmt): class Function(Stmt):
name: str name: str
posonlyargs: list[Argument] params: ParamSpec
args: list[Argument]
sink: Optional[Argument]
kwonlyargs: list[Argument]
kw_sink: Optional[Argument]
returns: Optional[MidasType] returns: Optional[MidasType]
body: list[Stmt] body: list[Stmt]
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Argument: class Parameter:
location: Optional[Location] = None location: Optional[Location] = None
name: str name: str
type: Optional[MidasType] type: Optional[MidasType]
default: Optional[Expr] default: Optional[Expr]
@property
def all_args(self) -> list[Argument]:
return self.posonlyargs + self.args + self.kwonlyargs
def accept(self, visitor: Stmt.Visitor[T]) -> T: def accept(self, visitor: Stmt.Visitor[T]) -> T:
return visitor.visit_function(self) return visitor.visit_function(self)
@@ -205,6 +224,24 @@ class ForStmt(Stmt):
return visitor.visit_for_stmt(self) return visitor.visit_for_stmt(self)
@dataclass(frozen=True)
class ImportStmt(Stmt):
imports: list[ImportAlias]
def accept(self, visitor: Stmt.Visitor[T]) -> T:
return visitor.visit_import_stmt(self)
@dataclass(frozen=True)
class FromImportStmt(Stmt):
module: Optional[str]
imports: list[ImportAlias]
level: int
def accept(self, visitor: Stmt.Visitor[T]) -> T:
return visitor.visit_from_import_stmt(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class RawStmt(Stmt): class RawStmt(Stmt):
stmt: ast.stmt stmt: ast.stmt
@@ -268,6 +305,9 @@ class Expr(ABC):
@abstractmethod @abstractmethod
def visit_slice_expr(self, expr: SliceExpr) -> T: ... def visit_slice_expr(self, expr: SliceExpr) -> T: ...
@abstractmethod
def visit_tuple_expr(self, expr: TupleExpr) -> T: ...
@abstractmethod @abstractmethod
def visit_raw_expr(self, expr: RawExpr) -> T: ... def visit_raw_expr(self, expr: RawExpr) -> T: ...
@@ -350,6 +390,7 @@ class LogicalExpr(Expr):
class CastExpr(Expr): class CastExpr(Expr):
type: MidasType type: MidasType
expr: Expr expr: Expr
unsafe: bool
def accept(self, visitor: Expr.Visitor[T]) -> T: def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_cast_expr(self) return visitor.visit_cast_expr(self)
@@ -401,6 +442,14 @@ class SliceExpr(Expr):
return visitor.visit_slice_expr(self) return visitor.visit_slice_expr(self)
@dataclass(frozen=True)
class TupleExpr(Expr):
items: tuple[Expr, ...]
def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_tuple_expr(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class RawExpr(Expr): class RawExpr(Expr):
expr: ast.expr expr: ast.expr

View File

@@ -179,3 +179,99 @@ extend dict[K, V] {
// def __ior__: fn(value: Iterable[tuple[K, V]], /) -> dict[K, V] // def __ior__: fn(value: Iterable[tuple[K, V]], /) -> dict[K, V]
} }
extend str {
def capitalize: fn() -> str
def casefold: fn() -> str
def center: fn(width: int, fillchar: str?, /) -> str
def count: fn(sub: str, start: None?, end: None?, /) -> int
def count: fn(sub: str, start: int, end: None?, /) -> int
def count: fn(sub: str, start: None, end: int, /) -> int
def count: fn(sub: str, start: int, end: int, /) -> int
def encode: fn(encoding: str?, errors: str?) -> bytes
def endswith: fn(suffix: str, start: None?, end: None?, /) -> bool
def endswith: fn(suffix: str, start: int, end: None?, /) -> bool
def endswith: fn(suffix: str, start: None, end: int, /) -> bool
def endswith: fn(suffix: str, start: int, end: int, /) -> bool
def expandtabs: fn(tabsize: int?) -> str
def find: fn(sub: str, start: None?, end: None?, /) -> int
def find: fn(sub: str, start: int, end: None?, /) -> int
def find: fn(sub: str, start: None, end: int, /) -> int
def find: fn(sub: str, start: int, end: int, /) -> int
// def format: fn(*args: object, **kwargs: object) -> str
// def format_map: fn(mapping: _FormatMapMapping, /) -> str
def index: fn(sub: str, start: None?, end: None?, /) -> int
def index: fn(sub: str, start: int, end: None?, /) -> int
def index: fn(sub: str, start: None, end: int, /) -> int
def index: fn(sub: str, start: int, end: int, /) -> int
def isalnum: fn() -> bool
def isalpha: fn() -> bool
def isascii: fn() -> bool
def isdecimal: fn() -> bool
def isdigit: fn() -> bool
def isidentifier: fn() -> bool
def islower: fn() -> bool
def isnumeric: fn() -> bool
def isprintable: fn() -> bool
def isspace: fn() -> bool
def istitle: fn() -> bool
def isupper: fn() -> bool
def join: fn(iterable: list[str], /) -> str // TODO: use Iterable
def ljust: fn(width: int, fillchar: str?, /) -> str
def lower: fn() -> str
def lstrip: fn(chars: None?, /) -> str
def lstrip: fn(chars: str, /) -> str
def partition: fn(sep: str, /) -> tuple[str, str, str]
def replace: fn(old: str, new: str, count: int?, /) -> str
def removeprefix: fn(prefix: str, /) -> str
def removesuffix: fn(suffix: str, /) -> str
def rfind: fn(sub: str, start: None?, end: None?, /) -> int
def rfind: fn(sub: str, start: int, end: None?, /) -> int
def rfind: fn(sub: str, start: None, end: int, /) -> int
def rfind: fn(sub: str, start: int, end: int, /) -> int
def rindex: fn(sub: str, start: None?, end: None?, /) -> int
def rindex: fn(sub: str, start: int, end: None?, /) -> int
def rindex: fn(sub: str, start: None, end: int, /) -> int
def rindex: fn(sub: str, start: int, end: int, /) -> int
def rjust: fn(width: int, fillchar: str?, /) -> str
def rpartition: fn(sep: str, /) -> tuple[str, str, str]
def rsplit: fn(sep: None?, maxsplit: int?) -> list[str]
def rsplit: fn(sep: str, maxsplit: int?) -> list[str]
def rstrip: fn(chars: None?, /) -> str
def rstrip: fn(chars: str, /) -> str
def split: fn(sep: None?, maxsplit: int?) -> list[str]
def split: fn(sep: str, maxsplit: int?) -> list[str]
def splitlines: fn(keepends: bool?) -> list[str]
def startswith: fn(prefix: str, start: None?, end: None?, /) -> bool
def startswith: fn(prefix: str, start: int, end: None?, /) -> bool
def startswith: fn(prefix: str, start: None, end: int, /) -> bool
def startswith: fn(prefix: str, start: int, end: int, /) -> bool
def strip: fn(chars: None?, /) -> str
def strip: fn(chars: str, /) -> str
def swapcase: fn() -> str
def title: fn() -> str
// def translate: fn(table: _TranslateTable, /) -> str
def upper: fn() -> str
def zfill: fn(width: int, /) -> str
def __add__: fn(value: str, /) -> str
// Incompatible with Sequence.__contains__
def __contains__: fn(key: str, /) -> bool
def __eq__: fn(value: object, /) -> bool
def __ge__: fn(value: str, /) -> bool
def __getitem__: fn(key: slice, /) -> str
def __getitem__: fn(key: int, /) -> str
def __gt__: fn(value: str, /) -> bool
def __hash__: fn() -> int
// def __iter__: fn() -> Iterator[str]
def __le__: fn(value: str, /) -> bool
def __len__: fn() -> int
def __lt__: fn(value: str, /) -> bool
def __mod__: fn(value: Any, /) -> str
def __mul__: fn(value: int, /) -> str
def __ne__: fn(value: object, /) -> bool
def __rmul__: fn(value: int, /) -> str
def __getnewargs__: fn() -> tuple[str]
def __format__: fn(format_spec: str, /) -> str
}

View File

@@ -15,9 +15,14 @@ if TYPE_CHECKING:
BUILTIN_SUBTYPES: dict[str, set[str]] = { BUILTIN_SUBTYPES: dict[str, set[str]] = {
"object": {"float", "list", "dict", "str", "bytes", "tuple"},
"float": {"int"}, "float": {"int"},
"int": {"bool"},
} }
"""
Hard-coded subtype relationships between builtin types
Circular dependencies and diamond inheritance MUST be avoided
"""
def define_builtins(reg: TypesRegistry): def define_builtins(reg: TypesRegistry):
@@ -25,12 +30,15 @@ def define_builtins(reg: TypesRegistry):
any = reg.define_type("Any", TopType()) any = reg.define_type("Any", TopType())
unit = reg.define_type("None", UnitType()) unit = reg.define_type("None", UnitType())
object = reg.define_type("object", BaseType(name="object")) object = reg.define_type("object", BaseType(name="object"))
bytes = reg.define_type("bytes", BaseType(name="bytes"))
bool = reg.define_type("bool", BaseType(name="bool")) bool = reg.define_type("bool", BaseType(name="bool"))
int = reg.define_type("int", BaseType(name="int")) int = reg.define_type("int", BaseType(name="int"))
float = reg.define_type("float", BaseType(name="float")) float = reg.define_type("float", BaseType(name="float"))
str = reg.define_type("str", BaseType(name="str")) str = reg.define_type("str", BaseType(name="str"))
slice = reg.define_type("slice", BaseType(name="slice")) slice = reg.define_type("slice", BaseType(name="slice"))
tuple = reg.define_type("tuple", BaseType(name="tuple"))
list = reg.define_type( list = reg.define_type(
"list", "list",
GenericType( GenericType(

View File

@@ -10,6 +10,11 @@ from midas.utils import TypedAST
class TypeChecker: class TypeChecker:
"""Type checking dispatcher
Contains a typer for Midas and one for Python, as well as the types registry
"""
def __init__(self): def __init__(self):
self.types: TypesRegistry = TypesRegistry() self.types: TypesRegistry = TypesRegistry()
self.reporter: Reporter = Reporter() self.reporter: Reporter = Reporter()

View File

@@ -9,10 +9,17 @@ class DiagnosticType(StrEnum):
ERROR = "Error" ERROR = "Error"
WARNING = "Warning" WARNING = "Warning"
INFO = "Info" INFO = "Info"
DEBUG = "Debug"
@dataclass(frozen=True) @dataclass(frozen=True)
class Diagnostic: class Diagnostic:
"""Information about a diagnostic (warning, errors, etc.)
Holds a location, a diagnostic type and a message.
Optionally bound to a file path
"""
file_path: Optional[str] file_path: Optional[str]
location: Location location: Location
type: DiagnosticType type: DiagnosticType
@@ -20,6 +27,18 @@ class Diagnostic:
@property @property
def location_str(self) -> str: def location_str(self) -> str:
"""Get diagnostic type and location as a human readable string
The location is formatted as "<Type> in <file> from L<start_line>:<start_col> to <end_line>:<end_col>",
for example: "Error in /home/user/Desktop/script.py from L12:5 to L12:8"
If the file is `None`, the "in ..." section is excluded from the result.<br>
If the location's end is not specified, the formulation "at L<start_line>:<start_col>" is used.
Returns:
str: the formatted type and location string
"""
start_loc: str = f"L{self.location.lineno}:{self.location.col_offset+1}" start_loc: str = f"L{self.location.lineno}:{self.location.col_offset+1}"
end_loc: Optional[str] = "" end_loc: Optional[str] = ""
if ( if (

532
midas/checker/dispatcher.py Normal file
View File

@@ -0,0 +1,532 @@
import logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Generic, Optional, Protocol, TypeVar, Union
from midas.ast.location import Location
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import (
AppliedType,
DerivedType,
Function,
GenericType,
OverloadedFunction,
Type,
UnknownType,
)
from midas.checker.unifier import Unifier
class HasLocation(Protocol):
@property
def location(self) -> Location: ...
E = TypeVar("E", bound=HasLocation)
TypedExpr = tuple[E, Type]
"""An expression and its type"""
@dataclass(frozen=True, kw_only=True)
class MappedArgument(Generic[E]):
"""An argument passed in a call and the corresponding parameter"""
arg_expr: E
arg_type: Type
parameter: Function.Parameter
@dataclass(frozen=True, kw_only=True)
class OverloadCandidate:
"""An overloaded function call candidate with its mapped arguments"""
function: Function
mapped: list[MappedArgument]
class CallError(StrEnum):
"""Reason of a call error"""
INVALID_ARGS = "Invalid arguments"
NO_MATCHING_OVERLOAD = "No matching overload"
IMPOSSIBLE_UNIFICATION = "Parameters unification failed"
NOT_CALLABLE = "Not callable"
@dataclass(frozen=True, kw_only=True)
class CallResult:
"""The result of a function call
Holds a return type, an optional error reason and message
"""
error: Optional[CallError] = None
"""The reason of the error, if there is one"""
result: Type = UnknownType()
"""The result type. `UnknownType()` if the call is invalid"""
message: Optional[str] = None
"""An optional error message"""
@property
def is_valid(self) -> bool:
"""Whether the call is valid (i.e. no error)"""
return self.error is None
@property
def error_message(self) -> str:
"""A descriptive message for the error, if there is one"""
if self.message is not None:
return self.message
if self.error is not None:
return str(self.error)
return ""
class CallDispatcher(Generic[E]):
"""Helper class to handle dispatching calls and mapping arguments
This class is responsible for mapping call-site arguments to function
parameters, verifying the validity of calls and computing their
return types
:class:`CallDispatcher` is generic to handle AST nodes from both Midas and Python
"""
def __init__(self, types: TypesRegistry, reporter: FileReporter) -> None:
self.types: TypesRegistry = types
self.reporter: FileReporter = reporter
self.logger: logging.Logger = logging.getLogger("CallDispatcher")
def set_reporter(self, reporter: FileReporter):
self.reporter = reporter
def get_result(
self,
location: Location,
callee: Type,
positional: list[TypedExpr[E]],
keywords: dict[str, TypedExpr[E]],
report_errors: bool = True,
) -> CallResult:
"""Get the result type of a function call
If the callee has overloads, this function will try to resolve the
appropriate signature.
Argument types are matched with the defined parameters.
This function doesn't take the raw expression as a parameter to
accommodate for desugared calls such as for operators.
Args:
location (Location): the call location
callee (Type): the called function
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns:
CallResult: the call result, either a type or an error
"""
match callee:
case Function() as function:
valid: bool
mapped: list[MappedArgument[E]]
valid, mapped = self.map_call_arguments(
function, location, positional, keywords
)
valid = valid and self._are_arguments_valid(mapped, report_errors)
if not valid:
return CallResult(error=CallError.INVALID_ARGS)
return CallResult(result=function.returns)
case OverloadedFunction(overloads=overloads):
res = self._match_overload(
overloads, location, positional, keywords, report_errors
)
if res[0] is None:
return CallResult(
error=CallError.NO_MATCHING_OVERLOAD,
message=res[1],
)
return CallResult(result=res[0].returns)
case AppliedType(body=body):
return self.get_result(
location, body, positional, keywords, report_errors
)
case UnknownType():
return CallResult(result=UnknownType())
case DerivedType(type=base):
return self.get_result(
location, base, positional, keywords, report_errors
)
case GenericType():
unifier: Unifier = Unifier(self.types)
pos: list[Type] = [a[1] for a in positional]
kw: dict[str, Type] = {k: v[1] for k, v in keywords.items()}
unified: Optional[Type] = unifier.unify_call(callee, pos, kw)
if unified is None:
pos_str: str = ", ".join(str(t) for t in pos)
kw_str: str = ", ".join(f"{k}: {v}" for k, v in kw.items())
message: str = (
f"Could not unify {callee}={callee.body} with pos=[{pos_str}] and kw={{{kw_str}}}"
)
if report_errors:
self.reporter.error(location, message)
return CallResult(
error=CallError.IMPOSSIBLE_UNIFICATION,
message=message,
)
return self.get_result(
location,
unified,
positional,
keywords,
report_errors,
)
case _:
message: str = f"{callee} ({callee.__class__.__name__}) is not callable"
if report_errors:
self.reporter.error(location, message)
return CallResult(
error=CallError.NOT_CALLABLE,
message=message,
)
def _unwrap_function(
self,
callee: Type,
positional: list[TypedExpr[E]],
keywords: dict[str, TypedExpr[E]],
) -> Union[tuple[Function, None], tuple[None, CallError]]:
"""Unwrap a type to get a callable `Function`
Args:
callee (Type): the called type
positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr[E]]): the map of keyword arguments
Returns:
Union[tuple[Function, None], tuple[None, CallError]]: a tuple
containing the callable `Function` type, or `None` if it could
not be unwrapped, and an error, or `None` if there was none.
"""
match callee:
case DerivedType(type=base):
return self._unwrap_function(base, positional, keywords)
case GenericType():
unifier: Unifier = Unifier(self.types)
unified: Optional[Type] = unifier.unify_call(
callee,
[a[1] for a in positional],
{k: v[1] for k, v in keywords.items()},
)
if unified is None:
return None, CallError.IMPOSSIBLE_UNIFICATION
return self._unwrap_function(unified, positional, keywords)
case Function():
return callee, None
case AppliedType(body=body):
return self._unwrap_function(body, positional, keywords)
case _:
return None, CallError.NOT_CALLABLE
def _are_arguments_valid(
self,
arguments: list[MappedArgument[E]],
report_errors: bool = True,
) -> bool:
"""Check whether the passed argument types correspond to their matched parameter definitions
Args:
arguments (list[MappedArgument]): the list of argument/parameter pairs
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns:
bool: True if all arguments fit the matching parameter definitions, False otherwise
"""
valid: bool = True
for arg in arguments:
if arg.parameter.unsupported:
# Always report error
self.reporter.error(
arg.arg_expr.location, f"Unsupported argument {arg.parameter.name}"
)
if not self.types.is_subtype(arg.arg_type, arg.parameter.type):
if report_errors:
self.reporter.error(
arg.arg_expr.location,
f"Wrong type for argument '{arg.parameter.name}', expected {arg.parameter.type}, got {arg.arg_type}",
)
valid = False
return valid
def _match_overload(
self,
overloads: list[Type],
location: Location,
positional: list[TypedExpr[E]],
keywords: dict[str, TypedExpr[E]],
report_errors: bool = True,
) -> Union[tuple[Function, None], tuple[None, str]]:
"""Try and resolve the appropriate overload for the given arguments
Args:
overloads (list[Type]): the list of possible overloads
location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keywords arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns:
Union[tuple[Function, None], tuple[None, str]]: a tuple containing
the resolved function signature if it can be determined
unambiguously, or `None`, and an error message, or `None`
"""
candidates: list[OverloadCandidate] = []
errors: list[CallError] = []
for overload in overloads:
function, unwrap_error = self._unwrap_function(
overload, positional, keywords
)
if function is None:
errors.append(unwrap_error) # type: ignore
continue
valid, mapped = self.map_call_arguments(
function=function,
location=location,
positional=positional,
keywords=keywords,
report_errors=False,
)
if valid and self._are_arguments_valid(mapped, report_errors=False):
candidates.append(
OverloadCandidate(
function=function,
mapped=mapped,
)
)
pos_types: str = ", ".join(str(type) for _, type in positional)
kw_types: str = ", ".join(
f"{name}: {type}" for name, (_, type) in keywords.items()
)
for_args: str = f"for arguments pos=[{pos_types}] and kw={{{kw_types}}}"
n_candidates: int = len(candidates)
# Exactly 1 match -> return it
if n_candidates == 1:
return candidates[0].function, None
# No match -> invalid call
if n_candidates == 0:
overloads_str: str = ", ".join(map(str, overloads))
errors_str: str = ", ".join(errors)
message: str = (
f"No matching overload in [{overloads_str}] {for_args} (errors: {errors_str})"
)
if report_errors:
self.reporter.error(location, message)
return None, message
# Multiple matches -> see if one <: all others (more specific)
for i1, c1 in enumerate(candidates):
mapped1: list[MappedArgument[E]] = c1.mapped
best_match: bool = True
for i2, c2 in enumerate(candidates):
if i1 == i2:
continue
mapped2: list[MappedArgument[E]] = c2.mapped
if not self._are_mapped_subtypes(mapped1, mapped2):
best_match = False
break
self.logger.debug(f"{c1.function} is a full overload of {c2.function}")
if best_match:
return c1.function, None
candidates_str: str = ", ".join(
str(candidate.function) for candidate in candidates
)
message: str = f"Multiple matching overloads {for_args}: {candidates_str}"
if report_errors:
self.reporter.error(location, message)
return None, message
def map_call_arguments(
self,
function: Function,
location: Location,
positional: list[TypedExpr[E]],
keywords: dict[str, TypedExpr[E]],
report_errors: bool = True,
) -> tuple[bool, list[MappedArgument]]:
"""Map call arguments to a function's parameters as defined in its signature
This method maps positional-only, keyword-only and mixed parameter definitions
with the arguments passed at the call site
Any mismatched, missing or unexpected argument is reported as a diagnostic,
unless `report_errors` is set to `False`
Args:
function (Function): the function definition
location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns:
tuple[bool, list[MappedArgument]]: a boolean reporting whether
the call is valid and the list of mapped arguments
"""
set_params: set[str] = set()
required_positional: list[str] = [
param.name
for param in function.params.pos + function.params.mixed
if param.required
]
required_keyword: list[str] = [
param.name for param in function.params.kw if param.required
]
mapped: list[MappedArgument[E]] = []
pos_params: list[Function.Parameter] = list(function.params.pos)
mixed_params: list[Function.Parameter] = list(function.params.mixed)
kw_params: dict[str, Function.Parameter] = {
param.name: param for param in function.params.kw
}
valid_call: bool = True
# TODO: handle *args and **kwargs sinks
for arg in positional:
param: Function.Parameter
if len(pos_params) != 0:
param = pos_params.pop(0)
elif len(mixed_params) != 0:
param = mixed_params.pop(0)
else:
if report_errors:
self.reporter.error(
arg[0].location, "Too many positional arguments"
)
valid_call = False
break
name: str = param.name
if name in required_positional:
required_positional.remove(name)
if name in required_keyword:
required_keyword.remove(name)
set_params.add(name)
mapped.append(
MappedArgument(
arg_expr=arg[0],
arg_type=arg[1],
parameter=param,
)
)
kw_params.update({param.name: param for param in mixed_params})
for name, arg in keywords.items():
param: Function.Parameter
if name not in kw_params:
if report_errors:
if name in set_params:
self.reporter.error(
arg[0].location, f"Multiple values for parameter '{name}'"
)
else:
self.reporter.error(
arg[0].location, f"Unknown keyword parameter '{name}'"
)
valid_call = False
continue
param = kw_params.pop(name)
if name in required_positional:
required_positional.remove(name)
if name in required_keyword:
required_keyword.remove(name)
set_params.add(name)
mapped.append(
MappedArgument(
arg_expr=arg[0],
arg_type=arg[1],
parameter=param,
)
)
def join_params(params: list[str]) -> str:
params = list(map(lambda p: f"'{p}'", params))
if len(params) == 0:
return ""
if len(params) == 1:
return params[0]
return ", ".join(params[:-1]) + " and " + params[-1]
if len(required_positional) != 0:
plural: str = "" if len(required_positional) == 1 else "s"
params: str = join_params(required_positional)
if report_errors:
self.reporter.error(
location,
f"Missing required positional argument{plural}: {params}",
)
valid_call = False
if len(required_keyword) != 0:
plural: str = "" if len(required_keyword) == 1 else "s"
params: str = join_params(required_keyword)
if report_errors:
self.reporter.error(
location,
f"Missing required keyword argument{plural}: {params}",
)
valid_call = False
return valid_call, mapped
def _are_mapped_subtypes(
self, mapped1: list[MappedArgument[E]], mapped2: list[MappedArgument[E]]
) -> bool:
"""Check whether the given argument mappings are subtype/supertype of one another
This function checks whether the argument mappings `mapped1` are subtypes
of `mapped2`. If any of the parameter type in `mapped1` is not a subtype
of the corresponding parameter in `mapped2`, `False` is returned.
This is used to check whether a given overload is a more specific
function / a subtype of another.
Args:
mapped1 (list[MappedArgument]): the first argument mappings (subtype)
mapped2 (list[MappedArgument]): the second argument mappings (supertype)
Returns:
bool: `True` if `mapped1` is a subtype of `mapped2`, `False` otherwise
"""
by_expr: dict[E, Type] = {}
for arg in mapped1:
by_expr[arg.arg_expr] = arg.parameter.type
for arg in mapped2:
type2: Type = arg.parameter.type
type1: Type = by_expr[arg.arg_expr]
if not self.types.is_subtype(type1, type2):
return False
return True

265
midas/checker/evaluator.py Normal file
View File

@@ -0,0 +1,265 @@
from dataclasses import dataclass
from typing import Any, Callable, Optional
import midas.ast.midas as m
from midas.ast.location import Location
from midas.checker.preamble import Preamble
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import Function, Predicate
from midas.lexer.token import TokenType
@dataclass(frozen=True, kw_only=True)
class PartialPredicate(Predicate):
"""A partially applied predicate"""
scope: dict[str, Any]
"""A dictionary of already applied parameters"""
class Evaluator(m.Expr.Visitor[Any]):
"""Helper class to evaluate an expression
This class is used to evaluate constraint types on literals at compile-time.
"""
def __init__(self, types: TypesRegistry, reporter: Optional[FileReporter] = None):
self.types: TypesRegistry = types
self.reporter: Optional[FileReporter] = reporter
self.preamble: Preamble = Preamble(self.types)
self.scopes: list[dict[str, Any]] = [{}]
def evaluate(self, expr: m.Expr) -> Any:
"""Evaluate the given expression
Args:
expr (m.Expr): the expression to evaluate
Returns:
Any: the value of the expression
"""
value: Any = expr.accept(self)
if self.reporter is not None:
self.reporter.debug(expr.location, f"Value: {value}")
return value
def get_value(self, name: str) -> Any:
"""Get the value of a variable in the current scope
Args:
name (str): the name of the variable
Raises:
KeyError: if the variable is not defined
Returns:
Any: the value of the variable
"""
scope: dict[str, Any] = self.scopes[-1]
return scope[name]
def set_value(self, name: str, value: Any, force_declare: bool = False):
"""Set the value of a variable
If `force_declare` is `False`, this function first tries to find the
closest scope in which the variable is defined and assign the value in
that scope, if it can find one.
If `force_declare` is `True` or if the variable is not defined in any
scope, it is declare and assigned in the current scope
Args:
name (str): the name of the variable
value (Any): the value of the variable
force_declare (bool, optional): if `False` and the variable is
defined in a scope, the value is assigned in that scope (the
closest if there are multiple declarations). Defaults to False.
"""
if not force_declare:
for scope in reversed(self.scopes):
if name in scope:
scope[name] = value
return
self.scopes[-1][name] = value
def visit_logical_expr(self, expr: m.LogicalExpr) -> Any:
def left():
return self.evaluate(expr.left)
def right():
return self.evaluate(expr.right)
match expr.operator.type:
case TokenType.AND:
return left() and right()
case _:
raise NotImplementedError
def visit_binary_expr(self, expr: m.BinaryExpr) -> Any:
left: Any = self.evaluate(expr.left)
right: Any = self.evaluate(expr.right)
match expr.operator.type:
case TokenType.PLUS:
return left + right
case TokenType.MINUS:
return left - right
case TokenType.STAR:
return left * right
case TokenType.SLASH:
return left / right
case TokenType.GREATER:
return left > right
case TokenType.GREATER_EQUAL:
return left >= right
case TokenType.LESS:
return left < right
case TokenType.LESS_EQUAL:
return left <= right
case TokenType.EQUAL_EQUAL:
return left == right
case TokenType.BANG_EQUAL:
return left != right
case _:
raise NotImplementedError
def visit_unary_expr(self, expr: m.UnaryExpr) -> Any:
right: Any = self.evaluate(expr.right)
match expr.operator.type:
case TokenType.PLUS:
return +right
case TokenType.MINUS:
return -right
case TokenType.BANG:
return not right
case _:
raise NotImplementedError
def visit_call_expr(self, expr: m.CallExpr) -> Any:
callee: Any = self.evaluate(expr.callee)
args: list[Any] = [self.evaluate(arg) for arg in expr.arguments]
kwargs: dict[str, Any] = {
name: self.evaluate(arg) for name, arg in expr.keywords.items()
}
match callee:
case Predicate():
return self._evaluate_predicate(expr.location, callee, args, kwargs)
case _ if callable(callee):
return callee(*args, **kwargs)
case _:
return NotImplementedError
def visit_get_expr(self, expr: m.GetExpr) -> Any:
obj: Any = self.evaluate(expr.expr)
return getattr(obj, expr.name.lexeme)
def visit_variable_expr(self, expr: m.VariableExpr) -> Any:
name: str = expr.name.lexeme
for scope in reversed(self.scopes):
if name in scope:
return scope[name]
predicate: Optional[Predicate] = self.types.lookup_predicate(name)
if predicate is not None:
if predicate.alias:
return self.evaluate(predicate.body)
return predicate
glob: Optional[Callable] = self.preamble.get_py_func(name)
if glob is not None:
return glob
raise NameError(f"Unknown variable '{name}'")
def visit_grouping_expr(self, expr: m.GroupingExpr) -> Any:
return self.evaluate(expr.expr)
def visit_literal_expr(self, expr: m.LiteralExpr) -> Any:
return expr.value
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> Any:
return self.get_value("_")
def _evaluate_predicate(
self,
location: Location,
predicate: Predicate,
args: list[Any],
kwargs: dict[str, Any],
) -> Any:
"""Evaluate a predicate function call
Args:
predicate (Predicate): the predicate to evaluate
args (list[Any]): a list of positional arguments
kwargs (dict[str, Any]): a map of keyword arguments
Returns:
Any: the value returned by the predicate call
"""
res: Any = None
if isinstance(predicate, PartialPredicate):
self.scopes.append(predicate.scope)
else:
self.scopes.append({})
match predicate.type:
case Function(returns=Function() as inner):
self._map_args(location, predicate.type, args, kwargs)
res = PartialPredicate(
type=inner,
body=predicate.body,
alias=False,
scope=self.scopes[-1],
)
case Function():
self._map_args(location, predicate.type, args, kwargs)
res = self.evaluate(predicate.body)
case _:
raise NotImplementedError
self.scopes.pop()
return res
def _map_args(
self,
location: Location,
function: Function,
args: list[Any],
kwargs: dict[str, Any],
):
"""Map call arguments to a function's parameters and set their values in context
Each argument is mapped to a parameter of the function, then its value
is set in the context using :func:`set_value` with the parameter's name
Args:
function (Function): the called function
args (list[Any]): a list of positional arguments
kwargs (dict[str, Any]): a map of keyword arguments
"""
positional: list[Function.Parameter] = (
function.params.pos + function.params.mixed
)
keywords: dict[str, Function.Parameter] = {
param.name: param for param in function.params.mixed + function.params.kw
}
for i, arg in enumerate(args):
if i >= len(positional):
if self.reporter is not None:
self.reporter.error(
location,
f"Too many positional arguments, expected at most {len(positional)}, got {len(args)}",
)
break
param: Function.Parameter = positional[i]
self.set_value(param.name, arg)
for name, arg in kwargs.items():
if name not in keywords:
if self.reporter is not None:
self.reporter.error(location, f"Unknown keyword argument '{name}'")
break
param: Function.Parameter = keywords[name]
self.set_value(param.name, arg)

View File

@@ -0,0 +1,240 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.dispatcher import CallResult
from midas.checker.frames.utils import MethodRegistry, method
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
Function,
ParamSpec,
Type,
UnknownType,
)
if TYPE_CHECKING:
from midas.checker.python import TypedExpr
@dataclass(frozen=True, kw_only=True)
class Call:
"""A column group-by method call, implements :class:`utils.MethodCall`"""
location: Location
call_expr: p.Expr
groupby: ColumnGroupBy
groupby_expr: p.Expr
positional: list[TypedExpr]
keywords: dict[str, TypedExpr]
@property
def subject(self) -> TypedExpr:
return (self.groupby_expr, self.groupby)
class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
"""The method registry for column group-by types"""
NAMED_ARGS: dict[str, str] = {
"numeric_only": "bool",
"skipna": "bool",
"engine": "str",
"engine_kwargs": "dict",
}
def _aggregate(
self,
call: Call,
method: str,
params: list[str | tuple[str, str, bool]] = [],
) -> Type:
"""Compute the result type of an aggregate method call
Args:
call (Call): the call object
method (str): the method name to delegate on :class:`Column`
params (list[str | tuple[str, str, bool], optional): a list of extra
mixed parameters. The list can contain strings to include
parameters predefined in `NAMED_ARGS`, or tuples containing the
parameter's name, type and required flag. Defaults to [].
Returns:
Type: the result type
"""
real_params: list[Function.Parameter] = []
for i, param in enumerate(params):
match param:
case str() as name:
param = Function.Parameter(
pos=i,
name=name,
type=self.types.get_type(self.NAMED_ARGS[name]),
required=False,
)
case (name, type, required):
param = Function.Parameter(
pos=i,
name=name,
type=self.types.get_type(type),
required=required,
)
real_params.append(param)
# TODO: maybe better to filter arguments and pass some, in case the
# return type depends on them
returns: Type = self.typer.call_method(
location=call.location,
call_expr=call.call_expr,
obj=(call.groupby_expr, call.groupby.column),
method_name=method,
positional=[],
keywords={},
)
if not isinstance(returns, ColumnType):
returns = ColumnType(type=UnknownType())
signature = Function(
params=ParamSpec(mixed=real_params),
returns=returns,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def kurt(self, call: Call) -> Type:
return self._aggregate(
call,
"kurt",
["skipna", "numeric_only"],
)
@method()
def max(self, call: Call) -> Type:
return self._aggregate(
call,
"max",
[
"numeric_only",
(
"min_count",
"int",
False,
),
"skipna",
"engine",
"engine_kwargs",
],
)
@method()
def mean(self, call: Call) -> Type:
return self._aggregate(
call,
"mean",
["numeric_only", "skipna", "engine", "engine_kwargs"],
)
@method()
def median(self, call: Call) -> Type:
return self._aggregate(
call,
"median",
["numeric_only", "skipna"],
)
@method()
def min(self, call: Call) -> Type:
return self._aggregate(
call,
"min",
[
"numeric_only",
(
"min_count",
"int",
False,
),
"skipna",
"engine",
"engine_kwargs",
],
)
@method()
def prod(self, call: Call) -> Type:
return self._aggregate(
call,
"prod",
[
"numeric_only",
(
"min_count",
"int",
False,
),
"skipna",
],
)
@method()
def std(self, call: Call) -> Type:
return self._aggregate(
call,
"std",
[
(
"ddof",
"int",
False,
),
"engine",
"engine_kwargs",
"numeric_only",
"skipna",
],
)
@method()
def sum(self, call: Call) -> Type:
return self._aggregate(
call,
"sum",
[
"numeric_only",
(
"min_count",
"int",
False,
),
"skipna",
"engine",
"engine_kwargs",
],
)
@method()
def var(self, call: Call) -> Type:
return self._aggregate(
call,
"var",
[
(
"var",
"int",
False,
),
"engine",
"engine_kwargs",
"numeric_only",
"skipna",
],
)

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.dispatcher import CallResult
from midas.checker.frames.column_groupby_methods import Call as GroupByCall
from midas.checker.frames.column_groupby_methods import ColumnGroupByMethodRegistry
from midas.checker.frames.column_methods import Call, ColumnMethodRegistry
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
Function,
OverloadedFunction,
ParamSpec,
Type,
)
if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr
class ColumnManager:
"""Helper class to handle methods and subscripts on column types"""
def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer
self.method_resolver: ColumnMethodRegistry = ColumnMethodRegistry(self.typer)
self.groupby_method_resolver: ColumnGroupByMethodRegistry = (
ColumnGroupByMethodRegistry(self.typer)
)
def get(
self,
reporter: FileReporter,
location: Location,
column: ColumnType,
index: TypedExpr,
) -> Type:
"""Compute the type of a subscript access
Args:
reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the subscript's location
column (DataFrameType): the column type
index (TypedExpr): the index
Returns:
Type: the resulting type
"""
single = Function(
params=ParamSpec(
pos=[
Function.Parameter(
pos=0,
name="index",
type=self.typer.types.get_type("int"),
required=True,
)
]
),
returns=column.type,
)
slice = Function(
params=ParamSpec(
pos=[
Function.Parameter(
pos=0,
name="slice",
type=self.typer.types.get_type("slice"),
required=True,
)
]
),
returns=column,
)
overload = OverloadedFunction(overloads=[single, slice])
result: CallResult = self.typer.dispatcher.get_result(
location=location,
callee=overload,
positional=[index],
keywords={},
)
return result.result
def call(
self,
method: str,
location: Location,
call_expr: p.Expr,
column: ColumnType,
column_expr: p.Expr,
positional: list[TypedExpr],
keywords: dict[str, TypedExpr],
) -> Type:
"""Compute the result type of a column's method call
Args:
method (str): the method name
location (Location): the call's location
call_expr (p.Expr): the call expression
column (ColumnType): the column type
column_expr (p.Expr): the column expression
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
Returns:
Type: the result type
"""
call: Call = Call(
location=location,
call_expr=call_expr,
column=column,
column_expr=column_expr,
positional=positional,
keywords=keywords,
)
return self.method_resolver.call(method, call)
def groupby_call(
self,
method: str,
location: Location,
call_expr: p.Expr,
groupby: ColumnGroupBy,
groupby_expr: p.Expr,
positional: list[TypedExpr],
keywords: dict[str, TypedExpr],
) -> Type:
"""Compute the result type of a column group-by's method call
Args:
method (str): the method name
location (Location): the call's location
call_expr (p.Expr): the call expression
groupby (ColumnGroupBy): the column group-by object
groupby_expr (p.Expr): the column group-by expression
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
Returns:
Type: the result type
"""
call: GroupByCall = GroupByCall(
location=location,
call_expr=call_expr,
groupby=groupby,
groupby_expr=groupby_expr,
positional=positional,
keywords=keywords,
)
return self.groupby_method_resolver.call(method, call)
def get_attribute(self, column: ColumnType, name: str) -> Optional[Type]:
"""Get the type of a column's attribute
Args:
column (ColumnType): the column type
name (str): the attribute's name
Returns:
Optional[Type]: the attribute's type, or `None` if it doesn't exist
"""
types: TypesRegistry = self.typer.types
match name:
case "ndim" | "size":
return types.get_type("int")
case "shape":
return types.tuple_of("int")
case "T":
return column
case _:
return None

View File

@@ -0,0 +1,683 @@
from __future__ import annotations
import ast
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Optional, TypeAlias, Union
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.dispatcher import CallResult
from midas.checker.frames.utils import MethodRegistry, method
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
Function,
OverloadedFunction,
ParamSpec,
TopType,
Type,
UnitType,
UnknownType,
unfold_type,
)
if TYPE_CHECKING:
from midas.checker.python import TypedExpr
FormulaOperand: TypeAlias = Union["Formula", str, Type]
"""
A operand type in a :data:`Formula`
Must be one of the following:
- a nested formula
- a type name (a string)
- a type instance
"""
Formula: TypeAlias = Union[Type, tuple[FormulaOperand, str, FormulaOperand]]
"""
A formula to compute the output type of a function
Must be either a type, or a tuple containing:
- a left operand
- an operation / method name (e.g. `"__add__"`)
- a right operand
For example, to compute the result of a `mean` function, given the input type `T`:
```python
mean_formula = ((T, "__add__", T), "__truediv__", "int")
```
"""
@dataclass(frozen=True, kw_only=True)
class Call:
"""A column method call, implements :class:`utils.MethodCall`"""
location: Location
call_expr: p.Expr
column: ColumnType
column_expr: p.Expr
positional: list[TypedExpr]
keywords: dict[str, TypedExpr]
@property
def subject(self) -> TypedExpr:
return (self.column_expr, self.column)
class ColumnMethodRegistry(MethodRegistry[Call]):
"""The method registry for column types"""
def _resolve_formula_operand(self, call: Call, operand: FormulaOperand) -> Type:
"""Resolve the type of a formula operand
See :data:`FormulaOperand` for more information on the accepted format
Args:
call (Call): the call that triggered this resolution
operand (FormulaOperand): the formula operand
Returns:
Type: the type of the operand
"""
match operand:
case str():
return self.types.get_type(operand)
case (_, _, _):
return self._resolve_formula_type(call, operand)
case _:
return operand
def _resolve_formula_type(self, call: Call, formula: Formula) -> Type:
"""Resolve the return type of a formula
See :data:`Formula` for more information on the accepted format
Args:
call (Call): the call that triggered this resolution
formula (Formula): the formula to evaluate
Returns:
Type: the return type of the formula
"""
if not isinstance(formula, tuple):
return formula
op1, operator, op2 = formula
op1_type: Type = self._resolve_formula_operand(call, op1)
op2_type: Type = self._resolve_formula_operand(call, op2)
return self.typer.result_of_binary_op(
location=call.location,
expr=call.call_expr,
left=(call.column_expr, op1_type),
right=(call.column_expr, op2_type),
method=operator,
)
def _simple_call(self, call: Call, function: Type) -> Type:
"""Get the result of calling a simple method
This function is a simple wrapper around :func:`dispatcher.CallDispatcher.get_result`
Args:
call (Call): the call that triggered this resolution
function (Type): the function type
Returns:
Type: the return type
"""
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=function,
positional=call.positional,
keywords=call.keywords,
)
return result.result
def _element_binary_op(self, call: Call, method: str) -> tuple[Type, bool]:
"""Compute the result of an element-wise binary operation
This function delegates to the inner types for computing the resulting
type.
Args:
call (Call): the call that triggered this resolution
method (str): the method name
Returns:
tuple[Type, bool]: the resulting type and a boolean indicating
whether the operand is a column
"""
if len(call.positional) == 0:
return UnknownType(), False
col_type1: Type = call.column.type
operand: TypedExpr = call.positional[0]
unfolded_operand: Type = unfold_type(operand[1])
col_type2: Type
column_operand: bool = isinstance(unfolded_operand, ColumnType)
# Operand is a column -> get the inner type
if column_operand:
col_type2 = unfolded_operand.type
# Otherwise use the operand type itself
else:
col_type2 = operand[1]
new_inner_type = self.typer.result_of_binary_op(
location=call.location,
expr=call.call_expr,
left=(call.column_expr, col_type1),
right=(operand[0], col_type2),
method=method,
)
return ColumnType(type=new_inner_type), column_operand
def _element_wise(self, call: Call, method: str) -> Type:
"""Compute the result of an element-wise method call
If the call is valid, this method also generates an assertion to check
that both operands have the same length at runtime
Args:
call (Call): the call object
method (str): the method's name
Returns:
Type: the result type
"""
# Build signature with new column type and generic operand
returns, column_operand = self._element_binary_op(call, method)
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="other",
type=TopType(),
required=True,
),
],
),
returns=returns,
)
# Map arguments and compute result type
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
if result.is_valid and column_operand:
self._assert_same_length(
call.call_expr, call.column_expr, call.positional[0][0]
)
return result.result
@method()
def copy(self, call: Call) -> Type:
return self._simple_call(
call,
Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="deep",
type=self.types.get_type("bool"),
required=False,
)
]
),
returns=call.column,
),
)
@method()
def info(self, call: Call) -> Type:
def make_overload(memory_usage: Type, required: bool = False) -> Type:
return Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="verbose",
type=self.types.get_type("bool"),
required=False,
),
Function.Parameter(
pos=1,
name="buf",
type=TopType(),
required=False,
),
Function.Parameter(
pos=2,
name="max_cols",
type=self.types.get_type("int"),
required=False,
),
Function.Parameter(
pos=3,
name="memory_usage",
type=memory_usage,
required=required,
),
Function.Parameter(
pos=4,
name="show_counts",
type=self.types.get_type("bool"),
required=False,
),
]
),
returns=UnitType(),
)
return self._simple_call(
call,
OverloadedFunction(
overloads=[
make_overload(self.types.get_type("bool"), False),
make_overload(self.types.get_type("str"), True),
],
),
)
@method("add", "__add__")
def add(self, call: Call) -> Type:
return self._element_wise(call, "__add__")
@method("sub", "__sub__")
def sub(self, call: Call) -> Type:
return self._element_wise(call, "__sub__")
@method("mul", "__mul__")
def mul(self, call: Call) -> Type:
return self._element_wise(call, "__mul__")
@method("div", "truediv", "__truediv__")
def truediv(self, call: Call) -> Type:
return self._element_wise(call, "__truediv__")
@method("floordiv", "__floordiv__")
def floordiv(self, call: Call) -> Type:
return self._element_wise(call, "__floordiv__")
@method("mod", "__mod__")
def mod(self, call: Call) -> Type:
return self._element_wise(call, "__mod__")
@method("pow", "__pow__")
def pow(self, call: Call) -> Type:
return self._element_wise(call, "__pow__")
@method("lt", "__lt__")
def lt(self, call: Call) -> Type:
return self._element_wise(call, "__lt__")
@method("gt", "__gt__")
def gt(self, call: Call) -> Type:
return self._element_wise(call, "__gt__")
@method("le", "__le__")
def le(self, call: Call) -> Type:
return self._element_wise(call, "__le__")
@method("ge", "__ge__")
def ge(self, call: Call) -> Type:
return self._element_wise(call, "__ge__")
@method("ne", "__ne__")
def ne(self, call: Call) -> Type:
return self._element_wise(call, "__ne__")
@method("eq", "__eq__")
def eq(self, call: Call) -> Type:
return self._element_wise(call, "__eq__")
def _aggregate(
self,
call: Call,
kwargs: list[Function.Parameter] = [],
*,
formula: Optional[Callable[[Type], Formula]] = None,
) -> Type:
"""Compute the result type of an aggregate method call
Args:
call (Call): the call object
kwargs (list[Function.Parameter], optional): a list of extra
keyword-only parameters. Defaults to [].
formula (Callable[[Type], Formula], optional): optional formula
builder function to compute the return type. If set, the function
should accept the inner column type and return a formula.
If `None`, the result is typed as `Column[Any]`. Defaults to None.
Returns:
Type: the result type
"""
returns: Type = ColumnType(type=TopType())
if formula:
returns = ColumnType(
type=self._resolve_formula_type(
call,
formula(call.column.type),
)
)
signature = Function(
params=ParamSpec(
kw=[
Function.Parameter(
pos=0,
name="axis",
type=TopType(),
required=False,
),
*kwargs,
],
),
returns=returns,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method("kurtosis", "kurt")
def kurtosis(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def max(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: t)
@method()
def mean(self, call: Call) -> Type:
return self._aggregate(
call, formula=lambda t: ((t, "__add__", t), "__truediv__", "int")
)
@method()
def median(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: t)
@method()
def min(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: t)
@method()
def mode(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: t)
@method("product", "prod")
def product(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: (t, "__mul__", t))
@method()
def std(self, call: Call) -> Type:
return self._aggregate(
call,
[
Function.Parameter(
pos=1,
name="ddof",
type=self.types.get_type("int"),
required=False,
)
],
)
@method()
def sum(self, call: Call) -> Type:
return self._aggregate(call, formula=lambda t: (t, "__add__", t))
@method()
def var(self, call: Call) -> Type:
return self._aggregate(
call,
[
Function.Parameter(
pos=1,
name="var",
type=self.types.get_type("int"),
required=False,
)
],
)
@method()
def head(self, call: Call) -> Type:
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="n",
type=self.types.get_type("int"),
required=False,
),
],
),
returns=call.column,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def tail(self, call: Call) -> Type:
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="n",
type=self.types.get_type("int"),
required=False,
),
],
),
returns=call.column,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def sort_values(self, call: Call) -> Type:
str_ = self.types.get_type("str")
bool_ = self.types.get_type("bool")
def make_overload(ascending: Type) -> Function:
return Function(
params=ParamSpec(
kw=[
Function.Parameter(
pos=0,
name="axis",
type=TopType(),
required=False,
),
Function.Parameter(
pos=1,
name="ascending",
type=ascending,
required=False,
),
Function.Parameter(
pos=2,
name="inplace",
type=bool_,
required=False,
unsupported=True,
),
Function.Parameter(
pos=3,
name="kind",
type=str_,
required=False,
),
Function.Parameter(
pos=4,
name="na_position",
type=str_,
required=False,
),
Function.Parameter(
pos=5,
name="ignore_index",
type=bool_,
required=False,
),
Function.Parameter(
pos=6,
name="key",
type=TopType(),
required=False,
),
],
),
returns=call.column,
)
list_of = self.types.list_of
overloads: list[Type] = [
make_overload(bool_),
make_overload(bool_),
make_overload(list_of(bool_)),
make_overload(list_of(bool_)),
]
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=OverloadedFunction(overloads=overloads),
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def groupby(self, call: Call) -> Type:
bool_: Type = self.types.get_type("bool")
function: Function = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="by",
type=TopType(),
required=False,
),
Function.Parameter(
pos=1,
name="level",
type=TopType(),
required=False,
),
],
kw=[
Function.Parameter(
pos=i + 2,
name=name,
type=bool_,
required=False,
)
for i, name in enumerate(
["as_index", "sort", "group_keys", "observed", "dropna"]
)
],
),
returns=ColumnGroupBy(column=call.column),
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=function,
positional=call.positional,
keywords=call.keywords,
)
return result.result
def _assert_same_length(self, call_expr: p.Expr, column1: p.Expr, column2: p.Expr):
"""Generate an assertion to check that two columns have the same length
Args:
call_expr (p.Expr): the call expression, to insert the assertion
at the right place
column1 (p.Expr): the first column expression
column2 (p.Expr): the second column expression
"""
func_name: str = "__midas_column_same_length__"
# Efficiently compute length
# https://stackoverflow.com/a/15943975/11109181
def len_of_col(col: ast.expr) -> ast.expr:
return ast.Call(
func=ast.Name(id="len"),
args=[
ast.Attribute(
value=col,
attr="index",
)
],
keywords=[],
)
self.assertions.define(
func_name,
ast.FunctionDef(
name=func_name,
args=ast.arguments(
posonlyargs=[],
args=[
ast.arg(arg="column1"),
ast.arg(arg="column2"),
],
kwonlyargs=[],
defaults=[],
kw_defaults=[],
),
body=[
ast.Return(
value=ast.Compare(
left=len_of_col(ast.Name(id="column1")),
ops=[ast.Eq()],
comparators=[
len_of_col(ast.Name(id="column2")),
],
)
)
],
decorator_list=[],
),
)
self.assertions.add(
bound_expr=call_expr,
inputs=[column1, column2],
builder=lambda c1, c2: ast.Call(
func=ast.Name(id=func_name),
args=[c1, c2],
keywords=[],
),
message="Columns must have the same length",
)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.frames.utils import MethodRegistry, method
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
DataFrameType,
FrameGroupBy,
Type,
UnknownType,
)
if TYPE_CHECKING:
from midas.checker.python import TypedExpr
@dataclass(frozen=True, kw_only=True)
class Call:
"""A frame group-by method call, implements :class:`utils.MethodCall`"""
location: Location
call_expr: p.Expr
groupby: FrameGroupBy
groupby_expr: p.Expr
positional: list[TypedExpr]
keywords: dict[str, TypedExpr]
@property
def subject(self) -> TypedExpr:
return (self.groupby_expr, self.groupby)
class FrameGroupByMethodRegistry(MethodRegistry[Call]):
"""The method registry for frame group-by types"""
def _aggregate(self, call: Call, method: str) -> Type:
"""Compute the result type of an aggregate method call
Args:
call (Call): the call object
method (str): the method's name
Returns:
Type: the result type
"""
new_columns: list[DataFrameType.Column] = []
for column in call.groupby.frame.columns:
with self.reporter.with_context(f"in column '{column.name}'"):
column_groupby: ColumnGroupBy = ColumnGroupBy(column=column.type)
result_type: Type = self.typer.call_method(
location=call.location,
call_expr=call.call_expr,
obj=(call.groupby_expr, column_groupby),
method_name=method,
positional=call.positional,
keywords=call.keywords,
)
if not isinstance(result_type, ColumnType):
result_type = ColumnType(type=UnknownType())
new_columns.append(
DataFrameType.Column(
index=column.index,
name=column.name,
type=result_type,
)
)
return DataFrameType(columns=new_columns)
@method()
def kurt(self, call: Call) -> Type:
return self._aggregate(call, "kurt")
@method()
def max(self, call: Call) -> Type:
return self._aggregate(call, "max")
@method()
def mean(self, call: Call) -> Type:
return self._aggregate(call, "mean")
@method()
def median(self, call: Call) -> Type:
return self._aggregate(call, "median")
@method()
def min(self, call: Call) -> Type:
return self._aggregate(call, "min")
@method()
def prod(self, call: Call) -> Type:
return self._aggregate(call, "prod")
@method()
def std(self, call: Call) -> Type:
return self._aggregate(call, "std")
@method()
def sum(self, call: Call) -> Type:
return self._aggregate(call, "sum")
@method()
def var(self, call: Call) -> Type:
return self._aggregate(call, "var")

View File

@@ -0,0 +1,386 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, TypeGuard, cast
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.frames.frame_groupby_methods import Call as GroupByCall
from midas.checker.frames.frame_groupby_methods import FrameGroupByMethodRegistry
from midas.checker.frames.frame_methods import Call, FrameMethodRegistry
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
DataFrameType,
FrameGroupBy,
TupleType,
Type,
UnknownType,
)
if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr
def is_list_of_literals(exprs: list[p.Expr]) -> TypeGuard[list[p.LiteralExpr]]:
"""Check whether the given list only contains literal expressions
Args:
exprs (list[p.Expr]): the list to check
Returns:
TypeGuard[list[p.LiteralExpr]]: whether `exprs` only contains literal expressions
"""
return all(isinstance(expr, p.LiteralExpr) for expr in exprs)
class FrameManager:
"""Helper class to handle methods and subscripts on frame types"""
def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer
self.method_resolver: FrameMethodRegistry = FrameMethodRegistry(self.typer)
self.groupby_method_resolver: FrameGroupByMethodRegistry = (
FrameGroupByMethodRegistry(self.typer)
)
def assign(
self,
reporter: FileReporter,
location: Location,
frame: DataFrameType,
index: p.Expr,
value_type: Type,
) -> Type:
"""Compute the new frame type after assigning a value to an index
Args:
reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the assignment's location
frame (DataFrameType): the frame type
index (p.Expr): the index expression
value_type (Type): the assigned value
Returns:
Type: the resulting frame type
"""
match index:
case p.LiteralExpr(value=str() as name):
return self.assign_column(reporter, location, frame, name, value_type)
case p.ListExpr(items=indices) if is_list_of_literals(indices) and all(
isinstance(index.value, str) for index in indices
):
names: list[str] = [cast(str, index.value) for index in indices]
if not isinstance(value_type, TupleType):
reporter.error(
location,
f"Cannot assign {type} to dataframe columns. Must be a tuple of columns",
)
return UnknownType()
if len(names) != len(value_type.items):
reporter.error(
location,
f"Wrong number of columns. Cannot assign {len(value_type.items)} to {len(names)} targets",
)
return UnknownType()
new_frame: Type = frame
for name, value in zip(names, value_type.items):
new_frame = self.assign_column(
reporter,
location,
new_frame,
name,
value,
)
if not isinstance(new_frame, DataFrameType):
return new_frame
return new_frame
case _:
reporter.error(
location, f"Invalid index type {index} on {frame} (assignment)"
)
return UnknownType()
def assign_column(
self,
reporter: FileReporter,
location: Location,
frame: DataFrameType,
name: str,
type: Type,
) -> Type:
"""Compute the new frame type after assigning a single value to a column
Args:
reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the assignment's location
frame (DataFrameType): the frame type
name (str): the column name
type (Type): the assigned value type
Returns:
Type: the resulting frame type
"""
if not isinstance(type, ColumnType):
reporter.error(
location,
f"Cannot assign {type} to dataframe column. Must be a ColumnType",
)
return self._set_column(frame, name, ColumnType(type=UnknownType()))
return self._set_column(frame, name, type)
def get(
self,
reporter: FileReporter,
location: Location,
frame: DataFrameType,
index: p.Expr,
) -> Type:
"""Compute the type of a subscript access
Args:
reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the subscript's location
frame (DataFrameType): the frame type
index (p.Expr): the index expression
Returns:
Type: the resulting type
"""
match index:
case p.LiteralExpr(value=str() as name):
column: Optional[ColumnType] = FrameManager._get_column(frame, name)
if column is None:
reporter.error(location, f"Unknown column '{name}' on {frame}")
return UnknownType()
return column
case p.ListExpr(items=indices) if is_list_of_literals(indices) and all(
isinstance(index.value, str) for index in indices
):
names: list[str] = [cast(str, index.value) for index in indices]
columns: list[ColumnType] = []
for name in names:
column: Optional[ColumnType] = FrameManager._get_column(frame, name)
if column is None:
reporter.error(location, f"Unknown column '{name}' on {frame}")
return UnknownType()
columns.append(column)
return TupleType(items=tuple(columns))
case _:
reporter.error(
location, f"Invalid index type {index} on {frame} (access)"
)
return UnknownType()
def groupby_get(
self,
reporter: FileReporter,
location: Location,
groupby: FrameGroupBy,
index: p.Expr,
) -> Type:
"""Compute the type of a subscript access on a frame group-by object
Args:
reporter (FileReporter): the file reporter to use for diagnostics
location (Location): the subscript's location
groupby (FrameGroupBy): the group-by object
index (p.Expr): the index expression
Returns:
Type: the resulting type
"""
result: Type = self.get(reporter, location, groupby.frame, index)
match result:
case ColumnType():
result = ColumnGroupBy(column=result)
case TupleType(items=columns):
result = TupleType(
items=tuple(
ColumnGroupBy(column=cast(ColumnType, column))
for column in columns
)
)
return result
@classmethod
def _set_column(
cls, frame: DataFrameType, name: str, column: ColumnType
) -> DataFrameType:
"""Set a frame's column to the given type
Args:
frame (DataFrameType): the frame type
name (str): the column's name
column (ColumnType): the new column's type
Returns:
DataFrameType: the new frame type
"""
new_columns: list[DataFrameType.Column] = []
index: int = len(frame.columns)
replace: bool = False
for i, col in enumerate(frame.columns):
if col.name == name:
index = i
replace = True
# TODO: check column type here to prevent changing it
new_columns.append(col)
new_col: DataFrameType.Column = DataFrameType.Column(
index=index,
name=name,
type=column,
)
if replace:
new_columns[index] = new_col
else:
new_columns.append(new_col)
return DataFrameType(columns=new_columns)
@classmethod
def _set_columns(
cls, frame: DataFrameType, names: list[str], columns: list[ColumnType]
) -> DataFrameType:
"""Set multiple columns of a frame to the given types
Args:
frame (DataFrameType): the frame type
names (list[str]): the column names
columns (list[ColumnType]): the new column types
Returns:
DataFrameType: the new frame type
"""
for name, col in zip(names, columns):
frame = cls._set_column(frame, name, col)
return frame
@classmethod
def _get_column(cls, frame: DataFrameType, name: str) -> Optional[ColumnType]:
"""Get a column's type by name
Args:
frame (DataFrameType): the frame type
name (str): the column's name
Returns:
Optional[ColumnType]: the column's type, or `None` if it doesn't exist
"""
for col in frame.columns:
if col.name == name:
return col.type
return None
@classmethod
def _get_columns(
cls, frame: DataFrameType, names: list[str]
) -> list[Optional[ColumnType]]:
"""Get multiple column types by name
Args:
frame (DataFrameType): the frame type
names (list[str]): the column names
Returns:
list[Optional[ColumnType]]: the column types (see :func:`_get_column`)
"""
return [cls._get_column(frame, name) for name in names]
def call(
self,
method: str,
location: Location,
call_expr: p.Expr,
frame: DataFrameType,
frame_expr: p.Expr,
positional: list[TypedExpr],
keywords: dict[str, TypedExpr],
) -> Type:
"""Compute the result type of a frame's method call
Args:
method (str): the method name
location (Location): the call's location
call_expr (p.Expr): the call expression
frame (DataFrameType): the frame type
frame_expr (p.Expr): the frame expression
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
Returns:
Type: the result type
"""
call: Call = Call(
location=location,
call_expr=call_expr,
frame=frame,
frame_expr=frame_expr,
positional=positional,
keywords=keywords,
)
return self.method_resolver.call(method, call)
def groupby_call(
self,
method: str,
location: Location,
call_expr: p.Expr,
groupby: FrameGroupBy,
groupby_expr: p.Expr,
positional: list[TypedExpr],
keywords: dict[str, TypedExpr],
) -> Type:
"""Compute the result type of a frame group-by's method call
Args:
method (str): the method name
location (Location): the call's location
call_expr (p.Expr): the call expression
groupby (FrameGroupBy): the frame group-by object
groupby_expr (p.Expr): the frame group-by expression
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
Returns:
Type: the result type
"""
call: GroupByCall = GroupByCall(
location=location,
call_expr=call_expr,
groupby=groupby,
groupby_expr=groupby_expr,
positional=positional,
keywords=keywords,
)
return self.groupby_method_resolver.call(method, call)
def get_attribute(self, frame: DataFrameType, name: str) -> Optional[Type]:
"""Get the type of a frame's attribute
Args:
frame (DataFrameType): the frame type
name (str): the attribute's name
Returns:
Optional[Type]: the attribute's type, or `None` if it doesn't exist
"""
types: TypesRegistry = self.typer.types
match name:
case "ndim" | "size":
return types.get_type("int")
case "shape":
return types.tuple_of("int", "int")
case _:
return None

View File

@@ -0,0 +1,757 @@
from __future__ import annotations
import ast
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.dispatcher import CallResult
from midas.checker.frames.utils import MethodRegistry, method
from midas.checker.types import (
ColumnType,
DataFrameType,
FrameGroupBy,
Function,
OverloadedFunction,
ParamSpec,
TopType,
Type,
UnitType,
UnknownType,
unfold_type,
)
if TYPE_CHECKING:
from midas.checker.python import TypedExpr
@dataclass(frozen=True, kw_only=True)
class Call:
"""A frame method call, implements :class:`utils.MethodCall`"""
location: Location
call_expr: p.Expr
frame: DataFrameType
frame_expr: p.Expr
positional: list[TypedExpr]
keywords: dict[str, TypedExpr]
@property
def subject(self) -> TypedExpr:
return (self.frame_expr, self.frame)
class FrameMethodRegistry(MethodRegistry[Call]):
"""The method registry for frame types"""
def _simple_call(self, call: Call, function: Type) -> Type:
"""Get the result of calling a simple method
This function is a simple wrapper around :func:`dispatcher.CallDispatcher.get_result`
Args:
call (Call): the call that triggered this resolution
function (Type): the function type
Returns:
Type: the return type
"""
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=function,
positional=call.positional,
keywords=call.keywords,
)
return result.result
def _get_method_result(
self,
call: Call,
column1: ColumnType,
column2: ColumnType,
method: str,
) -> ColumnType:
"""Get the result of calling a method on a column, passing a second
This function delegates to the main typer the resolution of the method
member, as well as computing the result type. Because we don't have any
AST expression for the individual columns, the frame expressions are
used instead.
Args:
call (Call): the call that triggered this resolution
column1 (ColumnType): the first column, i.e. left operand
column2 (ColumnType): the second column, i.e. right operand
method (str): the method name
Returns:
ColumnType: the resulting column.
If the operation is invalid / doesn't exist,
`ColumnType(type=UnknownType())` is returned
"""
result: Type = self.typer.result_of_binary_op(
location=call.location,
expr=call.call_expr,
left=(call.frame_expr, column1),
right=(call.positional[0][0], column2),
method=method,
)
if not isinstance(result, ColumnType):
return ColumnType(type=UnknownType())
return result
def _element_binary_op(self, call: Call, method: str) -> tuple[Type, bool]:
"""Compute the result of an element-wise binary operation
This function delegates to the matching columns for computing resulting
types. Any column only present in one of the frames is forwarded as a
generic `ColumnType(type=UnknownType())`. Columns only in the second
frame are append at the end of the schema.
Args:
call (Call): the call that triggered this resolution
method (str): the method name
Returns:
tuple[Type, bool]: the resulting type and a boolean indicating
whether the operand is a frame
"""
if len(call.positional) == 0:
return UnknownType(), False
operand: TypedExpr = call.positional[0]
new_columns: list[DataFrameType.Column] = []
by_name: dict[str, DataFrameType.Column] = {}
frame2: Optional[DataFrameType] = None
# Get map of operand's columns by name, if the operand is a dataframe
unfolded_other: Type = unfold_type(operand[1])
frame_operand: bool = isinstance(unfolded_other, DataFrameType)
if frame_operand:
frame2 = unfolded_other
by_name = {col.name: col for col in frame2.columns if col.name is not None}
# Compute new schema:
# Step 1: for all columns in frame1:
# - if present in frame2 -> delegate operation to columns
# - if not -> add to schema as unknown
in_frame1: set[str] = set()
for column in call.frame.columns:
if column.name is not None:
in_frame1.add(column.name)
col_type1: ColumnType = column.type
col_type: ColumnType = ColumnType(type=UnknownType())
col_type2: Optional[ColumnType] = None
# Operand is a frame -> lookup column with the same name
if frame2 is not None:
if column.name in by_name:
column2 = by_name[column.name]
col_type2 = column2.type
# Operand is not a frame -> scalar operation -> ad-hoc column
else:
col_type2 = ColumnType(type=operand[1])
if col_type2 is not None:
with self.reporter.with_context(f"in column '{column.name}'"):
col_type = self._get_method_result(
call, col_type1, col_type2, method
)
new_column = DataFrameType.Column(
index=column.index,
name=column.name,
type=col_type,
)
new_columns.append(new_column)
# Step 2: for all columns in frame2
# - if not in frame1 -> add to schema as unknown
if frame2 is not None:
for column in frame2.columns:
if column.name in in_frame1:
continue
new_columns.append(
DataFrameType.Column(
index=len(new_columns),
name=column.name,
type=ColumnType(type=UnknownType()),
)
)
return DataFrameType(columns=new_columns), frame_operand
def _element_wise(self, call: Call, method: str) -> Type:
"""Compute the result of an element-wise method call
If the call is valid, this method also generates an assertion to check
that both operands have the same length at runtime
Args:
call (Call): the call object
method (str): the method's name
Returns:
Type: the result type
"""
# TODO: support sequence, Series, dict operand
returns, frame_operand = self._element_binary_op(call, method)
# Build signature with new schema and generic operand
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="other",
type=TopType(),
required=True,
),
],
),
returns=returns,
)
# Map arguments and compute result type
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
if result.is_valid and frame_operand:
self._assert_same_length(
call.call_expr, call.frame_expr, call.positional[0][0]
)
return result.result
@method()
def copy(self, call: Call) -> Type:
return self._simple_call(
call,
Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="deep",
type=self.types.get_type("bool"),
required=False,
)
]
),
returns=call.frame,
),
)
@method()
def info(self, call: Call) -> Type:
def make_overload(memory_usage: Type, required: bool = False) -> Type:
return Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="verbose",
type=self.types.get_type("bool"),
required=False,
),
Function.Parameter(
pos=1,
name="buf",
type=TopType(),
required=False,
),
Function.Parameter(
pos=2,
name="max_cols",
type=self.types.get_type("int"),
required=False,
),
Function.Parameter(
pos=3,
name="memory_usage",
type=memory_usage,
required=required,
),
Function.Parameter(
pos=4,
name="show_counts",
type=self.types.get_type("bool"),
required=False,
),
]
),
returns=UnitType(),
)
return self._simple_call(
call,
OverloadedFunction(
overloads=[
make_overload(self.types.get_type("bool"), False),
make_overload(self.types.get_type("str"), True),
],
),
)
@method("add", "__add__")
def add(self, call: Call) -> Type:
return self._element_wise(call, "__add__")
@method("sub", "__sub__")
def sub(self, call: Call) -> Type:
return self._element_wise(call, "__sub__")
@method("mul", "__mul__")
def mul(self, call: Call) -> Type:
return self._element_wise(call, "__mul__")
@method("div", "truediv", "__truediv__")
def truediv(self, call: Call) -> Type:
return self._element_wise(call, "__truediv__")
@method("floordiv", "__floordiv__")
def floordiv(self, call: Call) -> Type:
return self._element_wise(call, "__floordiv__")
@method("mod", "__mod__")
def mod(self, call: Call) -> Type:
return self._element_wise(call, "__mod__")
@method("pow", "__pow__")
def pow(self, call: Call) -> Type:
return self._element_wise(call, "__pow__")
@method("lt", "__lt__")
def lt(self, call: Call) -> Type:
return self._element_wise(call, "__lt__")
@method("gt", "__gt__")
def gt(self, call: Call) -> Type:
return self._element_wise(call, "__gt__")
@method("le", "__le__")
def le(self, call: Call) -> Type:
return self._element_wise(call, "__le__")
@method("ge", "__ge__")
def ge(self, call: Call) -> Type:
return self._element_wise(call, "__ge__")
@method("ne", "__ne__")
def ne(self, call: Call) -> Type:
return self._element_wise(call, "__ne__")
@method("eq", "__eq__")
def eq(self, call: Call) -> Type:
return self._element_wise(call, "__eq__")
def _aggregate(self, call: Call, kwargs: list[Function.Parameter] = []) -> Type:
"""Compute the result type of an aggregate method call
Args:
call (Call): the call object
kwargs (list[Function.Parameter], optional): a list of extra
keyword-only parameters. Defaults to [].
Returns:
Type: the result type
"""
with_axis = Function(
params=ParamSpec(
kw=[
Function.Parameter(
pos=0,
name="axis",
type=self.types.get_type("int"),
required=False,
),
*kwargs,
],
),
returns=ColumnType(type=TopType()),
)
without_axis = Function(
params=ParamSpec(
kw=[
Function.Parameter(
pos=0,
name="axis",
type=self.types.get_type("None"),
required=True,
),
*kwargs,
],
),
returns=TopType(),
)
overload = OverloadedFunction(
overloads=[
with_axis,
without_axis,
]
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=overload,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method("kurtosis", "kurt")
def kurtosis(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def max(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def mean(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def median(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def min(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def mode(self, call: Call) -> Type:
return self._aggregate(call)
@method("product", "prod")
def product(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def std(self, call: Call) -> Type:
return self._aggregate(
call,
[
Function.Parameter(
pos=1,
name="ddof",
type=self.types.get_type("int"),
required=False,
)
],
)
@method()
def sum(self, call: Call) -> Type:
return self._aggregate(call)
@method()
def var(self, call: Call) -> Type:
return self._aggregate(
call,
[
Function.Parameter(
pos=1,
name="var",
type=self.types.get_type("int"),
required=False,
)
],
)
@method()
def head(self, call: Call) -> Type:
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="n",
type=self.types.get_type("int"),
required=False,
),
],
),
returns=call.frame,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def tail(self, call: Call) -> Type:
signature = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="n",
type=self.types.get_type("int"),
required=False,
),
],
),
returns=call.frame,
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=signature,
positional=call.positional,
keywords=call.keywords,
)
return result.result
@method()
def sort_values(self, call: Call) -> Type:
str_ = self.types.get_type("str")
bool_ = self.types.get_type("bool")
def make_overload(by: Type, ascending: Type) -> Function:
return Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="by",
type=by,
required=True,
),
],
kw=[
Function.Parameter(
pos=1,
name="axis",
type=TopType(),
required=False,
),
Function.Parameter(
pos=2,
name="ascending",
type=ascending,
required=False,
),
Function.Parameter(
pos=3,
name="inplace",
type=bool_,
required=False,
unsupported=True,
),
Function.Parameter(
pos=4,
name="kind",
type=str_,
required=False,
),
Function.Parameter(
pos=5,
name="na_position",
type=str_,
required=False,
),
Function.Parameter(
pos=6,
name="ignore_index",
type=bool_,
required=False,
),
Function.Parameter(
pos=7,
name="key",
type=TopType(),
required=False,
),
],
),
returns=call.frame,
)
list_of = self.types.list_of
overloads: list[Type] = [
make_overload(by=str_, ascending=bool_),
make_overload(by=list_of(str_), ascending=bool_),
make_overload(by=str_, ascending=list_of(bool_)),
make_overload(by=list_of(str_), ascending=list_of(bool_)),
]
# TODO: check that literal strings in `by` are valid columns
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=OverloadedFunction(overloads=overloads),
positional=call.positional,
keywords=call.keywords,
)
return result.result
def _filter_groupby_columns(
self, frame: DataFrameType, by: TypedExpr
) -> DataFrameType:
"""Remove columns passed as string literals in groupby's `by` argument
Args:
frame (DataFrameType): the original dataframe
by (TypedExpr): the by argument
Returns:
DataFrameType: the filtered dataframe
"""
by_columns: list[str] = []
by_expr, _ = by
match by_expr:
case p.ListExpr(items=items):
for item in items:
match item:
case p.LiteralExpr(value=str() as name):
by_columns.append(name)
case p.LiteralExpr(value=str() as name):
by_columns.append(name)
if len(by_columns) == 0:
return frame
new_columns: list[DataFrameType.Column] = []
for column in frame.columns:
if column.name in by_columns:
continue
new_columns.append(
DataFrameType.Column(
index=len(new_columns),
name=column.name,
type=column.type,
)
)
return DataFrameType(columns=new_columns)
@method()
def groupby(self, call: Call) -> Type:
new_frame: DataFrameType = call.frame
by: Optional[TypedExpr] = None
if len(call.positional) != 0:
by = call.positional[0]
elif "by" in call.keywords:
by = call.keywords["by"]
if by is not None:
new_frame = self._filter_groupby_columns(call.frame, by)
bool_: Type = self.types.get_type("bool")
function: Function = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="by",
type=TopType(),
required=False,
),
Function.Parameter(
pos=1,
name="level",
type=TopType(),
required=False,
),
],
kw=[
Function.Parameter(
pos=i + 2,
name=name,
type=bool_,
required=False,
)
for i, name in enumerate(
["as_index", "sort", "group_keys", "observed", "dropna"]
)
],
),
returns=FrameGroupBy(frame=new_frame),
)
result: CallResult = self.dispatcher.get_result(
location=call.location,
callee=function,
positional=call.positional,
keywords=call.keywords,
)
return result.result
def _assert_same_length(self, call_expr: p.Expr, frame1: p.Expr, frame2: p.Expr):
"""Generate an assertion to check that two frames have the same length
Args:
call_expr (p.Expr): the call expression, to insert the assertion
at the right place
frame1 (p.Expr): the first frame expression
frame2 (p.Expr): the second frame expression
"""
func_name: str = "__midas_frame_same_length__"
# Efficiently compute length
# https://stackoverflow.com/a/15943975/11109181
def len_of_df(df: ast.expr) -> ast.expr:
return ast.Call(
func=ast.Name(id="len"),
args=[
ast.Attribute(
value=df,
attr="index",
)
],
keywords=[],
)
self.assertions.define(
func_name,
ast.FunctionDef(
name=func_name,
args=ast.arguments(
posonlyargs=[],
args=[
ast.arg(arg="frame1"),
ast.arg(arg="frame2"),
],
kwonlyargs=[],
defaults=[],
kw_defaults=[],
),
body=[
ast.Return(
value=ast.Compare(
left=len_of_df(ast.Name(id="frame1")),
ops=[ast.Eq()],
comparators=[len_of_df(ast.Name(id="frame2"))],
)
)
],
decorator_list=[],
),
)
self.assertions.add(
bound_expr=call_expr,
inputs=[frame1, frame2],
builder=lambda f1, f2: ast.Call(
func=ast.Name(id=func_name),
args=[f1, f2],
keywords=[],
),
message="DataFrames must have the same length",
)

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
Optional,
Protocol,
Self,
TypeVar,
)
import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.dispatcher import CallDispatcher
from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter
from midas.checker.types import Type, UnknownType
from midas.generator.collector import AssertionCollector
if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr
class _MethodRegistryMeta(type):
"""Meta-class for :class:`MethodRegistry`
Collects methods marked with the :func:`method` decorator into a dictionary
named `_methods` on the class itself
"""
_methods: dict[str, Callable[..., Type]] = {}
def __new__(
cls,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
):
new_class = super().__new__(cls, name, bases, namespace)
new_class._methods = {}
for attr in namespace.values():
if callable(attr) and hasattr(attr, "__method_names__"):
for name in attr.__method_names__: # type: ignore
new_class._methods[name] = attr # type: ignore
return new_class
class MethodCall(Protocol):
"""A method call object
Must have at least `location`, `call_expr` and `subject` properties
"""
@property
def location(self) -> Location: ...
@property
def call_expr(self) -> p.Expr: ...
@property
def subject(self) -> TypedExpr: ...
T = TypeVar("T", bound=MethodCall)
class MethodRegistry(Generic[T], metaclass=_MethodRegistryMeta):
"""A registry of methods"""
def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer
@property
def reporter(self) -> FileReporter:
return self.typer.reporter
@property
def types(self) -> TypesRegistry:
return self.typer.types
@property
def dispatcher(self) -> CallDispatcher[p.Expr]:
return self.typer.dispatcher
@property
def assertions(self) -> AssertionCollector:
return self.typer.assertions
def call(self, method: str, call: T) -> Type:
"""Compute the result type of a call to the given method
Args:
method (str): the method's name
call (T): the call
Returns:
Type: the result type
"""
func: Optional[Callable[[Self, T], Type]] = self._methods.get(method)
if func is None:
self.reporter.warning(
call.location, f"Unknown method {method} on {call.subject[1]}"
)
return UnknownType()
return func(self, call)
_Self = TypeVar("_Self", bound=MethodRegistry[Any])
Method = Callable[[_Self, T], Type]
def method(*names: str) -> Callable[[Method[_Self, T]], Method[_Self, T]]:
"""Simple decorator to mark a method as part of the registry
Args:
names (str): names by which the method can be called. If left empty, the
Python method's name will be used
"""
def wrapper(func: Method[_Self, T]) -> Method[_Self, T]:
names_: tuple[str, ...] = names
if len(names_) == 0:
names_ = (func.__name__,)
setattr(func, "__method_names__", names_)
return func
return wrapper

View File

@@ -3,42 +3,77 @@ from pathlib import Path
from typing import Optional from typing import Optional
import midas.ast.midas as m import midas.ast.midas as m
from midas.ast.location import Location
from midas.checker.builtins import define_builtins from midas.checker.builtins import define_builtins
from midas.checker.dispatcher import CallDispatcher, CallResult
from midas.checker.environment import Environment
from midas.checker.operators import MIDAS_BINARY_METHODS, MIDAS_UNARY_METHODS
from midas.checker.preamble import Preamble
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter, Reporter from midas.checker.reporter import FileReporter, Reporter
from midas.checker.types import ( from midas.checker.types import (
AliasType, ColumnType,
ComplexType, ConstraintType,
ExtensionType, DataFrameType,
DerivedType,
Function, Function,
GenericType, GenericType,
ParamSpec,
Predicate,
Type, Type,
TypeVar, TypeVar,
UnknownType, UnknownType,
) )
from midas.checker.variance import VarianceInferrer
from midas.lexer.midas import MidasLexer from midas.lexer.midas import MidasLexer
from midas.lexer.token import Token from midas.lexer.token import Token, TokenType
from midas.parser.midas import MidasParser from midas.parser.midas import MidasParser
class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type]): class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type]):
"""A resolver which evaluates Midas type definitions and build a registry""" """A resolver which evaluates Midas type definitions and build a registry"""
def __init__(self, types: TypesRegistry, reporter: Reporter) -> None: def __init__(self, types: TypesRegistry, reporter: Reporter) -> None:
self.logger: logging.Logger = logging.getLogger("MidasTyper") self.logger: logging.Logger = logging.getLogger("MidasTyper")
self.reporter: FileReporter = reporter.for_file(None) self.reporter: FileReporter = reporter.for_file(None)
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.dispatcher: CallDispatcher[m.Expr] = CallDispatcher[m.Expr](
self.types, self.reporter
)
self._local_variables: dict[str, TypeVar] = {} self._local_variables: dict[str, TypeVar] = {}
self._predicate_params: dict[str, Type] = {}
self._current_name: Optional[str] = None self._current_name: Optional[str] = None
define_builtins(self.types) define_builtins(self.types)
builtins_path: Path = (Path(__file__).parent / "builtins.midas").resolve() builtins_path: Path = (Path(__file__).parent / "builtins.midas").resolve()
self.process(builtins_path.read_text(), str(builtins_path)) self.process(builtins_path.read_text(), str(builtins_path))
self._bool: Type = self.get_type("bool")
self._preamble: Environment = Preamble(self.types)
def set_reporter(self, reporter: FileReporter):
"""Set the file reporter to use for diagnostics
Args:
reporter (FileReporter): the file reporter
"""
self.reporter = reporter
self.dispatcher.set_reporter(reporter)
def process(self, source: str, path: Optional[str]): def process(self, source: str, path: Optional[str]):
self.reporter = self.reporter.for_file(path) """Process some Midas source code
Args:
source (str): the Midas source code
path (Optional[str]): the path of the source file, if known
"""
reporter: FileReporter = self.reporter.for_file(path)
self.set_reporter(reporter)
lexer: MidasLexer = MidasLexer(source) lexer: MidasLexer = MidasLexer(source)
tokens: list[Token] = lexer.process() tokens: list[Token] = lexer.process()
parser: MidasParser = MidasParser(tokens) parser: MidasParser = MidasParser(tokens)
@@ -47,6 +82,18 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
self.reporter.error(error.token.get_location(), error.message) self.reporter.error(error.token.get_location(), error.message)
self.resolve(stmts) self.resolve(stmts)
def type_of(self, expr: m.Expr) -> Type:
"""Compute the type of the given expression
Args:
expr (m.Expr): the expression to type
Returns:
Type: the type of the expression
"""
type: Type = expr.accept(self)
return type
def get_type(self, name: str) -> Type: def get_type(self, name: str) -> Type:
"""Get a type from its name """Get a type from its name
@@ -63,6 +110,34 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
return self._local_variables[name] return self._local_variables[name]
return self.types.get_type(name) return self.types.get_type(name)
def get_variable(self, name: str) -> Type:
"""Get the type of a variable
This function will first look into the current predicate's parameters if
we are in a predicate definition.
The the variable is looked up in the preamble (i.e. global environment)
Args:
name (str): the name of the variable
Raises:
NameError: if the variable cannot be found
Returns:
Type: the type of the variable
"""
if name in self._predicate_params:
return self._predicate_params[name]
predicate: Optional[Predicate] = self.types.lookup_predicate(name)
if predicate is not None:
return predicate.type
global_: Optional[Type] = self._preamble.get(name)
if global_ is not None:
return global_
raise NameError(f"Unknown variable '{name}'")
def resolve(self, stmts: list[m.Stmt]): def resolve(self, stmts: list[m.Stmt]):
"""Process a sequence of statements """Process a sequence of statements
@@ -72,6 +147,21 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
for stmt in stmts: for stmt in stmts:
stmt.accept(self) stmt.accept(self)
for name, type in self.types._types.items():
if isinstance(type, GenericType):
inferrer = VarianceInferrer(self.types)
self.types._types[name] = inferrer.infer(type)
def assert_bool(self, expr: m.Expr):
"""Check that the given expression is a subtype of `bool` or report an error
Args:
expr (m.Expr): the expression to check
"""
type: Type = self.type_of(expr)
if not self.types.is_subtype(type, self._bool):
self.reporter.error(expr.location, f"Must be a boolean but is {type}")
def visit_type_stmt(self, stmt: m.TypeStmt) -> None: def visit_type_stmt(self, stmt: m.TypeStmt) -> None:
name: str = stmt.name.lexeme name: str = stmt.name.lexeme
self._current_name = name self._current_name = name
@@ -81,11 +171,18 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
if len(params) != 0: if len(params) != 0:
type = GenericType(name=name, params=params, body=type) type = GenericType(name=name, params=params, body=type)
else: else:
type = AliasType(name=name, type=type) type = DerivedType(name=name, type=type)
self.types.define_type(name, type) self.types.define_type(name, type)
self._local_variables.clear() self._local_variables.clear()
self._current_name = None self._current_name = None
def visit_alias_stmt(self, stmt: m.AliasStmt) -> None:
name: str = stmt.name.lexeme
self._current_name = name
type: Type = stmt.type.accept(self)
self.types.define_type(name, type)
self._current_name = None
def visit_member_stmt(self, stmt: m.MemberStmt) -> None: ... def visit_member_stmt(self, stmt: m.MemberStmt) -> None: ...
def visit_extend_stmt(self, stmt: m.ExtendStmt) -> None: def visit_extend_stmt(self, stmt: m.ExtendStmt) -> None:
@@ -102,35 +199,180 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
base_name, base_name,
member.name.lexeme, member.name.lexeme,
member_type, member_type,
member.kind == m.MemberKind.METHOD, member.kind,
) )
def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None: def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None:
self.reporter.warning(stmt.location, "PredicateStmt not yet supported") for spec in stmt.params:
for param in spec.mixed:
assert param.name is not None
self._predicate_params[param.name.lexeme] = param.type.accept(self)
def visit_logical_expr(self, expr: m.LogicalExpr) -> None: type: Type = self.type_of(stmt.body)
self.reporter.warning(expr.location, "LogicalExpr not yet supported") params: list[ParamSpec] = [self._visit_param_spec(spec) for spec in stmt.params]
def visit_binary_expr(self, expr: m.BinaryExpr) -> None: if not self._is_valid_predicate(type):
self.reporter.warning(expr.location, "BinaryExpr not yet supported") self.reporter.error(
stmt.body.location,
f"Predicate function body must evaluate to a boolean, got {type}",
)
if len(params) != 0:
type = self._bool
for spec in reversed(params):
type = Function(
params=spec,
returns=type,
)
self._predicate_params = {}
self.types.define_predicate(
stmt.name.lexeme,
Predicate(
type=type,
body=stmt.body,
alias=len(params) == 0,
),
)
def visit_unary_expr(self, expr: m.UnaryExpr) -> None: def _is_valid_predicate(self, body: Type) -> bool:
self.reporter.warning(expr.location, "UnaryExpr not yet supported") """Check whether the given type is valid as a predicate's body
def visit_get_expr(self, expr: m.GetExpr) -> None: Accepted types are either subtypes of `bool` or valid predicates
self.reporter.warning(expr.location, "GetExpr not yet supported")
def visit_variable_expr(self, expr: m.VariableExpr) -> None: Args:
self.reporter.warning(expr.location, "VariableExpr not yet supported") body (Type): the potential predicate body
def visit_grouping_expr(self, expr: m.GroupingExpr) -> None: Returns:
bool: `True` if `body` can be a predicate body, `False` otherwise
"""
match body:
case Function(returns=returns):
return self._is_valid_predicate(returns)
case _ if self.types.is_subtype(body, self._bool):
return True
case _:
return False
def visit_logical_expr(self, expr: m.LogicalExpr) -> Type:
self.assert_bool(expr.left)
self.assert_bool(expr.right)
return self._bool
def visit_binary_expr(self, expr: m.BinaryExpr) -> Type:
method: Optional[str] = MIDAS_BINARY_METHODS.get(expr.operator.type)
if method is None:
self.logger.warning(f"Unsupported operator {expr.operator.lexeme}")
self.reporter.warning(
expr.location, f"Unsupported operator {expr.operator.lexeme}"
)
return UnknownType()
return self._visit_binary_expr(expr.location, expr.left, expr.right, method)
def _visit_binary_expr(
self,
location: Location,
left_expr: m.Expr,
right_expr: m.Expr,
method: str,
) -> Type:
left: Type = self.type_of(left_expr)
right: Type = self.type_of(right_expr)
operation: Optional[Type] = self.types.lookup_member(left, method)
if operation is None:
self.reporter.error(
location,
f"Undefined operation {method} between {left} and {right}",
)
return UnknownType()
result: CallResult = self.dispatcher.get_result(
location=location,
callee=operation,
positional=[(right_expr, right)],
keywords={},
)
return result.result
def visit_unary_expr(self, expr: m.UnaryExpr) -> Type:
# Special case because there is no __not__ dunder method
match expr.operator:
case Token(type=TokenType.BANG):
return self.types.get_type("bool")
method: Optional[str] = MIDAS_UNARY_METHODS.get(expr.operator.type)
if method is None:
self.logger.warning(f"Unsupported operator {expr.operator.lexeme}")
self.reporter.warning(
expr.location, f"Unsupported operator {expr.operator.lexeme}"
)
return UnknownType()
operand: Type = self.type_of(expr.right)
operation: Optional[Type] = self.types.lookup_member(operand, method)
if operation is None:
self.reporter.error(
expr.location,
f"Undefined operation {method} for {operand}",
)
return UnknownType()
result: CallResult = self.dispatcher.get_result(
location=expr.location,
callee=operation,
positional=[],
keywords={},
)
return result.result
def visit_call_expr(self, expr: m.CallExpr) -> Type:
callee: Type = expr.callee.accept(self)
positional: list[tuple[m.Expr, Type]] = [
(arg, self.type_of(arg)) for arg in expr.arguments
]
keywords: dict[str, tuple[m.Expr, Type]] = {
name: (arg, self.type_of(arg)) for name, arg in expr.keywords.items()
}
result: CallResult = self.dispatcher.get_result(
location=expr.location,
callee=callee,
positional=positional,
keywords=keywords,
)
return result.result
def visit_get_expr(self, expr: m.GetExpr) -> Type:
object: Type = expr.expr.accept(self)
member: Optional[Type] = self.types.lookup_member(object, expr.name.lexeme)
if member is None:
self.reporter.error(
expr.location, f"Unknown member '{expr.name.lexeme}' of {object}"
)
return UnknownType()
return member
def visit_variable_expr(self, expr: m.VariableExpr) -> Type:
return self.get_variable(expr.name.lexeme)
def visit_grouping_expr(self, expr: m.GroupingExpr) -> Type:
return expr.expr.accept(self) return expr.expr.accept(self)
def visit_literal_expr(self, expr: m.LiteralExpr) -> None: def visit_literal_expr(self, expr: m.LiteralExpr) -> Type:
self.reporter.warning(expr.location, "LiteralExpr not yet supported") match expr.value:
case bool(): # Must be before int
return self.types.get_type("bool")
case int():
return self.types.get_type("int")
case float():
return self.types.get_type("float")
case str():
return self.types.get_type("str")
case _:
self.reporter.warning(expr.location, f"Unknown literal {expr}")
return UnknownType()
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> None: def visit_wildcard_expr(self, expr: m.WildcardExpr) -> Type:
self.reporter.warning(expr.location, "WildcardExpr not yet supported") return self.get_variable("_")
def visit_named_type(self, type: m.NamedType) -> Type: def visit_named_type(self, type: m.NamedType) -> Type:
name: str = type.name.lexeme name: str = type.name.lexeme
@@ -144,6 +386,16 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
return UnknownType() return UnknownType()
def visit_generic_type(self, type: m.GenericType) -> Type: def visit_generic_type(self, type: m.GenericType) -> Type:
match type.type:
case m.NamedType(name=Token(lexeme="Column")):
if len(type.args) != 1:
self.reporter.error(
type.location,
f"Column requires 1 type argument, {len(type.args)} provided",
)
return ColumnType(type=UnknownType())
return ColumnType(type=type.args[0].accept(self))
type_: Type = type.type.accept(self) type_: Type = type.type.accept(self)
args: list[Type] = [arg.accept(self) for arg in type.args] args: list[Type] = [arg.accept(self) for arg in type.args]
try: try:
@@ -153,44 +405,62 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[Type
return UnknownType() return UnknownType()
def visit_constraint_type(self, type: m.ConstraintType) -> Type: def visit_constraint_type(self, type: m.ConstraintType) -> Type:
type_: Type = type.type.accept(self) base_type: Type = type.type.accept(self)
type.constraint.accept(self) self._predicate_params["_"] = base_type
# TODO constraint_type: Type = self.type_of(type.constraint)
return UnknownType() self._predicate_params = {}
if not self.types.is_subtype(constraint_type, self._bool):
self.reporter.error(
type.location,
f"Constraint must evaluate to a boolean, got {constraint_type}",
)
def visit_complex_type(self, type: m.ComplexType) -> ComplexType: return ConstraintType(
return ComplexType( type=base_type,
members={ constraint=type.constraint,
member.name.lexeme: member.type.accept(self) for member in type.members
}
)
def visit_extension_type(self, type: m.ExtensionType) -> Type:
return ExtensionType(
base=type.base.accept(self),
extension=self.visit_complex_type(type.extension),
) )
def visit_function_type(self, type: m.FunctionType) -> Type: def visit_function_type(self, type: m.FunctionType) -> Type:
n_pos_args: int = len(type.pos_args) return Function(
n_args: int = len(type.args) params=self._visit_param_spec(type.params),
returns=type.returns.accept(self),
)
def process_arg(arg: m.FunctionType.Argument, i: int) -> Function.Argument: def _visit_param_spec(self, spec: m.ParamSpec) -> ParamSpec:
return Function.Argument( n_pos: int = len(spec.pos)
n_mixed: int = len(spec.mixed)
def process_param(
param: m.FunctionType.Parameter, i: int
) -> Function.Parameter:
return Function.Parameter(
pos=i, pos=i,
name=arg.name.lexeme if arg.name is not None else str(i), name=param.name.lexeme if param.name is not None else str(i),
type=arg.type.accept(self), type=param.type.accept(self),
required=arg.required, required=param.required,
) )
return Function( return ParamSpec(
pos_args=[process_arg(arg, i) for i, arg in enumerate(type.pos_args)], pos=[process_param(param, i) for i, param in enumerate(spec.pos)],
args=[process_arg(arg, i + n_pos_args) for i, arg in enumerate(type.args)], mixed=[
kw_args=[ process_param(param, i + n_pos) for i, param in enumerate(spec.mixed)
process_arg(arg, i + n_pos_args + n_args)
for i, arg in enumerate(type.kw_args)
], ],
returns=type.returns.accept(self), kw=[
process_param(param, i + n_pos + n_mixed)
for i, param in enumerate(spec.kw)
],
)
def visit_frame_type(self, type: m.FrameType) -> Type:
def process_column(i: int, col: m.FrameType.Column) -> DataFrameType.Column:
return DataFrameType.Column(
index=i,
name=col.name.lexeme,
type=ColumnType(type=col.type.accept(self)),
)
return DataFrameType(
columns=[process_column(i, col) for i, col in enumerate(type.columns)]
) )
def _resolve_type_params(self, params: list[m.TypeParam]): def _resolve_type_params(self, params: list[m.TypeParam]):

View File

@@ -1,7 +1,9 @@
import ast import ast
from typing import Type from typing import Type
OPERATOR_METHODS: dict[Type[ast.operator], str] = { from midas.lexer.token import TokenType
PY_OPERATOR_METHODS: dict[Type[ast.operator], str] = {
ast.Add: "__add__", ast.Add: "__add__",
ast.Sub: "__sub__", ast.Sub: "__sub__",
ast.Mult: "__mul__", ast.Mult: "__mul__",
@@ -17,9 +19,9 @@ OPERATOR_METHODS: dict[Type[ast.operator], str] = {
ast.FloorDiv: "__floordiv__", ast.FloorDiv: "__floordiv__",
} }
COMPARATOR_METHODS: dict[Type[ast.cmpop], str] = { PY_COMPARATOR_METHODS: dict[Type[ast.cmpop], str] = {
ast.Eq: "__eq__", ast.Eq: "__eq__",
# ast.NotEq: "__noteq__", ast.NotEq: "__eq__",
ast.Lt: "__lt__", ast.Lt: "__lt__",
ast.LtE: "__le__", ast.LtE: "__le__",
ast.Gt: "__gt__", ast.Gt: "__gt__",
@@ -30,9 +32,40 @@ COMPARATOR_METHODS: dict[Type[ast.cmpop], str] = {
# ast.NotIn: "__notin__", # ast.NotIn: "__notin__",
} }
UNARY_METHODS: dict[Type[ast.unaryop], str] = { PY_UNARY_METHODS: dict[Type[ast.unaryop], str] = {
ast.Invert: "__invert__", ast.Invert: "__invert__",
# ast.Not: "", # ast.Not: "",
ast.UAdd: "__pos__", ast.UAdd: "__pos__",
ast.USub: "__neg__", ast.USub: "__neg__",
} }
MIDAS_BINARY_METHODS: dict[TokenType, str] = {
TokenType.PLUS: "__add__",
TokenType.MINUS: "__sub__",
TokenType.STAR: "__mul__",
TokenType.SLASH: "__truediv__",
# TokenType.MODULO: "__mod__",
# TokenType.POW: "__pow__",
# ast.BitOr: "__or__",
# ast.BitXor: "__xor__",
# ast.BitAnd: "__and__",
# ast.FloorDiv: "__floordiv__",
TokenType.EQUAL_EQUAL: "__eq__",
TokenType.BANG_EQUAL: "__eq__",
TokenType.LESS: "__lt__",
TokenType.LESS_EQUAL: "__le__",
TokenType.GREATER: "__gt__",
TokenType.GREATER_EQUAL: "__ge__",
# ast.Is: "__is__",
# ast.IsNot: "__isnot__",
# ast.In: "__in__",
# ast.NotIn: "__notin__",
}
MIDAS_UNARY_METHODS: dict[TokenType, str] = {
# ast.Invert: "__invert__",
# ast.Not: "",
# TokenType.PLUS: "__pos__",
TokenType.MINUS: "__neg__",
}

View File

@@ -1,8 +1,18 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Optional
from midas.checker.environment import Environment from midas.checker.environment import Environment
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
from midas.checker.types import Function, GenericType, TopType, Type, TypeVar, UnitType from midas.checker.types import (
Function,
GenericType,
OverloadedFunction,
ParamSpec,
TopType,
Type,
TypeVar,
UnitType,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -13,26 +23,32 @@ class Param:
class Preamble(Environment): class Preamble(Environment):
"""The initial environment containing some of Python's builtin functions"""
def __init__(self, types: TypesRegistry) -> None: def __init__(self, types: TypesRegistry) -> None:
super().__init__() super().__init__()
self._types: TypesRegistry = types self._types: TypesRegistry = types
self._python_funcs: dict[str, Callable[..., Any]] = {}
self._def_type_constructor("object") self.define("__name__", self._types.get_type("str"))
self._def_type_constructor("float") self._def_type_constructor("object", object)
self._def_type_constructor("int") self._def_type_constructor("float", float)
self._def_type_constructor("bool") self._def_type_constructor("int", int)
self._def_type_constructor("str") self._def_type_constructor("bool", bool)
self._def_type_constructor("str", str)
self._def_function( self._def_function(
name="list", name="list",
pos=[Param("object", TopType())], pos=[Param("object", TopType())],
returns=self._list_of(TopType()), returns=self._list_of(TopType()),
py_function=list,
) )
# TODO: use sink # TODO: use sink
self._def_function( self._def_function(
name="print", name="print",
pos=[Param("object", TopType())], pos=[Param("object", TopType(), required=False)],
returns=UnitType(), returns=UnitType(),
py_function=print,
) )
map_in = TypeVar(name="T", bound=None) map_in = TypeVar(name="T", bound=None)
@@ -52,17 +68,62 @@ class Preamble(Environment):
), ),
], ],
returns=self._list_of(map_out), # TODO: replace with Iterable[U] returns=self._list_of(map_out), # TODO: replace with Iterable[U]
type_vars=[map_in, map_out],
py_function=map,
)
self._def_function(
name="input",
pos=[Param("prompt", TopType(), required=False)],
returns=self._types.get_type("str"),
)
self._def_function(
name="len",
pos=[Param("object", TopType())],
returns=self._types.get_type("int"),
) )
def _list_of(self, item_type: Type) -> Type: T = TypeVar(name="T", bound=None)
return self._types.apply_generic(self._types.get_type("list"), [item_type]) self._def_overloads(
name="max",
py_function=max,
signatures=[
(
[Param("arg1", T), Param("arg2", T)],
[],
[],
T,
[T],
),
([Param("iterable", self._list_of(T))], [], [], T, [T]),
],
)
self._def_overloads(
name="min",
py_function=min,
signatures=[
(
[Param("arg1", T), Param("arg2", T)],
[],
[],
T,
[T],
),
([Param("iterable", self._list_of(T))], [], [], T, [T]),
],
)
def _def_type_constructor(self, name: str): def _list_of(self, item_type: str | Type) -> Type:
return self._types.list_of(item_type)
def _def_type_constructor(
self, name: str, py_function: Optional[Callable[..., Any]] = None
):
# TODO: more specific arg types # TODO: more specific arg types
self._def_function( self._def_function(
name=name, name=name,
pos=[Param("object", TopType(), required=False)], pos=[Param("object", TopType(), required=False)],
returns=self._types.get_type(name), returns=self._types.get_type(name),
py_function=py_function,
) )
def _make_function( def _make_function(
@@ -75,9 +136,9 @@ class Preamble(Environment):
returns: Type = UnitType(), returns: Type = UnitType(),
type_vars: list[TypeVar] = [], type_vars: list[TypeVar] = [],
) -> Type: ) -> Type:
def map_args(params: list[Param], offset: int) -> list[Function.Argument]: def map_params(params: list[Param], offset: int) -> list[Function.Parameter]:
return [ return [
Function.Argument( Function.Parameter(
pos=i + offset, pos=i + offset,
name=param.name, name=param.name,
type=param.type, type=param.type,
@@ -87,9 +148,11 @@ class Preamble(Environment):
] ]
function = Function( function = Function(
pos_args=map_args(pos, 0), params=ParamSpec(
args=map_args(mixed, len(pos)), pos=map_params(pos, 0),
kw_args=map_args(kw, len(pos) + len(mixed)), mixed=map_params(mixed, len(pos)),
kw=map_params(kw, len(pos) + len(mixed)),
),
returns=returns, returns=returns,
) )
if len(type_vars) != 0: if len(type_vars) != 0:
@@ -109,6 +172,7 @@ class Preamble(Environment):
kw: list[Param] = [], kw: list[Param] = [],
returns: Type = UnitType(), returns: Type = UnitType(),
type_vars: list[TypeVar] = [], type_vars: list[TypeVar] = [],
py_function: Optional[Callable[..., Any]] = None,
): ):
function: Type = self._make_function( function: Type = self._make_function(
name=name, name=name,
@@ -119,3 +183,34 @@ class Preamble(Environment):
type_vars=type_vars, type_vars=type_vars,
) )
self.define(name, function) self.define(name, function)
if py_function is not None:
self._python_funcs[name] = py_function
def _def_overloads(
self,
*,
name: str,
signatures: list[
tuple[list[Param], list[Param], list[Param], Type, list[TypeVar]]
],
py_function: Optional[Callable[..., Any]] = None,
):
overloads: list[Type] = []
for pos, mixed, kw, returns, type_vars in signatures:
overloads.append(
self._make_function(
name=name,
pos=pos,
mixed=mixed,
kw=kw,
returns=returns,
type_vars=type_vars,
)
)
function: Type = OverloadedFunction(overloads=overloads)
self.define(name, function)
if py_function is not None:
self._python_funcs[name] = py_function
def get_py_func(self, name: str) -> Optional[Callable[..., Any]]:
return self._python_funcs.get(name)

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,46 @@
import logging import logging
from dataclasses import dataclass
from typing import Optional from typing import Optional
from midas.ast.midas import MemberKind
from midas.checker.builtins import BUILTIN_SUBTYPES from midas.checker.builtins import BUILTIN_SUBTYPES
from midas.checker.types import ( from midas.checker.types import (
AliasType,
AppliedType, AppliedType,
BaseType, BaseType,
ComplexType, ColumnType,
ExtensionType, ConstraintType,
DataFrameType,
DerivedType,
Function, Function,
GenericType, GenericType,
OverloadedFunction, OverloadedFunction,
Predicate,
TopType, TopType,
TupleType,
Type, Type,
TypeVar, TypeVar,
UnknownType, UnknownType,
Variance,
substitute_typevars, substitute_typevars,
) )
@dataclass
class Member:
"""A member of a type (property or method)"""
kind: MemberKind
type: Type
class TypesRegistry: class TypesRegistry:
"""A registry of types, type members and predicates"""
def __init__(self) -> None: def __init__(self) -> None:
self.logger: logging.Logger = logging.getLogger("TypesRegistry") self.logger: logging.Logger = logging.getLogger("TypesRegistry")
self._types: dict[str, Type] = {} self._types: dict[str, Type] = {}
self._members: dict[str, dict[str, Type]] = {} self._members: dict[str, dict[str, Member]] = {}
self._predicates: dict[str, Predicate] = {}
def get_type(self, name: str) -> Type: def get_type(self, name: str) -> Type:
"""Get a type from its name """Get a type from its name
@@ -60,26 +77,89 @@ class TypesRegistry:
return type return type
def define_member( def define_member(
self, type_name: str, member_name: str, member_type: Type, is_method: bool self,
type_name: str,
member_name: str,
member_type: Type,
kind: MemberKind,
): ):
members: dict[str, Type] = self._members.setdefault(type_name, {}) """Define a member on a type
If the member is a method and a member with the same name is already
defined on the given type, the two are combined into an :class:`OverloadedFunction`.
If the member is a property and a member with the same name is already
defined on the given type, the new definition is dropped and an error
is reported.
In any case, if a member with the same name but a different kind is
already defined on the given type, the new definition is dropped and
an error is reported.
Args:
type_name (str): the name of the type on which the member is defined
member_name (str): the name of the new member
member_type (Type): the type of the new member
kind (MemberKind): the kind of member to define (property or method)
"""
members: dict[str, Member] = self._members.setdefault(type_name, {})
if member_name in members: if member_name in members:
if not is_method: current: Member = members[member_name]
if current.kind != kind:
self.logger.error( self.logger.error(
f"Member '{member_name}' already defined for type {type_name}" f"Member '{member_name}' is already defined as a {current.kind},"
+ f" cannot define a {kind} with the same name"
) )
return return
current: Type = members[member_name] if kind != MemberKind.METHOD:
self.logger.error(
f"Member '{member_name}' already defined for type {type_name},"
+ " only methods can be overloaded"
)
return
combined: Type combined: Type
match current: match current.type:
case OverloadedFunction(overloads=overloads): case OverloadedFunction(overloads=overloads):
combined = OverloadedFunction(overloads=overloads + [member_type]) combined = OverloadedFunction(overloads=overloads + [member_type])
case _: case _:
combined = OverloadedFunction(overloads=[current, member_type]) combined = OverloadedFunction(overloads=[current.type, member_type])
members[member_name] = combined members[member_name] = Member(kind=current.kind, type=combined)
else: else:
members[member_name] = member_type members[member_name] = Member(kind=kind, type=member_type)
def define_predicate(self, name: str, predicate: Predicate):
"""Define a predicate
Args:
name (str): the name of the new predicate
predicate (Predicate): the predicate to define
Raises:
ValueError: if a predicate with the same name is already defined
"""
if name in self._predicates:
raise ValueError(f"Predicate {name} already defined")
self._predicates[name] = predicate
def is_builtin_subtype(self, name1: str, name2: str) -> bool:
"""Check whether a type is a subtype of another base on builtin subtype rules
Args:
name1 (str): the name of the potential subtype
name2 (str): the name of the potential supertype
Returns:
bool: _description_
"""
subtypes: set[str] = BUILTIN_SUBTYPES.get(name2, set())
if name1 in subtypes:
return True
for subtype in subtypes:
if self.is_builtin_subtype(name1, subtype):
return True
return False
def is_subtype(self, type1: Type, type2: Type) -> bool: def is_subtype(self, type1: Type, type2: Type) -> bool:
"""Check whether `type1` is a subtype of `type2` """Check whether `type1` is a subtype of `type2`
@@ -101,30 +181,85 @@ class TypesRegistry:
case (_, TopType()): case (_, TopType()):
return True return True
case (AliasType(type=base1), _): case (_, UnknownType()):
return self.is_subtype(base1, type2)
case (BaseType(name=name1), BaseType(name=name2)):
return name1 in BUILTIN_SUBTYPES.get(name2, set())
case (ComplexType(properties=props1), ComplexType(properties=props2)):
for k, t in props2.items():
if k not in props1:
return False
if not self.is_subtype(props1[k], t):
return False
return True return True
case (Function(), Function()):
return self.is_func_subtype(type1, type2)
case (TypeVar(bound=bound), _): case (TypeVar(bound=bound), _):
if bound is None: if bound is None:
return False return False
return self.is_subtype(bound, type2) return self.is_subtype(bound, type2)
case (_, TypeVar(bound=bound)):
if bound is None:
return True
return self.is_subtype(type1, bound)
case (DerivedType(type=base1), _):
return self.is_subtype(base1, type2)
case (BaseType(name=name1), BaseType(name=name2)):
return self.is_builtin_subtype(name1, name2)
case (DataFrameType(columns=columns1), DataFrameType(columns=columns2)):
# TODO: check order?
by_name1: dict[str, DataFrameType.Column] = {
col.name: col for col in columns1 if col.name is not None
}
for col2 in columns2:
if col2.name not in by_name1:
return False
if not self.is_subtype(by_name1[col2.name].type, col2.type):
return False
return True
case (ColumnType(type=inner1), ColumnType(type=inner2)):
# TODO: invariant, replace ColumnType with simple GenericType
if not self.are_equivalent(inner1, inner2):
return False
return True
case (Function(), Function()):
return self.is_func_subtype(type1, type2)
case (ConstraintType(type=base1), _):
return self.is_subtype(base1, type2)
case (
AppliedType(name=name1, args=args1),
AppliedType(name=name2, args=args2),
) if (
name1 == name2
):
generic: Type = self.get_type(name1)
assert isinstance(generic, GenericType)
for param, arg1, arg2 in zip(generic.params, args1, args2):
variance: Variance = param.variance
if variance in {Variance.INVARIANT, Variance.COVARIANT}:
if not self.is_subtype(arg1, arg2):
return False
if variance in {Variance.INVARIANT, Variance.CONTRAVARIANT}:
if not self.is_subtype(arg2, arg1):
return False
return True
# TODO: verify legitimacy
case (AppliedType(body=body), _):
return self.is_subtype(body, type2)
return False return False
def are_equivalent(self, type1: Type, type2: Type) -> bool:
"""Check whether two types are equivalent (T <: S and S <: T)
Args:
type1 (Type): the first type
type2 (Type): the second type
Returns:
bool: whether `type1` is a subtype and a supertype of `type2`
"""
return self.is_subtype(type1, type2) and self.is_subtype(type2, type1)
# TODO: verify the logic in here # TODO: verify the logic in here
def is_func_subtype(self, func1: Function, func2: Function) -> bool: def is_func_subtype(self, func1: Function, func2: Function) -> bool:
"""Check whether a function is a subtype of another """Check whether a function is a subtype of another
@@ -139,101 +274,121 @@ class TypesRegistry:
if not self.is_subtype(func1.returns, func2.returns): if not self.is_subtype(func1.returns, func2.returns):
return False return False
pos1: list[Function.Argument] = func1.pos_args pos1: list[Function.Parameter] = func1.params.pos
mixed1: list[Function.Argument] = func1.args mixed1: list[Function.Parameter] = func1.params.mixed
kw1: dict[str, Function.Argument] = {a.name: a for a in func1.kw_args} kw1: dict[str, Function.Parameter] = {
pos2: list[Function.Argument] = func2.pos_args param.name: param for param in func1.params.kw
mixed2: list[Function.Argument] = func2.args }
kw2: dict[str, Function.Argument] = {a.name: a for a in func2.kw_args} pos2: list[Function.Parameter] = func2.params.pos
mixed2: list[Function.Parameter] = func2.params.mixed
kw2: dict[str, Function.Parameter] = {
param.name: param for param in func2.params.kw
}
mixed_by_pos: dict[int, Function.Argument] = {arg.pos: arg for arg in mixed2} mixed_by_pos: dict[int, Function.Parameter] = {
mixed_by_name: dict[str, Function.Argument] = {arg.name: arg for arg in mixed2} param.pos: param for param in mixed2
}
mixed_by_name: dict[str, Function.Parameter] = {
param.name: param for param in mixed2
}
def is_arg_subtype(sub: Function.Argument, sup: Function.Argument) -> bool: def is_arg_subtype(sub: Function.Parameter, sup: Function.Parameter) -> bool:
if not self.is_subtype(sub.type, sup.type): if not self.is_subtype(sub.type, sup.type):
return False return False
if not sup.required and sub.required: if not sup.required and sub.required:
return False return False
return True return True
for arg1 in pos1: for param1 in pos1:
arg2: Function.Argument param2: Function.Parameter
if arg1.pos < len(pos2): if param1.pos < len(pos2):
arg2 = pos2[arg1.pos] param2 = pos2[param1.pos]
elif arg1.pos in mixed_by_pos: elif param1.pos in mixed_by_pos:
arg2 = mixed_by_pos[arg1.pos] param2 = mixed_by_pos[param1.pos]
elif not arg1.required: elif not param1.required:
continue continue
else: else:
return False return False
if not is_arg_subtype(arg2, arg1): if not is_arg_subtype(param2, param1):
return False return False
for name, arg1 in kw1.items(): for name, param1 in kw1.items():
arg2: Function.Argument param2: Function.Parameter
if name in kw2: if name in kw2:
arg2 = kw2[name] param2 = kw2[name]
elif name in mixed_by_name: elif name in mixed_by_name:
arg2 = mixed_by_name[name] param2 = mixed_by_name[name]
elif not arg1.required: elif not param1.required:
continue continue
else: else:
return False return False
if not is_arg_subtype(arg2, arg1): if not is_arg_subtype(param2, param1):
return False return False
for arg1 in mixed1: for param1 in mixed1:
pos_arg2: Optional[Function.Argument] = None pos_param2: Optional[Function.Parameter] = None
kw_arg2: Optional[Function.Argument] = None kw_param2: Optional[Function.Parameter] = None
if arg1.name in kw2: if param1.name in kw2:
kw_arg2 = kw2[arg1.name] kw_param2 = kw2[param1.name]
elif arg1.name in mixed_by_name: elif param1.name in mixed_by_name:
kw_arg2 = mixed_by_name[arg1.name] kw_param2 = mixed_by_name[param1.name]
if arg1.pos < len(pos2): if param1.pos < len(pos2):
pos_arg2 = pos2[arg1.pos] pos_param2 = pos2[param1.pos]
elif arg1.pos in mixed_by_pos: elif param1.pos in mixed_by_pos:
pos_arg2 = mixed_by_pos[arg1.pos] pos_param2 = mixed_by_pos[param1.pos]
# No match in func2 and arg is required # No match in func2 and arg is required
if pos_arg2 is None and kw_arg2 is None and arg1.required: if pos_param2 is None and kw_param2 is None and param1.required:
return False return False
# Matching keyword argument # Matching keyword argument
if kw_arg2 is not None and not is_arg_subtype(kw_arg2, arg1): if kw_param2 is not None and not is_arg_subtype(kw_param2, param1):
return False return False
# Matching positional argument # Matching positional argument
if pos_arg2 is not None and not is_arg_subtype(pos_arg2, arg1): if pos_param2 is not None and not is_arg_subtype(pos_param2, param1):
return False return False
mixed_positions: set[int] = {a.pos for a in mixed1} mixed_positions: set[int] = {param.pos for param in mixed1}
mixed_names: set[str] = {a.name for a in mixed1} mixed_names: set[str] = {param.name for param in mixed1}
for arg2 in pos2: for param2 in pos2:
if not arg2.required: if not param2.required:
continue continue
if arg2.pos >= len(pos1) and arg2.pos not in mixed_positions: if param2.pos >= len(pos1) and param2.pos not in mixed_positions:
return False return False
for name, arg2 in kw2.items(): for name, param2 in kw2.items():
if not arg2.required: if not param2.required:
continue continue
if name not in kw1 and name not in mixed_names: if name not in kw1 and name not in mixed_names:
return False return False
for arg2 in mixed2: for param2 in mixed2:
if arg2.required: if param2.required:
continue continue
pos_match: bool = arg2.pos < len(pos1) or arg2.pos in mixed_positions pos_match: bool = param2.pos < len(pos1) or param2.pos in mixed_positions
kw_match: bool = arg2.name in kw1 or arg2.name in mixed_names kw_match: bool = param2.name in kw1 or param2.name in mixed_names
if not pos_match or not kw_match: if not pos_match or not kw_match:
return False return False
return True return True
def apply_generic(self, type: Type, args: list[Type]) -> Type: def apply_generic(self, type: Type, args: list[Type]) -> Type:
"""Instantiate a generic type with the given type arguments
Args:
type (Type): the generic
args (list[Type]): the type arguments
Raises:
ValueError: if the arguments are invalid (wrong number, bound violation)
Returns:
Type: the applied generic type
"""
match type: match type:
case AliasType(name=name, type=base): case DerivedType(name=name, type=base):
return AliasType(name=name, type=self.apply_generic(base, args)) return DerivedType(name=name, type=self.apply_generic(base, args))
case GenericType(name=name, params=type_vars, body=body): case GenericType(name=name, params=type_vars, body=body):
n_args: int = len(args) n_args: int = len(args)
@@ -261,6 +416,9 @@ class TypesRegistry:
body=substitute_typevars(body, substitutions), body=substitute_typevars(body, substitutions),
) )
case BaseType(name="tuple"):
return TupleType(items=tuple(args))
case _: case _:
raise ValueError(f"{type} is not a generic type") raise ValueError(f"{type} is not a generic type")
@@ -293,17 +451,30 @@ class TypesRegistry:
return [types[i] for i in keep] return [types[i] for i in keep]
def lookup_member(self, type: Type, member_name: str) -> Optional[Type]: def lookup_member(self, type: Type, member_name: str) -> Optional[Type]:
"""Lookup a member by name on a given type
This function first looks up directly on the specified type, then
recurse through supertypes until it finds the member or reaches
the root type
Args:
type (Type): the type on which to lookup the member
member_name (str): the member's name
Returns:
Optional[Type]: the member's type, or `None` if it is not defined
"""
match type: match type:
case BaseType(name=name): case BaseType(name=name):
if name in self._members: if name in self._members:
if member_name in self._members[name]: if member_name in self._members[name]:
return self._members[name][member_name] return self._members[name][member_name].type
return None return None
case AliasType(name=name, type=base): case DerivedType(name=name, type=base):
if name in self._members: if name in self._members:
if member_name in self._members[name]: if member_name in self._members[name]:
return self._members[name][member_name] return self._members[name][member_name].type
return self.lookup_member(base, member_name) return self.lookup_member(base, member_name)
case AppliedType(name=name, body=body, args=args): case AppliedType(name=name, body=body, args=args):
@@ -317,7 +488,7 @@ class TypesRegistry:
} }
if name in self._members: if name in self._members:
if member_name in self._members[name]: if member_name in self._members[name]:
member_type: Type = self._members[name][member_name] member_type: Type = self._members[name][member_name].type
return substitute_typevars(member_type, substitutions) return substitute_typevars(member_type, substitutions)
member_type2: Optional[Type] = self.lookup_member(body, member_name) member_type2: Optional[Type] = self.lookup_member(body, member_name)
@@ -325,23 +496,89 @@ class TypesRegistry:
member_type2 = substitute_typevars(member_type2, substitutions) member_type2 = substitute_typevars(member_type2, substitutions)
return member_type2 return member_type2
case ComplexType(members=members): case ConstraintType(type=base):
if member_name in members:
return members[member_name]
self.logger.debug(f"No member '{member_name}' in {type}")
return None
case ExtensionType(base=base, extension=ComplexType(members=members)):
if member_name in members:
return members[member_name]
self.logger.debug(
f"No member '{member_name}' on {type}, looking up in base"
)
return self.lookup_member(base, member_name) return self.lookup_member(base, member_name)
case TypeVar(bound=bound) if bound is not None:
return self.lookup_member(bound, member_name)
case UnknownType(): case UnknownType():
return UnknownType() return UnknownType()
case _: case _:
self.logger.debug(f"Can't get member on {type}") self.logger.debug(f"Can't get member on {type}")
return None return None
def lookup_predicate(self, name: str) -> Optional[Predicate]:
"""Lookup a predicate by name
Args:
name (str): the name of the predicate
Returns:
Optional[Predicate]: the predicate, or `None` if is not defined
"""
return self._predicates.get(name)
def _by_name_or_type(self, name_or_type: str | Type) -> Type:
"""Get a type by name or return it as is
If `name_or_type` is a string, the associated type is looked up and returned.
Otherwise, the type is returned as is.
Args:
name_or_type (str | Type): the type or type's name
Returns:
Type: the type
"""
if isinstance(name_or_type, str):
return self.get_type(name_or_type)
return name_or_type
def list_of(self, item_type: str | Type) -> Type:
"""Helper method to type a list of a given item type
Args:
item_type (str | Type): the item type
Returns:
Type: the list type
"""
list_ = self.get_type("list")
return self.apply_generic(list_, [self._by_name_or_type(item_type)])
def tuple_of(self, *item_types: str | Type) -> Type:
"""Helper method to type a tuple of given item types
Args:
item_type (str | Type): the item types
Returns:
Type: the tuple type
"""
tuple_ = self.get_type("tuple")
return self.apply_generic(
tuple_,
[self._by_name_or_type(item_type) for item_type in item_types],
)
def dict_of(self, key_type: str | Type, value_type: str | Type) -> Type:
"""Helper method to type a dict of given key and value types
Args:
key_type (str | Type): the key type
value_type (str | Type): the value type
Returns:
Type: the dict type
"""
dict_ = self.get_type("dict")
return self.apply_generic(
dict_,
[
self._by_name_or_type(key_type),
self._by_name_or_type(value_type),
],
)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from typing import Optional from typing import Optional
from midas.ast.location import Location from midas.ast.location import Location
@@ -7,6 +8,8 @@ from midas.checker.diagnostic import Diagnostic, DiagnosticType
class Reporter: class Reporter:
"""Helper class to store diagnostics"""
def __init__(self): def __init__(self):
self.diagnostics: list[Diagnostic] = [] self.diagnostics: list[Diagnostic] = []
@@ -17,6 +20,14 @@ class Reporter:
location: Location, location: Location,
message: str, message: str,
): ):
"""Create and record a diagnostic
Args:
path (Optional[str]): the path linked to this diagnostic
type (DiagnosticType): the type of diagnostic
location (Location): the location if the diagnostic in the file
message (str): the diagnostic's message
"""
self.diagnostics.append( self.diagnostics.append(
Diagnostic( Diagnostic(
file_path=path, file_path=path,
@@ -27,21 +38,63 @@ class Reporter:
) )
def for_file(self, path: Optional[str]) -> FileReporter: def for_file(self, path: Optional[str]) -> FileReporter:
"""Create a new file reporter for the given path using this reporter
Args:
path (Optional[str]): the path for the new file reporter
Returns:
FileReporter: the new file reporter, linked to this reporter
"""
return FileReporter(self, path) return FileReporter(self, path)
class FileReporter: class FileReporter:
"""Helper class to manage diagnostics for a file"""
def __init__(self, base_reporter: Reporter, path: Optional[str]) -> None: def __init__(self, base_reporter: Reporter, path: Optional[str]) -> None:
self.base_reporter: Reporter = base_reporter self.base_reporter: Reporter = base_reporter
self.path: Optional[str] = path self.path: Optional[str] = path
self._context: list[str] = []
def for_file(self, path: Optional[str]) -> FileReporter: def for_file(self, path: Optional[str]) -> FileReporter:
"""Create a new file reporter for the given path with the same base reporter
Args:
path (Optional[str]): the path for the new file reporter
Returns:
FileReporter: the file reporter
"""
return FileReporter(self.base_reporter, path) return FileReporter(self.base_reporter, path)
@contextmanager
def with_context(self, ctx: str):
self._context.append(ctx)
try:
yield
finally:
self._context.pop()
def report(self, type: DiagnosticType, location: Location, message: str): def report(self, type: DiagnosticType, location: Location, message: str):
"""Report a diagnostic to the base reporter
Args:
type (DiagnosticType): the type of diagnostic
location (Location): the location of the diagnostic in the file
message (str): the diagnostic's message
"""
for ctx in self._context:
message = message + ", " + ctx
self.base_reporter.report(self.path, type, location, message) self.base_reporter.report(self.path, type, location, message)
def error(self, location: Location, message: str): def error(self, location: Location, message: str):
"""Report an error diagnostic
Args:
location (Location): the location of the diagnostic in the file
message (str): the diagnostic's message
"""
self.report( self.report(
type=DiagnosticType.ERROR, type=DiagnosticType.ERROR,
location=location, location=location,
@@ -49,6 +102,12 @@ class FileReporter:
) )
def warning(self, location: Location, message: str): def warning(self, location: Location, message: str):
"""Report a warning diagnostic
Args:
location (Location): the location of the diagnostic in the file
message (str): the diagnostic's message
"""
self.report( self.report(
type=DiagnosticType.WARNING, type=DiagnosticType.WARNING,
location=location, location=location,
@@ -56,8 +115,27 @@ class FileReporter:
) )
def info(self, location: Location, message: str): def info(self, location: Location, message: str):
"""Report an info diagnostic
Args:
location (Location): the location of the diagnostic in the file
message (str): the diagnostic's message
"""
self.report( self.report(
type=DiagnosticType.INFO, type=DiagnosticType.INFO,
location=location, location=location,
message=message, message=message,
) )
def debug(self, location: Location, message: str):
"""Report a debug diagnostic
Args:
location (Location): the location of the diagnostic in the file
message (str): the diagnostic's message
"""
self.report(
type=DiagnosticType.DEBUG,
location=location,
message=message,
)

View File

@@ -1,4 +1,6 @@
import midas.ast.python as p import midas.ast.python as p
from midas.ast.location import Location
from midas.checker.reporter import FileReporter
class ResolverError(Exception): ... class ResolverError(Exception): ...
@@ -11,9 +13,10 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
scope is referred to when a variable is referenced scope is referred to when a variable is referenced
""" """
def __init__(self): def __init__(self, reporter: FileReporter):
self.locals: dict[p.Expr, int] = {} self.locals: dict[p.Expr, int] = {}
self.scopes: list[dict[str, bool]] = [{}] self.scopes: list[dict[str, bool]] = [{}]
self.reporter: FileReporter = reporter
def resolve(self, *objects: p.Stmt | p.Expr) -> None: def resolve(self, *objects: p.Stmt | p.Expr) -> None:
"""Resolve the given statements or expressions""" """Resolve the given statements or expressions"""
@@ -25,29 +28,29 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
"""Begin a new scope inside the current one""" """Begin a new scope inside the current one"""
self.scopes.append({}) self.scopes.append({})
def end_scope(self): def end_scope(self) -> dict[str, bool]:
"""Close the current scope""" """Close and return the current scope"""
self.scopes.pop() return self.scopes.pop()
def declare(self, name: str) -> None: def declare(self, location: Location, name: str) -> None:
"""Declare a variable in the current scope """Declare a variable in the current scope
This method must be called *before* evaluating the variable initializer This method must be called *before* evaluating the variable initializer
Args: Args:
location (Location): the location where the name is declared
name (str): the name of the variable name (str): the name of the variable
Raises:
ResolverError: if the variable has already been declared in the current scope
""" """
if len(self.scopes) == 0: if len(self.scopes) == 0:
return return
scope: dict[str, bool] = self.scopes[-1] scope: dict[str, bool] = self.scopes[-1]
if name in scope: if name in scope:
raise ResolverError( self.reporter.error(
f"A variable with the name {name} is already declared in this scope" location,
f"A variable with the name '{name}' is already declared in this scope",
) )
scope[name] = False else:
scope[name] = False
def define(self, name: str) -> None: def define(self, name: str) -> None:
"""Define a variable in the current scope """Define a variable in the current scope
@@ -77,7 +80,15 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
self.locals[expr] = i self.locals[expr] = i
return return
def is_defined(self, name: str) -> bool: def is_declared(self, name: str) -> bool:
"""Check whether the given variable is defined in any scope
Args:
name (str): the name of the variable
Returns:
bool: `True` if the variable is defined in a scope, `False` otherwise
"""
for scope in self.scopes: for scope in self.scopes:
if name in scope: if name in scope:
return True return True
@@ -93,8 +104,12 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
function (p.Function): the function to resolve function (p.Function): the function to resolve
""" """
self.begin_scope() self.begin_scope()
for param in function.all_args: for param in function.params.all:
self.declare(param.name) if param.default is not None:
self.resolve(param.default)
for param in function.params.all:
self.declare(function.location, param.name)
self.define(param.name) self.define(param.name)
self.resolve(*function.body) self.resolve(*function.body)
self.end_scope() self.end_scope()
@@ -104,14 +119,12 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
def visit_function(self, stmt: p.Function) -> None: def visit_function(self, stmt: p.Function) -> None:
# Declare before resolving body to allow recursion # Declare before resolving body to allow recursion
self.declare(stmt.name) self.declare(stmt.location, stmt.name)
self.define(stmt.name) self.define(stmt.name)
self.resolve_function(stmt) self.resolve_function(stmt)
def visit_type_assign(self, stmt: p.TypeAssign) -> None: def visit_type_assign(self, stmt: p.TypeAssign) -> None:
self.declare(stmt.name) self.declare(stmt.location, stmt.name)
# NOTE: resolve type here?
self.define(stmt.name)
def visit_assign_stmt(self, stmt: p.AssignStmt) -> None: def visit_assign_stmt(self, stmt: p.AssignStmt) -> None:
self.resolve(stmt.value) self.resolve(stmt.value)
@@ -121,15 +134,21 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
def _visit_assign(self, target: p.Expr): def _visit_assign(self, target: p.Expr):
match target: match target:
case p.VariableExpr(name=name): case p.VariableExpr(name=name):
if not self.is_defined(name): if not self.is_declared(name):
self.declare(name) self.declare(target.location, name)
self.define(name) self.define(name)
target.accept(self) target.accept(self)
case p.GetExpr(): case p.GetExpr():
target.accept(self) target.accept(self)
case p.SubscriptExpr():
target.accept(self)
case _: case _:
raise Exception(f"Unsupported assignment to {target}") self.reporter.error(
target.location, f"Unsupported assignment to {target}"
)
def visit_return_stmt(self, stmt: p.ReturnStmt) -> None: def visit_return_stmt(self, stmt: p.ReturnStmt) -> None:
if stmt.value is not None: if stmt.value is not None:
@@ -146,23 +165,40 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
# Body # Body
self.begin_scope() self.begin_scope()
self.resolve(*stmt.body) self.resolve(*stmt.body)
self.end_scope() body: dict[str, bool] = self.end_scope()
# Else # Else
self.begin_scope() self.begin_scope()
self.resolve(*stmt.orelse) self.resolve(*stmt.orelse)
self.end_scope() else_: dict[str, bool] = self.end_scope()
# Define variables in this scope if it was defined in both body and else blocks
for name, is_defined in body.items():
if is_defined and else_.get(name, False):
self.define(name)
def visit_pass(self, stmt: p.Pass) -> None: def visit_pass(self, stmt: p.Pass) -> None:
pass pass
def visit_for_stmt(self, stmt: p.ForStmt) -> None: def visit_for_stmt(self, stmt: p.ForStmt) -> None:
self.begin_scope()
self.resolve(stmt.iterator) self.resolve(stmt.iterator)
self._visit_assign(stmt.target) self._visit_assign(stmt.target)
self.begin_scope()
self.resolve(*stmt.body) self.resolve(*stmt.body)
self.end_scope() self.end_scope()
def visit_import_stmt(self, stmt: p.ImportStmt) -> None:
self._resolve_imports(stmt.imports)
def visit_from_import_stmt(self, stmt: p.FromImportStmt) -> None:
self._resolve_imports(stmt.imports)
def _resolve_imports(self, imports: list[p.ImportAlias]) -> None:
for import_ in imports:
name: str = import_.imported_name
self.declare(import_.location, name)
self.define(name)
def visit_raw_stmt(self, stmt: p.RawStmt) -> None: def visit_raw_stmt(self, stmt: p.RawStmt) -> None:
pass pass
@@ -192,8 +228,9 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
def visit_variable_expr(self, expr: p.VariableExpr) -> None: def visit_variable_expr(self, expr: p.VariableExpr) -> None:
if len(self.scopes) != 0 and self.scopes[-1].get(expr.name) is False: if len(self.scopes) != 0 and self.scopes[-1].get(expr.name) is False:
raise ResolverError( self.reporter.error(
f"Cannot use local variable '{expr.name}' in its own initializer" expr.location,
f"Variable '{expr.name}' is declared but may not be defined",
) # aka. UnboundLocalError ) # aka. UnboundLocalError
self.resolve_local(expr, expr.name) self.resolve_local(expr, expr.name)
@@ -232,5 +269,9 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
if expr.step is not None: if expr.step is not None:
self.resolve(expr.step) self.resolve(expr.step)
def visit_tuple_expr(self, expr: p.TupleExpr) -> None:
for item in expr.items:
self.resolve(item)
def visit_raw_expr(self, expr: p.RawExpr) -> None: def visit_raw_expr(self, expr: p.RawExpr) -> None:
pass pass

View File

@@ -1,17 +1,25 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from enum import StrEnum
from typing import Optional, assert_never, cast
import midas.ast.midas as m
from midas.ast.printer import MidasPrinter
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class TopType: class TopType:
"""The top type (`Any`)"""
def __str__(self) -> str: def __str__(self) -> str:
return "Any" return "Any"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class BaseType: class BaseType:
"""A base / builtin type"""
name: str name: str
def __str__(self) -> str: def __str__(self) -> str:
@@ -19,7 +27,9 @@ class BaseType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AliasType: class DerivedType:
"""A derived type, i.e. a named subtype of another type"""
name: str name: str
type: Type type: Type
@@ -29,89 +39,111 @@ class AliasType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class UnknownType: class UnknownType:
"""An unknown type"""
def __str__(self) -> str: def __str__(self) -> str:
return "<Unknown>" return "<Unknown>"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class UnitType: class UnitType:
"""The unit type (`None`)"""
def __str__(self) -> str: def __str__(self) -> str:
return "None" return "None"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Function: class Function:
pos_args: list[Argument] = field(default_factory=list) """A function type"""
args: list[Argument] = field(default_factory=list)
kw_args: list[Argument] = field(default_factory=list) params: ParamSpec
returns: Type returns: Type
def __str__(self) -> str: def __str__(self) -> str:
args: list[str] = [] return f"{self.params} -> {self.returns}"
if len(self.pos_args) != 0:
args += list(map(str, self.pos_args))
args.append("/")
if len(self.args) != 0:
args += list(map(str, self.args))
if len(self.kw_args) != 0:
args.append("*")
args += list(map(str, self.kw_args))
return f"({', '.join(args)}) -> {self.returns}"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Argument: class Parameter:
pos: int pos: int
name: str name: str
type: Type type: Type
required: bool required: bool
unsupported: bool = False
def __str__(self) -> str: def __str__(self) -> str:
opt: str = "" if self.required else "?" opt: str = "" if self.required else "?"
return f"{self.name}: {self.type}{opt}" param: str = f"{self.name}: {self.type}{opt}"
if self.unsupported:
param = f"({param})"
return param
@dataclass(frozen=True, kw_only=True)
class ParamSpec:
"""A function's parameter spec"""
pos: list[Function.Parameter] = field(default_factory=list)
mixed: list[Function.Parameter] = field(default_factory=list)
kw: list[Function.Parameter] = field(default_factory=list)
def __str__(self) -> str:
params: list[str] = []
if len(self.pos) != 0:
params += list(map(str, self.pos))
params.append("/")
if len(self.mixed) != 0:
params += list(map(str, self.mixed))
if len(self.kw) != 0:
params.append("*")
params += list(map(str, self.kw))
return f"({', '.join(params)})"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class OverloadedFunction: class OverloadedFunction:
"""A list of method overloads"""
overloads: list[Type] overloads: list[Type]
def __str__(self) -> str: def __str__(self) -> str:
return "<overloaded function>" return "<overloaded function>"
@dataclass(frozen=True, kw_only=True) class Variance(StrEnum):
class ComplexType: """The variance of a :class:`TypeVar`"""
members: dict[str, Type]
def __str__(self) -> str: INVARIANT = "INVARIANT"
props: list[str] = [f"{name}: {type}" for name, type in self.members.items()] COVARIANT = "COVARIANT"
return f"{{{', '.join(props)}}}" CONTRAVARIANT = "CONTRAVARIANT"
@dataclass(frozen=True, kw_only=True)
class ExtensionType:
base: Type
extension: ComplexType
def __str__(self) -> str:
return f"{self.base} & {self.extension}"
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class TypeVar: class TypeVar:
"""A type variable, often used as type parameters for a generic type"""
name: str name: str
bound: Optional[Type] bound: Optional[Type]
variance: Variance = Variance.INVARIANT
def __str__(self) -> str: def __str__(self) -> str:
variance: str = {
Variance.COVARIANT: "+",
Variance.CONTRAVARIANT: "-",
}.get(self.variance, "")
res: str = f"{variance}{self.name}"
if self.bound is not None: if self.bound is not None:
return f"{self.name} <: {self.bound}" res = f"{res} <: {self.bound}"
return self.name return res
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class GenericType: class GenericType:
"""A generic type, with type parameters and a generic body type"""
name: str name: str
params: list[TypeVar] params: list[TypeVar]
body: Type body: Type
@@ -122,6 +154,8 @@ class GenericType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AppliedType: class AppliedType:
"""An instance of a :class:`GenericType`, with concrete type arguments substituted in its body"""
name: str name: str
args: list[Type] args: list[Type]
body: Type body: Type
@@ -130,35 +164,132 @@ class AppliedType:
return f"{self.name}[{', '.join(map(str, self.args))}]" return f"{self.name}[{', '.join(map(str, self.args))}]"
@dataclass(frozen=True, kw_only=True)
class ConstraintType:
"""A type with a constraint expression"""
type: Type
constraint: m.Expr
def __str__(self) -> str:
printer = MidasPrinter()
return f"{self.type} where {printer.print(self.constraint)}"
@dataclass(frozen=True, kw_only=True)
class TupleType:
"""A tuple type, containing any number of ordered item types"""
items: tuple[Type, ...]
def __str__(self) -> str:
return f"({', '.join(map(str, self.items))})"
@dataclass(frozen=True, kw_only=True)
class ColumnType:
"""A column type containing items of a given unique type"""
type: Type
def __str__(self) -> str:
return f"Column[{self.type}]"
@dataclass(frozen=True, kw_only=True)
class DataFrameType:
"""A data-frame type, containing named columns of specific :class:`ColumnType`"""
columns: list[Column]
def __str__(self) -> str:
schema: list[str] = [f"{col.name}: {col.type}" for col in self.columns]
return f"Frame[{', '.join(schema)}]"
@dataclass(frozen=True, kw_only=True)
class Column:
index: int
name: Optional[str]
type: ColumnType
@dataclass(frozen=True, kw_only=True)
class FrameGroupBy:
"""A frame group-by object"""
frame: DataFrameType
def __str__(self) -> str:
return f"FrameGroupBy[{self.frame}]"
@dataclass(frozen=True, kw_only=True)
class ColumnGroupBy:
"""A column group-by object"""
column: ColumnType
def __str__(self) -> str:
return f"ColumnGroupBy[{self.column}]"
def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type: def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type:
def sub_argument(arg: Function.Argument): """Substitute type variables in the given type
return Function.Argument(
pos=arg.pos, This function is called recursively on inner type structures
name=arg.name,
type=substitute_typevars(arg.type, substitutions), Args:
required=arg.required, type (Type): the type in which to substitute type variables
substitutions (dict[str, Type]): a mapping of type variable names to
concrete types
Returns:
Type: the resulting type with substitutions applied
"""
def sub_parameter(param: Function.Parameter):
return Function.Parameter(
pos=param.pos,
name=param.name,
type=substitute_typevars(param.type, substitutions),
required=param.required,
)
def sub_param_spec(spec: ParamSpec):
return ParamSpec(
pos=list(map(sub_parameter, spec.pos)),
mixed=list(map(sub_parameter, spec.mixed)),
kw=list(map(sub_parameter, spec.kw)),
)
def sub_column(col: DataFrameType.Column):
return DataFrameType.Column(
index=col.index,
name=col.name,
type=cast(ColumnType, substitute_typevars(col.type, substitutions)),
) )
match type: match type:
case TopType():
return type
case BaseType(name=name) if name in substitutions: case BaseType(name=name) if name in substitutions:
return substitutions[name] return substitutions[name]
case BaseType(): case BaseType():
return type return type
case AliasType(name=name, type=type2): case DerivedType(name=name, type=type2):
return AliasType(name=name, type=substitute_typevars(type2, substitutions)) return DerivedType(
name=name, type=substitute_typevars(type2, substitutions)
)
case Function( case Function(
pos_args=pos_args, params=params,
args=args,
kw_args=kw_args,
returns=returns, returns=returns,
): ):
return Function( return Function(
pos_args=list(map(sub_argument, pos_args)), params=sub_param_spec(params),
args=list(map(sub_argument, args)),
kw_args=list(map(sub_argument, kw_args)),
returns=substitute_typevars(returns, substitutions), returns=substitute_typevars(returns, substitutions),
) )
@@ -170,24 +301,6 @@ def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type:
] ]
) )
case ComplexType(members=members):
members2: dict[str, Type] = {
name: substitute_typevars(prop, substitutions)
for name, prop in members.items()
}
return ComplexType(members=members2)
case ExtensionType(base=base, extension=ComplexType(members=members)):
return ExtensionType(
base=substitute_typevars(base, substitutions),
extension=ComplexType(
members={
name: substitute_typevars(prop, substitutions)
for name, prop in members.items()
}
),
)
case AppliedType(name=name, args=args, body=body): case AppliedType(name=name, args=args, body=body):
return AppliedType( return AppliedType(
name=name, name=name,
@@ -195,37 +308,181 @@ def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type:
body=substitute_typevars(body, substitutions), body=substitute_typevars(body, substitutions),
) )
case ConstraintType():
return ConstraintType(
type=substitute_typevars(type.type, substitutions),
constraint=type.constraint,
)
case TypeVar(name=name): case TypeVar(name=name):
if name in substitutions: if name in substitutions:
return substitutions[name] return substitutions[name]
raise ValueError(f"Missing TypeVar substitution for {name}") raise ValueError(f"Missing TypeVar substitution for {name}")
case GenericType(name=name, params=params, body=body):
params2: list[TypeVar] = []
for param in params:
param2: Type = substitute_typevars(param, substitutions)
if not isinstance(param2, TypeVar):
raise ValueError(
f"Invalid type parameter substitution, expected TypeVar, got {param2}"
)
params2.append(param2)
return GenericType(
name=name,
params=params2,
body=substitute_typevars(body, substitutions),
)
case TupleType(items=items):
return TupleType(
items=tuple(substitute_typevars(item, substitutions) for item in items),
)
case ColumnType(type=items_type):
return ColumnType(
type=substitute_typevars(items_type, substitutions),
)
case DataFrameType(columns=columns):
return DataFrameType(
columns=list(map(sub_column, columns)),
)
case FrameGroupBy(frame=frame):
return FrameGroupBy(
frame=cast(DataFrameType, substitute_typevars(frame, substitutions))
)
case ColumnGroupBy(column=column):
return ColumnGroupBy(
column=cast(ColumnType, substitute_typevars(column, substitutions))
)
case UnknownType() | UnitType(): case UnknownType() | UnitType():
return type return type
case _: case TopType() | GenericType():
raise NotImplementedError(f"Unsupported type {type}") raise NotImplementedError(f"Unsupported type {type}")
# Ensure exhaustiveness
case _:
assert_never(type)
def unfold_type(type: Type) -> Type: def unfold_type(type: Type) -> Type:
"""Unfold a chain of :class:`DerivedType` to get the root supertype
Args:
type (Type): the type to unfold
Returns:
Type: the root supertype
"""
match type: match type:
case AliasType(type=ref_type): case DerivedType(type=ref_type):
return unfold_type(ref_type) return unfold_type(ref_type)
case _: case _:
return type return type
def to_annotation(type: Type) -> str:
"""Convert the given type to a Python annotation string
Args:
type (Type): the type to convert
Returns:
str: the annotation string
"""
def _params_annotation(spec: ParamSpec) -> str:
if len(spec.kw) != 0:
return "..."
params: str = ", ".join(
to_annotation(param.type) for param in spec.pos + spec.mixed
)
return f"[{params}]"
match type:
case TopType():
return "Any"
case BaseType(name=name):
return name
case DerivedType(name=name):
return name
case UnknownType():
return "Any"
case UnitType():
return "None"
case Function(params=params, returns=returns):
params_annot: str = _params_annotation(params)
return f"Callable[{params_annot}, {to_annotation(returns)}]"
case OverloadedFunction():
return "Callable"
case TypeVar(name=name):
return name
case GenericType(name=name, params=params):
return f"{name}[{', '.join(map(to_annotation, params))}]"
case AppliedType(name=name, args=args):
return f"{name}[{', '.join(map(to_annotation, args))}]"
case ConstraintType():
return str(type)
case TupleType(items=items):
return f"Tuple[{', '.join(map(to_annotation, items))}]"
case ColumnType():
return "pd.Series"
case DataFrameType():
return "pd.DataFrame"
case FrameGroupBy():
return "pd.api.typing.DataFrameGroupBy"
case ColumnGroupBy():
return "pd.api.typing.SeriesGroupBy"
case _:
assert_never(type)
@dataclass(frozen=True, kw_only=True)
class Predicate:
"""A predicate"""
type: Type
body: m.Expr
alias: bool
Type = ( Type = (
TopType TopType
| BaseType | BaseType
| AliasType | DerivedType
| UnknownType | UnknownType
| UnitType | UnitType
| Function | Function
| OverloadedFunction | OverloadedFunction
| ComplexType
| ExtensionType
| TypeVar | TypeVar
| GenericType | GenericType
| AppliedType | AppliedType
| ConstraintType
| TupleType
| ColumnType
| DataFrameType
| FrameGroupBy
| ColumnGroupBy
) )

268
midas/checker/unifier.py Normal file
View File

@@ -0,0 +1,268 @@
import logging
from typing import Optional
from midas.checker.registry import TypesRegistry
from midas.checker.types import (
AppliedType,
ColumnType,
DataFrameType,
Function,
GenericType,
ParamSpec,
TopType,
Type,
TypeVar,
)
class UnificationError(Exception): ...
class Unifier:
"""
Helper class to unify generic types in concrete usages
This can be used for example when a generic function is called with concrete
arguments, at which point the type parameters of the function signature
should be resolvable
"""
def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types
self.logger: logging.Logger = logging.getLogger("Unifier")
def unify_call(
self,
type: GenericType,
positional: list[Type],
keywords: dict[str, Type],
) -> Optional[Type]:
"""Try and unify a generic function call given concrete arguments
Args:
type (GenericType): the generic function type
positional (list[Type]): the list of positional arguments
keywords (dict[str, Type]): the map of keyword arguments
Returns:
Optional[Type]: the concrete function type if unifiable, or `None`
"""
concrete_func: Function = Function(
params=ParamSpec(
pos=[
Function.Parameter(
pos=i,
name=str(i),
type=arg,
required=True,
)
for i, arg in enumerate(positional)
],
kw=[
Function.Parameter(
pos=len(positional) + i,
name=name,
type=arg,
required=True,
)
for i, (name, arg) in enumerate(keywords.items())
],
),
returns=TopType(), # TODO: use expected type
)
return self.unify_generic(type, concrete_func, match_return=False)
def unify_generic(
self,
template: GenericType,
concrete: Type,
match_return: bool = True,
) -> Optional[Type]:
"""Unify a generic type's parameters given a concrete usage
Args:
template (GenericType): the generic type
concrete (Type): a concrete usage
match_return (bool, optional): if `template` is a function type,
whether its return type must be matched (see :func:`match`).
Defaults to True.
Returns:
Optional[Type]: the concrete type if unifiable, or `None`
"""
substitutions: dict[str, Type]
try:
substitutions = self.match(template.body, concrete, match_return)
except UnificationError:
return None
args: list[Type] = []
for param in template.params:
if param.name not in substitutions:
return None
args.append(substitutions[param.name])
applied: Type = self.types.apply_generic(template, args)
return applied
def match(
self,
template: Type,
concrete: Type,
match_return: bool = True,
) -> dict[str, Type]:
"""Match a generic type with a concrete usage, recording parameter substitutions
Args:
template (Type): the generic type
concrete (Type): a concrete usage
match_return (bool, optional): if `template` and `concrete` are both
:class:`Function`, whether their return types are also matched.
Defaults to True.
Raises:
UnificationError: if there is a conflict in parameter substitutions
Returns:
dict[str, Type]: the parameter substitutions which,
applied to `template`, yield `concrete`
"""
# TODO: if concrete is Generic, record bound TypeVar. Then when merging
# substitutions, check that the constraint is respected
match (template, concrete):
case (TypeVar(name=name), _):
return {name: concrete}
case (
AppliedType(name=template_name, args=template_args),
AppliedType(name=concrete_name, args=concrete_args),
) if template_name == concrete_name and len(template_args) == len(
concrete_args
):
substitutions: dict[str, Type] = {}
for template_arg, concrete_arg in zip(template_args, concrete_args):
new_substistutions: dict[str, Type] = self.match(
template_arg, concrete_arg
)
substitutions = self.merge(substitutions, new_substistutions)
return substitutions
case (
DataFrameType(columns=template_columns),
DataFrameType(columns=concrete_columns),
) if len(template_columns) == len(concrete_columns):
substitutions: dict[str, Type] = {}
for template_column, concrete_column in zip(
template_columns, concrete_columns
):
if template_column.index != concrete_column or (
template_column.name != concrete_column.name
):
self.logger.debug(
f"Column mismatch: template={template_column}, concrete={concrete_column}"
)
raise UnificationError
new_substistutions: dict[str, Type] = self.match(
template_column.type, concrete_column.type
)
substitutions = self.merge(substitutions, new_substistutions)
return substitutions
case (ColumnType(type=template_column), ColumnType(type=concrete_column)):
return self.match(template_column, concrete_column)
case (Function(), Function()):
mapped: list[tuple[Function.Parameter, Function.Parameter]] = (
self.map_params(template, concrete)
)
substitutions: dict[str, Type] = {}
for template_arg, concrete_arg in mapped:
arg_subs: dict[str, Type] = self.match(
template_arg.type, concrete_arg.type
)
substitutions = self.merge(substitutions, arg_subs)
if match_return:
return_subs: dict[str, Type] = self.match(
template.returns, concrete.returns
)
substitutions = self.merge(substitutions, return_subs)
return substitutions
case _:
self.logger.debug(f"Can't match {concrete!r} with {template!r}")
return {}
def merge(self, subs1: dict[str, Type], subs2: dict[str, Type]) -> dict[str, Type]:
"""Merge two maps of substitutions and raise an error if incompatible
Args:
subs1 (dict[str, Type]): the first substitutions
subs2 (dict[str, Type]): the second substitutions
Raises:
UnificationError: if there is a conflict between the two maps
Returns:
dict[str, Type]: the merged map of substitutions
"""
merged: dict[str, Type] = subs1.copy()
for k, v in subs2.items():
if k in merged and merged[k] != v:
self.logger.debug(
f"Substitution already defined for {k} with type {merged[k]}, got {v}"
)
raise UnificationError
merged[k] = v
return merged
def map_params(
self, func1: Function, func2: Function
) -> list[tuple[Function.Parameter, Function.Parameter]]:
"""Map parameters of two functions
Args:
func1 (Function): the first function
func2 (Function): the second function
Returns:
list[tuple[Function.Parameter, Function.Parameter]]: the list of parameter pairs
"""
pos1: list[Function.Parameter] = func1.params.pos
mixed1: list[Function.Parameter] = func1.params.mixed
kw1: list[Function.Parameter] = func1.params.kw
pos2: list[Function.Parameter] = func2.params.pos
mixed2: list[Function.Parameter] = func2.params.mixed
kw2: list[Function.Parameter] = func2.params.kw
mapped: list[tuple[Function.Parameter, Function.Parameter]] = []
by_pos2: dict[int, Function.Parameter] = {
param.pos: param for param in pos2 + mixed2
}
by_name2: dict[str, Function.Parameter] = {
param.name: param for param in mixed2 + kw2
}
for arg1 in pos1:
if (arg2 := by_pos2.get(arg1.pos)) is not None:
mapped.append((arg1, arg2))
for arg1 in mixed1:
# Match both positionally and by name, conflicts are caught
# when merging substitutions
if (arg2 := by_pos2.get(arg1.pos)) is not None:
mapped.append((arg1, arg2))
if (arg2 := by_name2.get(arg1.name)) is not None:
mapped.append((arg1, arg2))
for arg1 in kw1:
if (arg2 := by_name2.get(arg1.name)) is not None:
mapped.append((arg1, arg2))
return mapped

181
midas/checker/variance.py Normal file
View File

@@ -0,0 +1,181 @@
from typing import Literal, Optional, cast
from midas.checker.registry import Member, TypesRegistry
from midas.checker.types import (
AppliedType,
ConstraintType,
Function,
GenericType,
OverloadedFunction,
Type,
TypeVar,
Variance,
)
Polarity = Literal[-1, 0, 1]
class Tracker:
"""Helper class to track the polarity of type parameter references and computer their variance"""
def __init__(self, vars: list[TypeVar]) -> None:
self.vars: list[TypeVar] = vars
self.refs: dict[str, set[Polarity]] = {var.name: set() for var in self.vars}
def record(self, var: TypeVar, polarity: Polarity):
"""Record a polarity of the given type parameter
Args:
var (TypeVar): the type parameter
polarity (Polarity): the polarity
"""
self.refs[var.name].add(polarity)
def get_updated_vars(self) -> list[TypeVar]:
"""Get a list of the tracked type variables with their recorded variance
Returns:
list[TypeVar]: the list of update type parameters
"""
return [
TypeVar(
name=var.name, bound=var.bound, variance=self.get_variance(var.name)
)
for var in self.vars
]
def get_variance(self, name: str) -> Variance:
"""Get the variance of a type parameter
If the type parameter is only referenced in positive positions, it is
covariant. If it is only referenced in negative positions, it is
contravariant. Otherwise, it is invariant
Args:
name (str): the name of the type parameter
Returns:
Variance: the variance of the type parameter
"""
refs: set[Polarity] = self.refs[name]
if refs == {-1}:
return Variance.CONTRAVARIANT
if refs == {1}:
return Variance.COVARIANT
return Variance.INVARIANT
def __contains__(self, item: TypeVar | str):
if isinstance(item, TypeVar):
return item.name in self
return item in self.refs
class VarianceInferrer:
"""Helper class to compute type parameter variance"""
def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types
self.tracker: Tracker = Tracker([])
def infer(self, type: GenericType) -> GenericType:
"""Infer the variance of a generic type's parameters
Args:
type (GenericType): the generic type
Returns:
GenericType: a new generic type with its parameters updated with
their inferred variance
"""
self.tracker = Tracker(type.params)
self.walk(type.body, 1, type.name)
members: dict[str, Member] = self.types._members.get(type.name, {})
for name, member in members.items():
self.walk(member.type, 1, type.name, [f"member:'{name}'"])
return GenericType(
name=type.name,
params=self.tracker.get_updated_vars(),
body=type.body,
)
def walk(
self,
type: Type,
polarity: Polarity,
base_name: str,
path: Optional[list[str]] = None,
):
"""Walk the type nodes and record variance
This function recurses into type substructures (e.g. function parameters,
overloads, constraint type bases, etc.)
When recursing, the polarity is flipped for consumer positions (e.g. function
parameters) or kept the same for producer positions (e.g. return type)
Args:
type (Type): the type to visit
polarity (Polarity): the current polarity
base_name (str): the root generic type name (used to detect and
handle cyclic references)
path (Optional[list[str]], optional): the path to reach the current
type from the root generic type (used for debugging). Defaults to None.
"""
if path is None:
path = []
match type:
# Arguments are negative positions -> flip polarity
# Return is positive position -> keep polarity
case Function(params=spec):
all_params: list[Function.Parameter] = spec.pos + spec.mixed + spec.kw
for param in all_params:
self.walk(
param.type,
-polarity,
base_name,
path + [f"param:'{param.name}'"],
)
self.walk(type.returns, polarity, base_name, path + ["return"])
# Walk all overloads
case OverloadedFunction(overloads=overloads):
for overload in overloads:
self.walk(overload, polarity, base_name, path)
# If same name as root generic -> skip
# Get inferred variance of parameters and multiply with current
# polarity to recurse through arguments
case AppliedType(name=name, args=args):
# TODO: handle mutually recursive types
if name == base_name:
return
generic: Type = self.types.get_type(name)
assert isinstance(generic, GenericType)
params: list[TypeVar] = generic.params
polarities: dict[Variance, Polarity] = {
Variance.INVARIANT: 0,
Variance.COVARIANT: 1,
Variance.CONTRAVARIANT: -1,
}
for arg, param in zip(args, params):
param_polarity: Polarity = polarities[param.variance]
self.walk(
arg,
cast(Polarity, polarity * param_polarity),
base_name,
path + [f"applied:'{name}'"],
)
# Walk base type
case ConstraintType(type=base):
self.walk(base, polarity, base_name, path + ["constraint"])
# Reached end
# If tracked, record polarity
case TypeVar():
if type in self.tracker:
self.tracker.record(type, polarity)

View File

@@ -4,5 +4,6 @@ from .format import format as format
from .highlight import highlight as highlight from .highlight import highlight as highlight
from .parse import parse as parse from .parse import parse as parse
from .registry import dump_registry as dump_registry from .registry import dump_registry as dump_registry
from .stubs import stubs as stubs
from .types import types as types from .types import types as types
from .validate import validate as validate from .validate import validate as validate

View File

@@ -5,7 +5,7 @@
import sys import sys
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import Optional, TextIO
import click import click
@@ -19,24 +19,33 @@ from midas.utils import TypedAST
@click.command(help="Compile source") @click.command(help="Compile source")
@click.argument("file", type=click.File("r")) @click.argument("file", type=click.File("r"))
@click.option("-t", "--types", type=click.File("r"), multiple=True) @click.option("-t", "--types", type=click.File("r"), multiple=True)
@click.option("-s", "--stubs", type=str, multiple=True)
@click.option("--ignore-errors", is_flag=True)
def compile( def compile(
file: TextIO, file: TextIO,
types: tuple[TextIO], types: tuple[TextIO],
stubs: tuple[str],
ignore_errors: bool,
): ):
source: str = file.read() source: str = file.read()
source_path: Path = Path(file.name).resolve() source_path: Path = Path(file.name).resolve()
checker = TypeChecker() checker = TypeChecker()
for types_file in types: type_files: list[tuple[Path, Optional[str]]] = []
checker.import_midas(Path(types_file.name).resolve()) for i, types_file in enumerate(types):
in_path: Path = Path(types_file.name).resolve()
checker.import_midas(in_path)
type_files.append((in_path, stubs[i] if i < len(stubs) else None))
typed_ast: TypedAST = checker.type_check_source(source, str(source_path)) typed_ast: TypedAST = checker.type_check_source(source, str(source_path))
diagnostics: list[Diagnostic] = checker.diagnostics.copy() diagnostics: list[Diagnostic] = checker.diagnostics.copy()
printer = DiagnosticPrinter() printer = DiagnosticPrinter()
printer.print_all(diagnostics) printer.print_all(diagnostics)
if any(map(lambda d: d.type == DiagnosticType.ERROR, diagnostics)): if not ignore_errors and any(
map(lambda d: d.type == DiagnosticType.ERROR, diagnostics)
):
sys.exit(1) sys.exit(1)
generator = Generator(workdir=source_path.parent) generator = Generator(workdir=source_path.parent, types=checker.types)
generator.generate(typed_ast, source_path) generator.generate(typed_ast, source_path, type_files=type_files)

View File

@@ -8,15 +8,17 @@ from typing import TextIO
import click import click
from midas.ast.printer import MidasPrinter
from midas.checker.checker import TypeChecker from midas.checker.checker import TypeChecker
from midas.checker.types import AliasType, AppliedType, BaseType, GenericType, Type from midas.checker.registry import Member
from midas.checker.types import AppliedType, BaseType, DerivedType, GenericType, Type
def base_type(type: Type) -> Type: def base_type(type: Type) -> Type:
match type: match type:
case BaseType(): case BaseType():
return type return type
case AliasType(type=base): case DerivedType(type=base):
return base return base
case AppliedType(body=body): case AppliedType(body=body):
return body return body
@@ -35,10 +37,30 @@ def dump_registry(
for types_file in types: for types_file in types:
checker.import_midas(Path(types_file.name).resolve()) checker.import_midas(Path(types_file.name).resolve())
print("##### Types #####")
for name, type in checker.types._types.items(): for name, type in checker.types._types.items():
members: dict[str, Type] = checker.types._members.get(name, {}) members: dict[str, Member] = checker.types._members.get(name, {})
print(f"{name} = {base_type(type)}") params: str = ""
if isinstance(type, GenericType):
params = ", ".join(map(str, type.params))
params = f"[{params}]"
print(f"{name}{params} = {base_type(type)}")
if len(members) != 0: if len(members) != 0:
print(" " * 4 + "Members:") print(" " * 4 + "Members:")
for member_name, member_type in members.items(): for member_name, member in members.items():
print(" " * 8 + f"{member_name}: {member_type}") kind: str = member.kind.name
print(" " * 8 + f"({kind:8}) {member_name}: {member.type}")
print("##### Predicates #####")
printer = MidasPrinter()
for name, predicate in checker.types._predicates.items():
body: str = printer.print(predicate.body)
if predicate.alias:
print(f"{name}: {predicate.type} = {body}")
else:
print(f"{name}{predicate.type}:")
body = "\n".join(
" " + ("return " if i == 0 else "") + line
for i, line in enumerate(body.split("\n"))
)
print(body)

View File

@@ -0,0 +1,66 @@
import ast
import time
from pathlib import Path
from typing import Optional, TextIO
import black
import click
from watchdog.events import DirModifiedEvent, FileModifiedEvent, FileSystemEventHandler
from watchdog.observers import Observer
from midas.checker.checker import TypeChecker
from midas.generator.stubs import StubsGenerator
def generate_stubs(in_path: Path, out_path: Path):
checker = TypeChecker()
checker.import_midas(in_path)
generator = StubsGenerator(checker.types)
module: ast.Module = generator.generate_stubs()
module = ast.fix_missing_locations(module)
output: str = ast.unparse(module)
output = black.format_str(output, mode=black.Mode(is_pyi=True))
out_path.write_text(output)
class Handler(FileSystemEventHandler):
def __init__(self, in_path: Path, out_path: Path) -> None:
super().__init__()
self.in_path: Path = in_path
self.out_path: Path = out_path
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
generate_stubs(self.in_path, self.out_path)
@click.command(help="Generate stubs from Midas definitions")
@click.argument("file", type=click.File("r"))
@click.option("-o", "--output", type=click.File("w"))
@click.option("-w", "--watch", is_flag=True)
def stubs(
file: TextIO,
output: Optional[TextIO],
watch: bool,
):
source_path: Path = Path(file.name).resolve()
out_path: Path = source_path.with_suffix(".pyi")
if output is not None:
out_path = Path(output.name).resolve()
generate_stubs(source_path, out_path)
if watch:
print(f"Watching {source_path}...")
print("Press CTRL+C to stop")
handler = Handler(source_path, out_path)
observer = Observer()
observer.schedule(handler, str(source_path))
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()

View File

@@ -41,6 +41,7 @@ def types(
message=f"Type: {type}", message=f"Type: {type}",
) )
) )
diagnostics.extend(checker.diagnostics)
printer = DiagnosticPrinter() printer = DiagnosticPrinter()
printer.print_all(diagnostics) printer.print_all(diagnostics)

View File

@@ -134,9 +134,9 @@ class PythonHighlighter(
def visit_base_type(self, node: p.BaseType) -> None: def visit_base_type(self, node: p.BaseType) -> None:
self.wrap(node, "base-type") self.wrap(node, "base-type")
if node.param is not None: for arg in node.args:
self.wrap(node.param, "param") self.wrap(arg, "arg")
node.param.accept(self) arg.accept(self)
def visit_constraint_type(self, node: p.ConstraintType) -> None: def visit_constraint_type(self, node: p.ConstraintType) -> None:
self.wrap(node, "constraint-type") self.wrap(node, "constraint-type")
@@ -157,15 +157,18 @@ class PythonHighlighter(
def visit_function(self, stmt: p.Function) -> None: def visit_function(self, stmt: p.Function) -> None:
self.wrap(stmt, "function") self.wrap(stmt, "function")
for arg in stmt.posonlyargs + stmt.args + stmt.kwonlyargs: self._highlight_param_spec(stmt.params)
self._highlight_function_argument(arg)
for body_stmt in stmt.body: for body_stmt in stmt.body:
body_stmt.accept(self) body_stmt.accept(self)
def _highlight_function_argument(self, arg: p.Function.Argument) -> None: def _highlight_param_spec(self, spec: p.ParamSpec) -> None:
self.wrap(arg, "argument") for param in spec.all:
if arg.type is not None: self._highlight_function_param(param)
arg.type.accept(self)
def _highlight_function_param(self, param: p.Function.Parameter) -> None:
self.wrap(param, "parameter")
if param.type is not None:
param.type.accept(self)
def visit_type_assign(self, stmt: p.TypeAssign) -> None: def visit_type_assign(self, stmt: p.TypeAssign) -> None:
stmt.type.accept(self) stmt.type.accept(self)
@@ -228,6 +231,13 @@ class PythonHighlighter(
for item in expr.items: for item in expr.items:
item.accept(self) item.accept(self)
def visit_dict_expr(self, expr: p.DictExpr) -> None:
for key in expr.keys:
if key is not None:
key.accept(self)
for value in expr.values:
value.accept(self)
def visit_subscript_expr(self, expr: p.SubscriptExpr) -> None: def visit_subscript_expr(self, expr: p.SubscriptExpr) -> None:
expr.object.accept(self) expr.object.accept(self)
expr.index.accept(self) expr.index.accept(self)
@@ -240,6 +250,14 @@ class PythonHighlighter(
if expr.step is not None: if expr.step is not None:
expr.step.accept(self) expr.step.accept(self)
def visit_tuple_expr(self, expr: p.TupleExpr) -> None:
for item in expr.items:
item.accept(self)
def visit_raw_expr(self, expr: p.RawExpr) -> None: ...
def visit_raw_stmt(self, stmt: p.RawStmt) -> None: ...
class MidasHighlighter( class MidasHighlighter(
Highlighter, m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[None] Highlighter, m.Stmt.Visitor[None], m.Expr.Visitor[None], m.Type.Visitor[None]
@@ -266,8 +284,9 @@ class MidasHighlighter(
def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None: def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None:
self.wrap(stmt, "predicate") self.wrap(stmt, "predicate")
self.wrap(LocatableToken(stmt.name), "predicate-name") self.wrap(LocatableToken(stmt.name), "predicate-name")
stmt.type.accept(self) for spec in stmt.params:
stmt.condition.accept(self) self._visit_param_spec(spec)
stmt.body.accept(self)
def visit_logical_expr(self, expr: m.LogicalExpr) -> None: def visit_logical_expr(self, expr: m.LogicalExpr) -> None:
self.wrap(expr, "logical-expr") self.wrap(expr, "logical-expr")
@@ -283,6 +302,14 @@ class MidasHighlighter(
self.wrap(expr, "unary-expr") self.wrap(expr, "unary-expr")
expr.right.accept(self) expr.right.accept(self)
def visit_call_expr(self, expr: m.CallExpr) -> None:
self.wrap(expr, "call-expr")
expr.callee.accept(self)
for arg in expr.arguments:
arg.accept(self)
for arg in expr.keywords.values():
arg.accept(self)
def visit_get_expr(self, expr: m.GetExpr) -> None: def visit_get_expr(self, expr: m.GetExpr) -> None:
self.wrap(expr, "get-expr") self.wrap(expr, "get-expr")
expr.expr.accept(self) expr.expr.accept(self)
@@ -311,21 +338,22 @@ class MidasHighlighter(
type.type.accept(self) type.type.accept(self)
type.constraint.accept(self) type.constraint.accept(self)
def visit_complex_type(self, type: m.ComplexType) -> None:
self.wrap(type, "complex-type")
for member in type.members:
member.accept(self)
def visit_function_type(self, type: m.FunctionType) -> None: def visit_function_type(self, type: m.FunctionType) -> None:
self.wrap(type, "function") self.wrap(type, "function")
for arg in type.pos_args + type.args + type.kw_args: self._visit_param_spec(type.params)
arg.type.accept(self)
type.returns.accept(self) type.returns.accept(self)
def visit_extension_type(self, type: m.ExtensionType) -> None: def _visit_param_spec(self, spec: m.ParamSpec) -> None:
self.wrap(type, "extension") for param in spec.pos + spec.mixed + spec.kw:
type.base.accept(self) param.type.accept(self)
type.extension.accept(self)
def visit_frame_type(self, type: m.FrameType) -> None:
self.wrap(type, "frame")
for column in type.columns:
self._visit_frame_column(column)
def _visit_frame_column(self, column: m.FrameType.Column) -> None:
self.wrap(column, "column")
class DiagnosticsHighlighter(Highlighter): class DiagnosticsHighlighter(Highlighter):

View File

@@ -4,9 +4,11 @@ span {
&.error { &.error {
--col: 255, 0, 0; --col: 255, 0, 0;
} }
&.warning { &.warning {
--col: 250, 160, 0; --col: 250, 160, 0;
} }
&.info { &.info {
--col: 150, 190, 250; --col: 150, 190, 250;
} }
@@ -19,12 +21,12 @@ span {
} }
&:hover:not(:has(.with-msg:hover)) { &:hover:not(:has(.with-msg:hover)) {
.message { &>.message {
display: inline-block; display: inline-block;
} }
} }
.message { &>.message {
position: absolute; position: absolute;
top: calc(100% + 0.2em); top: calc(100% + 0.2em);
left: -.2em; left: -.2em;
@@ -33,7 +35,8 @@ span {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: .2em; border-radius: .2em;
z-index: 10; z-index: 10;
width: 300%; width: max-content;
max-width: 60vw;
} }
} }
} }

View File

@@ -7,8 +7,7 @@ span {
&.named-type, &.named-type,
&.generic-type, &.generic-type,
&.constraint-type, &.constraint-type {
&.complex-type {
--col: 150, 150, 150; --col: 150, 150, 150;
} }

View File

@@ -3,7 +3,7 @@ span {
--col: 108, 233, 108; --col: 108, 233, 108;
} }
&.param { &.arg {
--col: 103, 192, 224; --col: 103, 192, 224;
} }
@@ -23,7 +23,7 @@ span {
--col: 215, 103, 224; --col: 215, 103, 224;
} }
&.argument { &.parameter {
--col: 103, 192, 224; --col: 103, 192, 224;
} }
} }

View File

@@ -18,6 +18,7 @@ midas.add_command(commands.highlight)
midas.add_command(commands.parse) midas.add_command(commands.parse)
midas.add_command(commands.dump_registry) midas.add_command(commands.dump_registry)
midas.add_command(commands.types) midas.add_command(commands.types)
midas.add_command(commands.stubs)
midas.add_command(commands.validate) midas.add_command(commands.validate)

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -7,6 +8,13 @@ from midas.cli.ansi import Ansi
class DiagnosticPrinter: class DiagnosticPrinter:
COLORS: dict[DiagnosticType, int] = {
DiagnosticType.ERROR: Ansi.RED,
DiagnosticType.WARNING: Ansi.YELLOW,
DiagnosticType.INFO: Ansi.CYAN,
DiagnosticType.DEBUG: Ansi.MAGENTA,
}
def __init__(self) -> None: def __init__(self) -> None:
self.files: dict[Optional[str], list[str]] = {} self.files: dict[Optional[str], list[str]] = {}
@@ -22,10 +30,25 @@ class DiagnosticPrinter:
return self.files[filename] return self.files[filename]
def print_all(self, diagnostics: list[Diagnostic], indent: int = 4): def print_all(self, diagnostics: list[Diagnostic], indent: int = 4):
by_type: dict[DiagnosticType, int] = defaultdict(int)
for diagnostic in diagnostics: for diagnostic in diagnostics:
filename: Optional[str] = diagnostic.file_path filename: Optional[str] = diagnostic.file_path
lines = self.get_lines(filename) lines = self.get_lines(filename)
self.print(lines, diagnostic, indent=indent) self.print(lines, diagnostic, indent=indent)
by_type[diagnostic.type] += 1
if len(diagnostics) == 0:
return
counts: list[str] = []
for type in DiagnosticType:
if type not in by_type:
continue
count: int = by_type[type]
color: int = self.COLORS.get(type, Ansi.WHITE)
counts.append(f"{Ansi.FG(color)}{type.value}s{Ansi.RESET}: {count}")
print(" ".join(counts))
def print(self, lines: list[str], diagnostic: Diagnostic, indent: int = 4): def print(self, lines: list[str], diagnostic: Diagnostic, indent: int = 4):
"""Pretty-print a diagnostic, showing some context if possible """Pretty-print a diagnostic, showing some context if possible
@@ -45,7 +68,7 @@ class DiagnosticPrinter:
loc: Location = diagnostic.location loc: Location = diagnostic.location
if loc.lineno != loc.end_lineno: if loc.lineno != loc.end_lineno:
print(diagnostic) self.print_multiline(lines, diagnostic, indent)
return return
start_offset: int = loc.col_offset start_offset: int = loc.col_offset
@@ -55,11 +78,7 @@ class DiagnosticPrinter:
before: str = line[:start_offset] before: str = line[:start_offset]
after: str = line[end_offset:] after: str = line[end_offset:]
color: int = { color: int = self.COLORS.get(diagnostic.type, Ansi.WHITE)
DiagnosticType.ERROR: Ansi.RED,
DiagnosticType.WARNING: Ansi.YELLOW,
DiagnosticType.INFO: Ansi.CYAN,
}.get(diagnostic.type, Ansi.WHITE)
subject: str = Ansi.FG(color) + line[start_offset:end_offset] + Ansi.RESET subject: str = Ansi.FG(color) + line[start_offset:end_offset] + Ansi.RESET
cursor: str = ( cursor: str = (
@@ -76,3 +95,27 @@ class DiagnosticPrinter:
print(indent_str + before + subject + after) print(indent_str + before + subject + after)
print(indent_str + cursor) print(indent_str + cursor)
print() print()
def print_multiline(
self, all_lines: list[str], diagnostic: Diagnostic, indent: int = 4
):
loc: Location = diagnostic.location
lines: list[str] = all_lines[loc.lineno - 1 : loc.end_lineno]
start_offset: int = loc.col_offset
end_offset: int = loc.end_col_offset or (start_offset + 1)
indent_str: str = " " * indent
color: int = self.COLORS.get(diagnostic.type, Ansi.WHITE)
res: str = indent_str + lines[0][:start_offset]
res += Ansi.FG(color) + lines[0][start_offset:]
for line in lines[1:-1]:
res += "\n" + indent_str + line
res += "\n" + indent_str + lines[-1][:end_offset]
res += Ansi.RESET + lines[-1][end_offset:]
print(diagnostic.location_str + ":")
print(res)
print()
print(Ansi.FG(color) + diagnostic.message + Ansi.RESET)
print()

View File

@@ -0,0 +1,124 @@
import ast
from dataclasses import dataclass
from typing import Callable
import midas.ast.python as p
AssertionBuilder = Callable[..., ast.expr]
"""A callback function which builds an assertion test given some input expressions"""
@dataclass
class Assertion:
"""Runtime assertion to generate, bound to an expression"""
bound_expr: p.Expr
"""The expression the assertion is bound to"""
inputs: list[p.Expr]
"""
Expressions needed for the assertion
Each expression will be converted by the generator and passed as individual
arguments to `builder`
"""
builder: AssertionBuilder
"""The callback to build the assertion test given converted expression from `inputs`"""
message: str
"""The assertion message"""
def is_bound_to(self, expr: p.Expr) -> bool:
"""Check whether this assertion is bound to the given expression
Args:
expr (p.Expr): the expression
Returns:
bool: whether this assertion is bound to `expr`
"""
return expr == self.bound_expr
class AssertionCollector:
"""Helper class to collect assertions from outside the generator"""
def __init__(self):
self.assertions: list[Assertion] = []
self.definitions: dict[str, ast.stmt] = {}
def add(
self,
bound_expr: p.Expr,
inputs: list[p.Expr],
builder: AssertionBuilder,
message: str,
):
"""Add an assertion bound to the given expression
Args:
bound_expr (p.Expr): the expression before which the assertion
must be generated
inputs (list[p.Expr]): the list of input expressions (see :class:`Assertion`)
builder (AssertionBuilder): the builder callback (see :class:`Assertion`)
message (str): the assertion message
"""
self.assertions.append(
Assertion(
bound_expr=bound_expr,
inputs=inputs,
builder=builder,
message=message,
)
)
def remove(self, assertion: Assertion):
"""Remove the given assertion from the collection
Args:
assertion (Assertion): the assertion to remove
"""
try:
self.assertions.remove(assertion)
except ValueError:
pass
def define(self, name: str, stmt: ast.stmt):
"""Register a statement definition
This method will only register the first definition of any given name
Args:
name (str): the name of the definition
stmt (ast.stmt): the definition statement, like a function def
"""
if name not in self.definitions:
self.definitions[name] = stmt
def get_definitions(self) -> list[ast.stmt]:
"""Get the list of definitions
Returns:
list[ast.stmt]: the list of definitions
"""
return list(self.definitions.values())
def get_assertions(self) -> list[Assertion]:
"""Get the list of assertions
Returns:
list[Assertion]: the list of assertions
"""
return self.assertions
def get_assertions_for(self, expr: p.Expr) -> list[Assertion]:
"""Get the list of assertions bound to a given expression
Args:
expr (p.Expr): the expression
Returns:
list[Assertion]: the list of assertions bound to `expr`
"""
return list(filter(lambda a: a.is_bound_to(expr), self.assertions))

View File

@@ -0,0 +1,304 @@
import ast
from typing import Optional
import midas.ast.midas as m
from midas.checker.registry import TypesRegistry
from midas.checker.types import (
Function,
ParamSpec,
Predicate,
Type,
to_annotation,
)
from midas.lexer.token import TokenType
LOGICAL_OPERATORS: dict[TokenType, type[ast.boolop]] = {
TokenType.AND: ast.And,
# TokenType.OR: ast.Or,
}
BINARY_OPERATORS: dict[TokenType, type[ast.operator]] = {
TokenType.PLUS: ast.Add,
TokenType.MINUS: ast.Sub,
TokenType.STAR: ast.Mult,
TokenType.SLASH: ast.Div,
}
UNARY_OPERATORS: dict[TokenType, type[ast.unaryop]] = {
TokenType.PLUS: ast.UAdd,
TokenType.MINUS: ast.USub,
}
COMPARISON_OPERATORS: dict[TokenType, type[ast.cmpop]] = {
TokenType.GREATER: ast.Gt,
TokenType.GREATER_EQUAL: ast.GtE,
TokenType.LESS: ast.Lt,
TokenType.LESS_EQUAL: ast.LtE,
TokenType.EQUAL_EQUAL: ast.Eq,
TokenType.BANG_EQUAL: ast.NotEq,
}
class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
"""Class to generate Python code for constraint expressions"""
def __init__(self, types: TypesRegistry):
self.types: TypesRegistry = types
self._id: int = 0
self._definitions: list[ast.stmt] = []
self._aliases: dict[str, str] = {}
def get_definitions(self) -> list[ast.stmt]:
"""Get the list of definitions
Returns:
list[ast.stmt]: the list of definitions
"""
return self._definitions
def generate(self, expr: m.Expr) -> ast.expr:
"""Translate the given Midas expression to a Python expression
Args:
expr (m.Expr): the expression to translate
Returns:
ast.expr: the equivalent Python expression
"""
match expr:
case m.VariableExpr():
return expr.accept(self)
case _:
func = Function(
params=ParamSpec(
mixed=[
Function.Parameter(
pos=0,
name="_",
type=self.types.get_type("Any"),
required=True,
)
],
),
returns=self.types.get_type("bool"),
)
alias: str = self.make_alias(None)
definition: ast.stmt = self.make_definition(
alias, Predicate(type=func, body=expr, alias=False)
)
self._definitions.append(definition)
return ast.Name(id=alias)
def make_alias(self, name: Optional[str]) -> str:
"""Get a unique alias for a predicate
Args:
name (Optional[str]): the name of the predicate as defined by the user
Returns:
str: a unique name
"""
suffix: str
if name is None:
suffix = f"p{self._id}"
self._id += 1
else:
suffix = name
alias: str = f"__midas_{suffix}__"
return alias
def make_definition(self, name: str, predicate: Predicate) -> ast.stmt:
"""Translate the given predicate to a Python definition (or assignment)
Args:
name (str): the name of the predicate
predicate (Predicate): the predicate
Returns:
ast.stmt: the equivalent Python statement
"""
body: ast.expr = predicate.body.accept(self)
if predicate.alias:
return ast.Assign(
targets=[
ast.Name(id=name),
],
value=body,
)
return self.make_func(name, [ast.Return(value=body)], predicate.type)
def make_args(self, params: ParamSpec) -> ast.arguments:
"""Translate the given parameter spec into an `ast.arguments` node
Args:
params (ParamSpec): the parameter spec to translate
Returns:
ast.arguments: the equivalent `ast.arguments`
"""
return ast.arguments(
posonlyargs=[
ast.arg(
arg=param.name,
annotation=ast.Constant(value=to_annotation(param.type)),
)
for param in params.pos
],
args=[
ast.arg(
arg=param.name,
annotation=ast.Constant(value=to_annotation(param.type)),
)
for param in params.mixed
],
kwonlyargs=[
ast.arg(
arg=param.name,
annotation=ast.Constant(value=to_annotation(param.type)),
)
for param in params.kw
],
defaults=[],
kw_defaults=[],
)
def make_func(
self, name: str, inner_body: list[ast.stmt], type: Type, level: int = 0
) -> ast.stmt:
"""Generate a Python function def with the given name, body and signature
If `type` returns a function, the curried arguments are separated into
inner methods.
For example, if `type` is `(a: int) -> (b: int) -> (c: int) -> int`, the
following function would be generated:
```python
def predicate(a: int):
def inner0(b: int):
def inner1(c: int):
return ...
return inner1
return inner0
```
Args:
name (str): the name of the outer function
inner_body (list[ast.stmt]): the body of the innermost function
type (Type): the function type / signature
level (int, optional): the current nesting level. Defaults to 0.
Raises:
ValueError: if `type` is not a function
Returns:
ast.stmt: the equivalent Python function definition
"""
match type:
case Function(params=params, returns=Function()):
inner_name: str = f"inner{level}"
return ast.FunctionDef(
name=name,
args=self.make_args(params),
body=[
self.make_func(inner_name, inner_body, type.returns, level + 1),
ast.Return(value=ast.Name(id=inner_name)),
],
returns=ast.Constant(value=to_annotation(type.returns)),
decorator_list=[],
)
case Function(params=params):
return ast.FunctionDef(
name=name,
args=self.make_args(params),
body=inner_body,
returns=ast.Constant(value=to_annotation(type.returns)),
decorator_list=[],
)
case _:
raise ValueError(f"Expected function, got {type!r}")
def get_predicate(self, name: str) -> Optional[ast.expr]:
"""Get a predicate's alias, and generate its definition if first reference
When calling this function for the first time for a given predicate,
a Python definition and an alias are generated. Subsequent calls only
return the alias, without re-generating the predicate's definition
Args:
name (str): the predicate's name
Returns:
Optional[ast.expr]: the predicate's alias, or `None` if it is not defined
"""
if name not in self._aliases:
predicate: Optional[Predicate] = self.types.lookup_predicate(name)
if predicate is None:
return None
alias: str = self.make_alias(name)
self._aliases[name] = alias
self._definitions.append(self.make_definition(alias, predicate))
return ast.Name(id=self._aliases[name])
def visit_logical_expr(self, expr: m.LogicalExpr) -> ast.expr:
return ast.BoolOp(
op=LOGICAL_OPERATORS[expr.operator.type](),
values=[
expr.left.accept(self),
expr.right.accept(self),
],
)
def visit_binary_expr(self, expr: m.BinaryExpr) -> ast.expr:
op: TokenType = expr.operator.type
if op in BINARY_OPERATORS:
return ast.BinOp(
left=expr.left.accept(self),
op=BINARY_OPERATORS[op](),
right=expr.right.accept(self),
)
if op in COMPARISON_OPERATORS:
return ast.Compare(
left=expr.left.accept(self),
ops=[COMPARISON_OPERATORS[op]()],
comparators=[expr.right.accept(self)],
)
raise ValueError(f"Unexpected binary operator {op}")
def visit_unary_expr(self, expr: m.UnaryExpr) -> ast.expr:
return ast.UnaryOp(
op=UNARY_OPERATORS[expr.operator.type](),
operand=expr.right.accept(self),
)
def visit_call_expr(self, expr: m.CallExpr) -> ast.expr:
return ast.Call(
func=expr.callee.accept(self),
args=[arg.accept(self) for arg in expr.arguments],
keywords=[
ast.keyword(arg=name, value=arg.accept(self))
for name, arg in expr.keywords.items()
],
)
def visit_get_expr(self, expr: m.GetExpr) -> ast.expr:
return ast.Attribute(
value=expr.expr.accept(self),
attr=expr.name.lexeme,
)
def visit_variable_expr(self, expr: m.VariableExpr) -> ast.expr:
name: str = expr.name.lexeme
if (p := self.get_predicate(name)) is not None:
return p
return ast.Name(id=name)
def visit_grouping_expr(self, expr: m.GroupingExpr) -> ast.expr:
return expr.accept(self)
def visit_literal_expr(self, expr: m.LiteralExpr) -> ast.expr:
return ast.Constant(value=expr.value)
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> ast.expr:
return ast.Name(id="_")

File diff suppressed because it is too large Load Diff

579
midas/generator/stubs.py Normal file
View File

@@ -0,0 +1,579 @@
import ast
from typing import Optional, assert_never
import midas.ast.midas as m
from midas.checker.registry import Member, TypesRegistry
from midas.checker.types import (
AppliedType,
BaseType,
ColumnGroupBy,
ColumnType,
ConstraintType,
DataFrameType,
DerivedType,
FrameGroupBy,
Function,
GenericType,
OverloadedFunction,
ParamSpec,
TopType,
TupleType,
Type,
TypeVar,
UnitType,
UnknownType,
Variance,
substitute_typevars,
)
Empty = ast.Constant(value=...)
class StubsGenerator:
"""A class to generate Python stubs for user-defined Midas types"""
def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types
self.stubs: list[ast.stmt] = []
self.typing_imports: set[str] = set()
self.import_pandas: bool = False
self.protocol_idx: int = 0
self.stub_idx: int = 0
self.type_var_idx: int = 0
self.substitutions: dict[str, dict[str, Type]] = {}
def generate_stubs(self) -> ast.Module:
"""Generate a Python module of stubs for all user-defined types
Returns:
ast.Module: the generated module
"""
self.stubs = []
self.typing_imports = set()
self.import_pandas = False
for name, type in self.types._types.items():
# Skip builtin types, not just based on name so the user can override
# TODO: check if added members on builtin type
match type:
case BaseType(name=name_) if name == name_:
continue
case GenericType(
name=name1,
body=BaseType(name=name2),
) if (
name == name1 == name2
):
continue
self.generate_stub(name, type)
imports: list[ast.stmt] = [
ast.ImportFrom(
module="__future__",
names=[ast.alias(name="annotations")],
level=0,
)
]
if len(self.typing_imports) != 0:
imports.append(
ast.ImportFrom(
module="typing",
names=[
ast.alias(name=name) for name in sorted(self.typing_imports)
],
level=0,
)
)
if self.import_pandas:
imports.append(
ast.Import(
names=[
ast.alias(
name="pandas",
asname="pd",
)
],
)
)
return ast.Module(body=imports + self.stubs, type_ignores=[])
def generate_stub(self, name: str, type: Type):
"""Generate a stub for the given type
Args:
name (str): the name of the type
type (Type): the type
"""
base_type: Type = type
# TODO: improve
match type:
case DerivedType(name=name_) | GenericType(name=name_) if name_ == name:
pass
case UnitType() if name == "None":
pass
case TopType() if name == "Any":
pass
case _:
alias = ast.Assign(
targets=[ast.Name(id=name)], value=self.dump_type(type)
)
self.add_stub(alias)
return
members: dict[str, Member] = self.types._members.get(name, {})
if isinstance(base_type, (BaseType, TopType, UnitType)) and len(members) == 0:
return
bases: list[ast.expr] = []
substitutions: dict[str, Type] = {}
bases, substitutions = self.get_bases(type)
self.substitutions[name] = substitutions
body = self.generate_body(members, substitutions)
stub = ast.ClassDef(
name=name,
bases=bases,
body=body,
keywords=[],
decorator_list=[],
)
self.add_stub(stub)
def get_bases(self, type: Type) -> tuple[list[ast.expr], dict[str, Type]]:
"""Get the list of class bases and type parameter substitutions for a type
Args:
type (Type): the type whose bases to get
Returns:
tuple[list[ast.expr], dict[str, Type]]: a tuple containing the list
of class bases (already translated to Python AST nodes), and a
mapping of type parameter substitutions (to replace them with
their generated aliases)
"""
match type:
case DerivedType(type=base):
return [self.dump_type(base)], {}
case GenericType(params=params, body=body):
self.add_typing_import("Generic")
type_vars: ast.expr
params2: list[TypeVar] = self.define_type_vars(params)
if len(params) == 1:
type_vars = ast.Name(id=params2[0].name)
else:
type_vars = ast.Tuple(
elts=[ast.Name(id=param.name) for param in params2]
)
substitutions: dict[str, TypeVar] = {
param.name: param2 for param, param2 in zip(params, params2)
}
body_bases, body_subsitutions = self.get_bases(body)
return (
body_bases
+ [
ast.Subscript(
value=ast.Name(id="Generic"),
slice=type_vars,
)
],
body_subsitutions | substitutions,
)
case ConstraintType(type=base):
return self.get_bases(base)
case TypeVar(bound=bound) if bound is not None:
return [self.dump_type(bound)], {}
case _:
return [], {}
def generate_body(
self, members: dict[str, Member], substitutions: dict[str, Type]
) -> list[ast.stmt]:
"""Generate a class body given its members
Args:
members (dict[str, Member]): the class members
substitutions (dict[str, Type]): a mapping of type parameter
substitutions (to replace them with their generated aliases)
Returns:
list[ast.stmt]: the generated class body statements
"""
if len(members) == 0:
return [ast.Expr(value=Empty)]
body: list[ast.stmt] = []
for name, member in members.items():
type: Type = member.type
type = substitute_typevars(type, substitutions)
match member.kind:
case m.MemberKind.PROPERTY:
body.append(
ast.AnnAssign(
target=ast.Name(id=name),
annotation=self.dump_type(type),
simple=1,
)
)
case m.MemberKind.METHOD:
body.extend(self.dump_method(name, type))
return body
def dump_type(self, type: Type) -> ast.expr:
"""Translate a type to a Python expression
Args:
type (Type): the type to translate
Returns:
ast.expr: the generated Python expression
"""
match type:
case DerivedType(name=name) | GenericType(name=name) if (
name in self.substitutions
):
type = substitute_typevars(type, self.substitutions[name])
match type:
case TopType() | UnknownType():
self.add_typing_import("Any")
return ast.Name(id="Any")
case BaseType(name=name):
return ast.Name(id=name)
case DerivedType(name=name):
return ast.Name(id=name)
case UnitType():
return ast.Constant(value=None)
case Function():
name: str = self.define_protocol(type)
return ast.Name(id=name)
case OverloadedFunction(overloads=overloads):
if len(overloads) == 1:
return self.dump_type(overloads[0])
return ast.BinOp(
left=self.dump_type(OverloadedFunction(overloads=overloads[:-1])),
op=ast.BitOr(),
right=self.dump_type(overloads[-1]),
)
case TypeVar():
return ast.Name(id=type.name)
case GenericType(name=name):
params: ast.expr
if len(type.params) == 1:
params = self.dump_type(type.params[0])
else:
params = ast.Tuple(
elts=[self.dump_type(param) for param in type.params]
)
return ast.Subscript(
value=ast.Name(id=type.name),
slice=params,
)
case AppliedType():
args: ast.expr
if len(type.args) == 1:
args = self.dump_type(type.args[0])
else:
args = ast.Tuple(elts=[self.dump_type(arg) for arg in type.args])
return ast.Subscript(
value=ast.Name(id=type.name),
slice=args,
)
case ConstraintType():
return self.dump_type(type.type)
case TupleType(items=items):
return ast.Subscript(
value=ast.Name(id="tuple"),
slice=ast.Tuple(
elts=[self.dump_type(item) for item in items],
),
)
case ColumnType():
self.import_pandas = True
return ast.Attribute(
value=ast.Name(id="pd"),
attr="Series",
)
case DataFrameType():
self.import_pandas = True
return ast.Attribute(
value=ast.Name(id="pd"),
attr="DataFrame",
)
case FrameGroupBy():
self.import_pandas = True
return ast.Attribute(
value=ast.Attribute(
value=ast.Attribute(
value=ast.Name(id="pd"),
attr="api",
),
attr="typing",
),
attr="DataFrameGroupBy",
)
case ColumnGroupBy():
self.import_pandas = True
return ast.Attribute(
value=ast.Attribute(
value=ast.Attribute(
value=ast.Name(id="pd"),
attr="api",
),
attr="typing",
),
attr="SeriesGroupBy",
)
case _:
assert_never(type)
def dump_method(
self, name: str, method: Type, overloaded: bool = False
) -> list[ast.stmt]:
"""Generate definitions for a method
Args:
name (str): the method's name
method (Type): the method's type
overloaded (bool, optional): whether this method is part of an
overloaded method (used when called recursively). Defaults to False.
Returns:
list[ast.stmt]: the generated function definitions
"""
match method:
case Function():
if overloaded:
self.add_typing_import("overload")
return [
ast.FunctionDef(
name=name,
args=self.dump_params(method.params, with_self=True),
returns=self.dump_type(method.returns),
body=[ast.Expr(value=Empty)],
decorator_list=[ast.Name(id="overload")] if overloaded else [],
)
]
case OverloadedFunction(overloads=overloads):
stmts: list[ast.stmt] = []
for overload in overloads:
stmts.extend(self.dump_method(name, overload, True))
return stmts
case _:
return [
ast.AnnAssign(
target=ast.Name(id=name),
annotation=self.dump_type(method),
simple=1,
)
]
def dump_params(self, params: ParamSpec, with_self: bool = False) -> ast.arguments:
"""Generate an `ast.arguments` node for the given parameter spec
Args:
params (ParamSpec): the parameter spec to translate
with_self (bool, optional): whether to include a `self` parameter.
Defaults to False.
Returns:
ast.arguments: the generate Python AST node
"""
pos: list[ast.arg] = [
ast.arg(
arg=f"_{param.pos}",
annotation=self.dump_type(param.type),
)
for param in params.pos
]
mixed: list[ast.arg] = [
ast.arg(
arg=param.name,
annotation=self.dump_type(param.type),
)
for param in params.mixed
]
kw: list[ast.arg] = [
ast.arg(
arg=param.name,
annotation=self.dump_type(param.type),
)
for param in params.kw
]
defaults: list[ast.expr] = [
Empty for param in params.pos + params.mixed if not param.required
]
kw_defaults: list[Optional[ast.expr]] = [
None if param.required else Empty for param in params.kw
]
if with_self:
arg = ast.arg(arg="self", annotation=None)
if len(pos) != 0:
pos.insert(0, arg)
else:
mixed.insert(0, arg)
return ast.arguments(
posonlyargs=pos,
args=mixed,
kwonlyargs=kw,
defaults=defaults,
kw_defaults=kw_defaults,
)
def define_protocol(self, func: Function) -> str:
"""Generate a :class:`Protocol` to use in a function stub
Args:
func (Function): the function signature to define
Returns:
str: the name of the generated protocol
"""
self.add_typing_import("Protocol")
name: str = self.new_protocol_name()
protocol = ast.ClassDef(
name=name,
bases=[ast.Name(id="Protocol")],
keywords=[],
body=[
ast.FunctionDef(
name="__call__",
args=self.dump_params(func.params, with_self=True),
returns=self.dump_type(func.returns),
body=[ast.Expr(value=Empty)],
decorator_list=[],
),
],
decorator_list=[],
)
self.add_stub(protocol)
return name
def new_protocol_name(self) -> str:
"""Get a unique protocol name
Returns:
str: the unique protocol name
"""
name: str = f"_Protocol{self.protocol_idx}"
self.protocol_idx += 1
return name
def new_stub_name(self) -> str:
"""Get a unique stub name
Returns:
str: the unique stub name
"""
name: str = f"_Stub_{self.stub_idx}"
self.stub_idx += 1
return name
def new_type_var_name(self) -> str:
"""Get a unique type variable name
Returns:
str: the unique type variable name
"""
name: str = f"_T{self.type_var_idx}"
self.type_var_idx += 1
return name
def add_stub(self, stub: ast.stmt):
"""Append the given statement to the output
Args:
stub (ast.stmt): the statement to append
"""
self.stubs.append(stub)
def add_typing_import(self, name: str):
"""Add the given name to the list of names to import from `typing`
Args:
name (str): the name to import
"""
self.typing_imports.add(name)
def define_type_vars(self, vars: list[TypeVar]) -> list[TypeVar]:
"""Define aliases for the given type variables
Args:
vars (list[TypeVar]): the variables to define
Returns:
list[TypeVar]: new type variables named with the generated aliases
"""
vars2: list[TypeVar] = []
for var in vars:
vars2.append(self.define_type_var(var))
return vars2
def define_type_var(self, var: TypeVar) -> TypeVar:
"""Define a type variable alias
Args:
var (TypeVar): the type variable to define
Returns:
TypeVar: a new type variable named with a uniquely generated alias
"""
name: str = self.new_type_var_name()
self.add_typing_import("TypeVar")
kwargs: list[ast.keyword] = []
if var.bound is not None:
kwargs.append(
ast.keyword(
arg="bound",
value=self.dump_type(var.bound),
)
)
if var.variance == Variance.COVARIANT:
kwargs.append(
ast.keyword(
arg="covariant",
value=ast.Constant(value=True),
)
)
elif var.variance == Variance.CONTRAVARIANT:
kwargs.append(
ast.keyword(
arg="contravariant",
value=ast.Constant(value=True),
)
)
self.add_stub(
ast.Assign(
targets=[ast.Name(id=name)],
value=ast.Call(
func=ast.Name(id="TypeVar"),
args=[
ast.Constant(value=name),
],
keywords=kwargs,
),
)
)
return TypeVar(name=name, bound=None)

View File

@@ -16,9 +16,10 @@ class Lexer(ABC):
"""An abstract lexer which provides methods to easily extend it into a concrete one """An abstract lexer which provides methods to easily extend it into a concrete one
This implementation is based on the [_Crafting Interpreters_][1] book by Robert Nystrom, This implementation is based on the [_Crafting Interpreters_][1] book by Robert Nystrom,
more specifically on my [previous Python implementation](https://git.kb28.ch/HEL/pebble) more specifically on my [previous Python implementation][2]
[1]: https://craftinginterpreters.com/ [1]: https://craftinginterpreters.com/
[2]: https://git.kb28.ch/HEL/pebble
""" """
def __init__(self, source: str, file: Optional[str] = None) -> None: def __init__(self, source: str, file: Optional[str] = None) -> None:
@@ -168,6 +169,6 @@ class Lexer(ABC):
def scan_token(self) -> None: def scan_token(self) -> None:
"""Scan a token """Scan a token
This function should (at least) consume the current character and produce the appropriate token(s), using `add_token` This function should (at least) consume the current character and produce the appropriate token(s), using :func:`add_token`
""" """
pass pass

View File

@@ -32,6 +32,8 @@ class MidasLexer(Lexer):
) )
case "!" if self.match("="): case "!" if self.match("="):
self.add_token(TokenType.BANG_EQUAL) self.add_token(TokenType.BANG_EQUAL)
case "!":
self.add_token(TokenType.BANG)
case ":": case ":":
self.add_token(TokenType.COLON) self.add_token(TokenType.COLON)
case ".": case ".":
@@ -46,8 +48,8 @@ class MidasLexer(Lexer):
self.add_token(TokenType.UNDERSCORE) self.add_token(TokenType.UNDERSCORE)
case "-" if self.match(">"): case "-" if self.match(">"):
self.add_token(TokenType.ARROW) self.add_token(TokenType.ARROW)
# case "+": case "+":
# self.add_token(TokenType.PLUS) self.add_token(TokenType.PLUS)
case "-": case "-":
self.add_token(TokenType.MINUS) self.add_token(TokenType.MINUS)
case "*": case "*":
@@ -69,6 +71,8 @@ class MidasLexer(Lexer):
): ):
self.advance() self.advance()
self.add_token(TokenType.WHITESPACE) self.add_token(TokenType.WHITESPACE)
case '"' | "'":
self.scan_string(char)
case _: case _:
if char.isdigit(): if char.isdigit():
self.scan_number() self.scan_number()
@@ -78,6 +82,23 @@ class MidasLexer(Lexer):
self.error("Unexpected character") self.error("Unexpected character")
return None return None
def scan_string(self, opening: str):
"""Scan the rest of a string and add it as a token
Args:
opening (str): the opening quote or double quote, to be matched
at the end of the string
"""
while self.peek() != opening and not self.is_at_end():
self.advance()
if self.is_at_end():
self.error("Unterminated string")
self.advance()
value: str = self.source[self.start + 1 : self.idx - 1]
self.add_token(TokenType.STRING, value)
def scan_number(self): def scan_number(self):
"""Scan the rest of number and add it as a token """Scan the rest of number and add it as a token
@@ -87,12 +108,15 @@ class MidasLexer(Lexer):
while self.peek().isdigit(): while self.peek().isdigit():
self.advance() self.advance()
is_float: bool = False
if self.peek() == "." and self.peek_next().isdigit(): if self.peek() == "." and self.peek_next().isdigit():
is_float = True
self.advance() self.advance()
while self.peek().isdigit(): while self.peek().isdigit():
self.advance() self.advance()
value: float = float(self.source[self.start : self.idx]) value_str: str = self.source[self.start : self.idx]
value: int | float = float(value_str) if is_float else int(value_str)
self.add_token(TokenType.NUMBER, value) self.add_token(TokenType.NUMBER, value)
def scan_identifier(self): def scan_identifier(self):
@@ -134,6 +158,18 @@ class MidasLexer(Lexer):
self.add_token(TokenType.COMMENT) self.add_token(TokenType.COMMENT)
def is_identifier_char(self, char: str, *, start: bool) -> bool: def is_identifier_char(self, char: str, *, start: bool) -> bool:
"""Check whether a character is a valid as part of an identifier
Identifiers can contain any alphanumerical character or underscore.
They cannot start with a digit.
Args:
char (str): the character to check
start (bool): whether this is the first character of the identifier
Returns:
bool: `True` if the character is valid, `False` otherwise
"""
if char == "_": if char == "_":
return True return True
if char.isalpha(): if char.isalpha():

View File

@@ -25,7 +25,7 @@ class TokenType(Enum):
DOT = auto() DOT = auto()
# Operators # Operators
# PLUS = auto() PLUS = auto()
MINUS = auto() MINUS = auto()
STAR = auto() STAR = auto()
SLASH = auto() SLASH = auto()
@@ -36,6 +36,7 @@ class TokenType(Enum):
EQUAL = auto() EQUAL = auto()
EQUAL_EQUAL = auto() EQUAL_EQUAL = auto()
BANG_EQUAL = auto() BANG_EQUAL = auto()
BANG = auto()
# Literals # Literals
IDENTIFIER = auto() IDENTIFIER = auto()
@@ -43,9 +44,11 @@ class TokenType(Enum):
TRUE = auto() TRUE = auto()
FALSE = auto() FALSE = auto()
NONE = auto() NONE = auto()
STRING = auto()
# Keywords # Keywords
TYPE = auto() TYPE = auto()
ALIAS = auto()
PREDICATE = auto() PREDICATE = auto()
EXTEND = auto() EXTEND = auto()
WHERE = auto() WHERE = auto()
@@ -62,6 +65,7 @@ class TokenType(Enum):
KEYWORDS: dict[str, TokenType] = { KEYWORDS: dict[str, TokenType] = {
"type": TokenType.TYPE, "type": TokenType.TYPE,
"alias": TokenType.ALIAS,
"predicate": TokenType.PREDICATE, "predicate": TokenType.PREDICATE,
"extend": TokenType.EXTEND, "extend": TokenType.EXTEND,
"where": TokenType.WHERE, "where": TokenType.WHERE,
@@ -101,6 +105,15 @@ class Token:
) )
def location_to(self, to: Token) -> Location: def location_to(self, to: Token) -> Location:
"""Create a new :class:`Location` spanning from this token to another
Args:
to (Token): the end token
Returns:
Location: a new :class:`Location` starting at this token and ending
at `to`, both included
"""
return Location.span(self.get_location(), to.get_location()) return Location.span(self.get_location(), to.get_location())
@property @property

View File

@@ -16,6 +16,9 @@ class TokenError:
def get_report(self) -> str: def get_report(self) -> str:
"""Get a detailed error message """Get a detailed error message
The error message is formatted as "(<position>) Error at <token>: <message>".
For example: "(L2:5) Error at '3': Expected ')' after arguments."
Returns: Returns:
str: the complete error message str: the complete error message
""" """
@@ -32,9 +35,10 @@ class Parser(ABC, Generic[T]):
"""An abstract parser which provides methods to easily extend it into a concrete one """An abstract parser which provides methods to easily extend it into a concrete one
This implementation is based on the [_Crafting Interpreters_][1] book by Robert Nystrom, This implementation is based on the [_Crafting Interpreters_][1] book by Robert Nystrom,
more specifically on my [previous Python implementation](https://git.kb28.ch/HEL/pebble) more specifically on my [previous Python implementation][2]
[1]: https://craftinginterpreters.com/ [1]: https://craftinginterpreters.com/
[2]: https://git.kb28.ch/HEL/pebble
""" """
IGNORE: set[TokenType] = { IGNORE: set[TokenType] = {
@@ -173,7 +177,7 @@ class Parser(ABC, Generic[T]):
error_msg (str): the error message if the token doesn't match error_msg (str): the error message if the token doesn't match
Raises: Raises:
SyntaxError: if the current token doesn't match the given type ParsingError: if the current token doesn't match the given type
Returns: Returns:
Token: the current token which matched the given type Token: the current token which matched the given type

View File

@@ -2,12 +2,13 @@ from typing import Optional
from midas.ast.location import Location from midas.ast.location import Location
from midas.ast.midas import ( from midas.ast.midas import (
AliasStmt,
BinaryExpr, BinaryExpr,
ComplexType, CallExpr,
ConstraintType, ConstraintType,
Expr, Expr,
ExtendStmt, ExtendStmt,
ExtensionType, FrameType,
FunctionType, FunctionType,
GenericType, GenericType,
GetExpr, GetExpr,
@@ -17,6 +18,7 @@ from midas.ast.midas import (
MemberKind, MemberKind,
MemberStmt, MemberStmt,
NamedType, NamedType,
ParamSpec,
PredicateStmt, PredicateStmt,
Stmt, Stmt,
Type, Type,
@@ -31,10 +33,11 @@ from midas.parser.base import Parser
from midas.parser.errors import ParsingError from midas.parser.errors import ParsingError
class MidasParser(Parser): class MidasParser(Parser[list[Stmt]]):
"""A simple parser for midas type definitions""" """A simple parser for midas type definitions"""
SYNC_BOUNDARY: set[TokenType] = { SYNC_BOUNDARY: set[TokenType] = {
TokenType.ALIAS,
TokenType.TYPE, TokenType.TYPE,
TokenType.EXTEND, TokenType.EXTEND,
TokenType.PREDICATE, TokenType.PREDICATE,
@@ -69,14 +72,16 @@ class MidasParser(Parser):
def declaration(self) -> Optional[Stmt]: def declaration(self) -> Optional[Stmt]:
"""Try and parse a declaration """Try and parse a declaration
Any parsing error is caught and None is returned Any parsing error is caught and `None` is returned
Returns: Returns:
Optional[Stmt]: the parsed Midas statement, or None if a ParsingError was raised Optional[Stmt]: the parsed Midas statement, or `None` if a ParsingError was raised
""" """
try: try:
if self.match(TokenType.TYPE): if self.match(TokenType.TYPE):
return self.type_declaration() return self.type_declaration()
if self.match(TokenType.ALIAS):
return self.alias_declaration()
if self.match(TokenType.EXTEND): if self.match(TokenType.EXTEND):
return self.extend_declaration() return self.extend_declaration()
if self.match(TokenType.PREDICATE): if self.match(TokenType.PREDICATE):
@@ -89,23 +94,14 @@ class MidasParser(Parser):
def type_declaration(self) -> TypeStmt: def type_declaration(self) -> TypeStmt:
"""Parse a type declaration """Parse a type declaration
A type declaration can either be a simple type alias or a new complex type. A type declaration creates a named subtype of a type expression.
In either case, it can have an optional template expression after its name, wrapped in brackets. It can have an optional template expression after its name, wrapped in brackets, to handle type parameters.
A simple type alias is derived from a base type expression, and can have a optional constraint expression preceded by the `where` keyword.
A full simple type alias is thus written:
```
type Name[Template](TypeExpr) where Condition
```
A new complex type has a set of properties which are named, have a type and an optional constraint expression (also preceded by the `where` keyword). A type statement consists of:
A full complex type definition is thus written: - the `type` keyword
``` - a name (identifier)
type Name[Template] { - (optional) type parameters
prop1: TypeExpr1 where Condition1 - a body, a type expression (see :func:`type_expr`)
prop2: TypeExpr2 where Condition2
...
}
```
Returns: Returns:
TypeStmt: the parsed type declaration statement TypeStmt: the parsed type declaration statement
@@ -156,30 +152,53 @@ class MidasParser(Parser):
self.consume(TokenType.RIGHT_BRACKET, "Missing ']' after type parameters") self.consume(TokenType.RIGHT_BRACKET, "Missing ']' after type parameters")
return params return params
def alias_declaration(self) -> AliasStmt:
"""Parse an alias declaration
An alias statement consists of:
- the `alias` keyword
- a name (identifier)
- a body, a type expression (see :func:`type_expr`)
Returns:
AliasStmt: the parsed alias declaration statement
"""
keyword: Token = self.previous()
name: Token = self.consume_identifier("Expected alias name")
self.consume(TokenType.EQUAL, "Expected '=' before alias definition")
type: Type = self.type_expr()
return AliasStmt(
location=keyword.location_to(self.previous()),
name=name,
type=type,
)
def type_expr(self) -> Type: def type_expr(self) -> Type:
"""Parse a type expression """Parse a type expression
A type is an identifier, optionally followed by a template expression. A type expression can either be a function type (see :func:`function`)
It can also optionally be followed by a '?' to indicate a nullable type or a constraint type (see :func:`constraint_type`)
Returns: Returns:
TypeExpr: the parsed type expression TypeExpr: the parsed type expression
""" """
base: Type
if self.match(TokenType.FUNC): if self.match(TokenType.FUNC):
base = self.function() return self.function()
else: return self.constraint_type()
base = self.constraint_type()
if self.match(TokenType.AND):
extension: ComplexType = self.complex_type()
return ExtensionType(
location=Location.span(base.location, extension.location),
base=base,
extension=extension,
)
return base
def constraint_type(self) -> Type: def constraint_type(self) -> Type:
"""Parse a constraint type expression
A constraint type consists of a base type (see :func:`base_type`),
optionally followed by the `where` keyword and a constraint
expression (see :func:`constraint`)
Returns:
Type: the parsed constraint type expression
"""
type: Type = self.base_type() type: Type = self.base_type()
if self.match(TokenType.WHERE): if self.match(TokenType.WHERE):
constraint: Expr = self.constraint() constraint: Expr = self.constraint()
@@ -191,19 +210,37 @@ class MidasParser(Parser):
return type return type
def base_type(self) -> Type: def base_type(self) -> Type:
"""Parse a base type expression
A base type is either a parenthesized type expression (see :func:`type_expr`)
or a generic type (see :func:`generic_type`)
Returns:
Type: the parsed base type expression
"""
if self.match(TokenType.LEFT_PAREN): if self.match(TokenType.LEFT_PAREN):
type: Type = self.type_expr() type: Type = self.type_expr()
self.consume(TokenType.RIGHT_PAREN, "Unclosed parenthesis") self.consume(TokenType.RIGHT_PAREN, "Unclosed parenthesis")
return type return type
if self.check(TokenType.LEFT_BRACE):
return self.complex_type()
return self.generic_type() return self.generic_type()
def generic_type(self) -> Type: def generic_type(self) -> Type:
type: Type = self.named_type() """Parse a generic type expression
A generic type consists of a named type (see :func:`named_type`),
optionally followed by type arguments in brackets.
The special `Frame` type accepts a frame schema instead of type
arguments (see :func:`frame_type`).
Returns:
Type: the parsed generic type
"""
type: NamedType = self.named_type()
if self.check(TokenType.LEFT_BRACKET): if self.check(TokenType.LEFT_BRACKET):
if type.name.lexeme == "Frame":
return self.frame_type()
args: list[Type] = self.type_args() args: list[Type] = self.type_args()
return GenericType( return GenericType(
location=Location.span(type.location, self.previous().get_location()), location=Location.span(type.location, self.previous().get_location()),
@@ -213,6 +250,13 @@ class MidasParser(Parser):
return type return type
def type_args(self) -> list[Type]: def type_args(self) -> list[Type]:
"""Parse a list of type arguments
Type arguments are a comma-separated list of type expression wrapped in brackets.
Returns:
list[Type]: the list of type arguments, if any, or an empty list
"""
args: list[Type] = [] args: list[Type] = []
self.consume(TokenType.LEFT_BRACKET, "Missing '[' before generic arguments") self.consume(TokenType.LEFT_BRACKET, "Missing '[' before generic arguments")
while not self.is_at_end() and not self.check(TokenType.RIGHT_BRACKET): while not self.is_at_end() and not self.check(TokenType.RIGHT_BRACKET):
@@ -222,53 +266,85 @@ class MidasParser(Parser):
self.consume(TokenType.RIGHT_BRACKET, "Missing ']' after generic arguments") self.consume(TokenType.RIGHT_BRACKET, "Missing ']' after generic arguments")
return args return args
def named_type(self) -> Type: def named_type(self) -> NamedType:
"""Parse a named type expression
A named type is an identifier token
Returns:
NamedType: the parsed named type expression
"""
name: Token = self.consume_identifier("Expected type name") name: Token = self.consume_identifier("Expected type name")
return NamedType( return NamedType(
location=name.get_location(), location=name.get_location(),
name=name, name=name,
) )
def complex_type(self) -> ComplexType: def frame_type(self) -> FrameType:
"""Parse a type definition body """Parse a frame type expression
A type definition body is a set of whitespace-separated A frame type consists of:
property statements enclosed in curly braces - the `Frame` identifier
- an opening bracket `[`
- a list of comma-separated column expression consisting of:
- a name (token)
- a colon `:`
- a type expression (see :func:`type_expr`)
- a closing bracket `]`
Returns: Returns:
ComplexType: the parsed complex type FrameType: the parsed frame type
""" """
left: Token = self.consume( keyword: Token = self.previous()
TokenType.LEFT_BRACE, "Expected '{' to start type body" self.consume(TokenType.LEFT_BRACKET, "Expected '[' to start frame schema")
)
members: list[MemberStmt] = [] columns: list[FrameType.Column] = []
# TODO: add keyword to differentiate properties and methods, while not self.check(TokenType.RIGHT_BRACKET) and not self.is_at_end():
# and allow multiple methods with the same name but not properties name: Token = self.advance()
names: set[str] = set() self.consume(TokenType.COLON, "Expected ':' between column name and type")
while not self.check(TokenType.RIGHT_BRACE) and not self.is_at_end(): type: Type = self.type_expr()
member: MemberStmt = self.member_stmt() columns.append(
# if member.name.lexeme in names: FrameType.Column(
# raise self.error(member.name, "Duplicate property") location=name.location_to(self.previous()),
# names.add(member.name.lexeme) name=name,
members.append(member) type=type,
right: Token = self.consume(TokenType.RIGHT_BRACE, "Unclosed type body") )
return ComplexType( )
location=left.location_to(right), if not self.match(TokenType.COMMA):
members=members, break
self.consume(TokenType.RIGHT_BRACKET, "Unclosed frame schema")
return FrameType(
location=keyword.location_to(self.previous()),
columns=columns,
) )
def constraint(self) -> Expr: def constraint(self) -> Expr:
"""Parse a constraint """Parse a constraint expression
A constraint is basically a logical predicate A constraint is an expression (see :func:`expression`)
Returns: Returns:
Expr: the parsed constraint expression Expr: the parsed constraint expression
""" """
return self.expression()
def expression(self) -> Expr:
"""Parse an expression
An expression consists of a logical AND expression (see :func:`and_`)
Returns:
Expr: the parsed expression
"""
return self.and_() return self.and_()
def and_(self) -> Expr: def and_(self) -> Expr:
"""Parse a logical AND expression or a simpler expression """Parse a logical AND expression
An AND consists of one or more equality expressions (see :func:`equality`)
separated by logical AND operators (`&`)
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
@@ -284,7 +360,10 @@ class MidasParser(Parser):
return expr return expr
def equality(self) -> Expr: def equality(self) -> Expr:
"""Parse a logical equality expression or a simpler expression """Parse an equality expression
An equality consists of one or more comparison expressions (see :func:`comparison`)
separated by equality operators (`==`, `!=`)
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
@@ -300,18 +379,59 @@ class MidasParser(Parser):
return expr return expr
def comparison(self) -> Expr: def comparison(self) -> Expr:
"""Parse a logical comparison expression or a simpler expression """Parse a comparison expression
A comparison consists of one or more term expressions (see :func:`term`)
separated by comparison operators (`<`, `<=`, `>`, `>=`)
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
""" """
expr: Expr = self.unary() expr: Expr = self.term()
while self.match( while self.match(
TokenType.LESS, TokenType.LESS,
TokenType.LESS_EQUAL, TokenType.LESS_EQUAL,
TokenType.GREATER, TokenType.GREATER,
TokenType.GREATER_EQUAL, TokenType.GREATER_EQUAL,
): ):
operator: Token = self.previous()
right: Expr = self.term()
location: Location = Location.span(expr.location, right.location)
expr = BinaryExpr(
location=location, left=expr, operator=operator, right=right
)
return expr
def term(self) -> Expr:
"""Parse a term expression
A term consists of one or more factor expressions (see :func:`factor`)
separated by weak arithmetic operators (`+`, `-`)
Returns:
Expr: the parsed expression
"""
expr: Expr = self.factor()
while self.match(TokenType.PLUS, TokenType.MINUS):
operator: Token = self.previous()
right: Expr = self.factor()
location: Location = Location.span(expr.location, right.location)
expr = BinaryExpr(
location=location, left=expr, operator=operator, right=right
)
return expr
def factor(self) -> Expr:
"""Parse a factor expression
A factor consists of one or more unary expressions (see :func:`unary`)
separated by strong arithmetic operators (`*`, `/`)
Returns:
Expr: the parsed expression
"""
expr: Expr = self.unary()
while self.match(TokenType.STAR, TokenType.SLASH):
operator: Token = self.previous() operator: Token = self.previous()
right: Expr = self.unary() right: Expr = self.unary()
location: Location = Location.span(expr.location, right.location) location: Location = Location.span(expr.location, right.location)
@@ -321,20 +441,109 @@ class MidasParser(Parser):
return expr return expr
def unary(self) -> Expr: def unary(self) -> Expr:
"""Parse a unary expression or a simpler expression """Parse a unary expression
A unary consists of a call expression (see :func:`call`) optionally
preceded by zero or more unary operators (`+`, `-`, `!`)
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
""" """
if self.match(TokenType.MINUS): if self.match(TokenType.PLUS, TokenType.MINUS, TokenType.BANG):
operator: Token = self.previous() operator: Token = self.previous()
right: Expr = self.unary() right: Expr = self.unary()
location: Location = Location.span(operator.get_location(), right.location) location: Location = Location.span(operator.get_location(), right.location)
return UnaryExpr(location=location, operator=operator, right=right) return UnaryExpr(location=location, operator=operator, right=right)
return self.reference() return self.call()
def call(self) -> Expr:
"""Parse a call expression
A call consists of a reference expression (see :func:`reference`)
optionally followed by zero or more argument groups.
Argument groups are parenthesize, comma-separated list of arguments (see :func:`finish_call`)
Returns:
Expr: the parsed expression
"""
expr: Expr = self.reference()
while self.match(TokenType.LEFT_PAREN):
expr = self.finish_call(expr)
return expr
def finish_call(self, callee: Expr) -> Expr:
"""Parse an argument group, i.e. the arguments of a call
Arguments are either passed positionally or by name (keyword argument).
All positional arguments must come before any keyword argument and
vice-versa. Arguments are separated by commas.
A positional argument simply consists of an expression (see :func:`expression`)
A keyword argument consists of and identifier, followed by the equal `=`
token and an expression (see :func:`expression`).
Args:
callee (Expr): the callee expression
Raises:
ParsingError: if a positional argument is passed after a keyword
argument or if a keyword argument's name is invalid (i.e. not
an identifier)
Returns:
Expr: the parsed call expression
"""
pos_args: list[Expr] = []
kw_args: dict[str, Expr] = {}
keywords: bool = False
while not self.check(TokenType.RIGHT_PAREN):
if self.check_identifier() and self.check_next(TokenType.EQUAL):
keywords = True
keyword: Token = self.advance()
self.advance()
value: Expr = self.expression()
name: str = keyword.lexeme
if name in kw_args:
self.error(
self.peek(),
f"Multiple values passed for '{name}', only the last occurrence will be used",
)
kw_args[name] = value
else:
value = self.expression()
if self.check(TokenType.EQUAL):
error_msg: str
if keywords:
error_msg = "Invalid keyword argument name"
else:
error_msg = (
"Cannot pass positional arguments after a keyword argument"
)
raise self.error(self.peek(), error_msg)
pos_args.append(value)
if not self.match(TokenType.COMMA):
break
r_paren: Token = self.consume(
TokenType.RIGHT_PAREN, "Expected ')' after arguments."
)
return CallExpr(
location=Location.span(callee.location, r_paren.get_location()),
callee=callee,
arguments=pos_args,
keywords=kw_args,
)
def reference(self) -> Expr: def reference(self) -> Expr:
"""Parse an attribute access expression or a simpler expression """Parse a reference expression
A reference consists of a primary expression (see :func:`primary`)
optionally followed by zero or more attribute accesses.
An attribute access consists of a dot `.` token followed by an identifier
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
@@ -349,7 +558,12 @@ class MidasParser(Parser):
def primary(self) -> Expr: def primary(self) -> Expr:
"""Parse a primary expression """Parse a primary expression
This includes literals (booleans, numbers, etc.), wildcards, identifiers and grouped expressions This includes literals (booleans, numbers, etc.), wildcards, identifiers
and grouped expressions
Raises:
ParsingError: if a primary expressions cannot be parsed from the
following tokens
Returns: Returns:
Expr: the parsed expression Expr: the parsed expression
@@ -365,6 +579,9 @@ class MidasParser(Parser):
if self.match(TokenType.NUMBER): if self.match(TokenType.NUMBER):
return LiteralExpr(location=token.get_location(), value=token.value) return LiteralExpr(location=token.get_location(), value=token.value)
if self.match(TokenType.STRING):
return LiteralExpr(location=token.get_location(), value=token.value)
if self.match_identifier(): if self.match_identifier():
return VariableExpr(location=token.get_location(), name=token) return VariableExpr(location=token.get_location(), name=token)
@@ -372,21 +589,48 @@ class MidasParser(Parser):
return WildcardExpr(location=token.get_location(), token=token) return WildcardExpr(location=token.get_location(), token=token)
if self.match(TokenType.LEFT_PAREN): if self.match(TokenType.LEFT_PAREN):
expr: Expr = self.constraint() expr: Expr = self.expression()
right: Token = self.consume(TokenType.RIGHT_PAREN, "Unclosed parenthesis") right: Token = self.consume(TokenType.RIGHT_PAREN, "Unclosed parenthesis")
return GroupingExpr(location=token.location_to(right), expr=expr) return GroupingExpr(location=token.location_to(right), expr=expr)
raise self.error(self.peek(), "Expected expression") raise self.error(self.peek(), "Expected expression")
def consume_identifier(self, message: str = "Expected identifier") -> Token: def consume_identifier(self, message: str = "Expected identifier") -> Token:
"""Consume the current token if it is a valid identifier or raise an error (see :func:`check_identifier`)
If the current token is not a valid identifier, an error is raised
with the provided message
Args:
message (str, optional): the error message. Defaults to "Expected identifier".
Raises:
ParsingError: if the current token is not a valid identifier
Returns:
Token: the current token which is a valid identifier
"""
if not self.match_identifier(): if not self.match_identifier():
raise self.error(self.peek(), message) raise self.error(self.peek(), message)
return self.previous() return self.previous()
def match_identifier(self) -> bool: def match_identifier(self) -> bool:
"""Consume the next token if it is a valid identifier (see :func:`check_identifier`)
Returns:
bool: whether a token was matched and consumed
"""
return self.match(TokenType.IDENTIFIER, *KEYWORDS.values()) return self.match(TokenType.IDENTIFIER, *KEYWORDS.values())
def check_identifier(self) -> bool: def check_identifier(self) -> bool:
"""Check whether the current token is a valid identifier
A valid identifier is either an identifier token or a keyword token.
This function always returns False if the parser is at the EOF token
Returns:
bool: True if the current token is a valid identifier and not EOF
"""
for tt in [TokenType.IDENTIFIER, *KEYWORDS.values()]: for tt in [TokenType.IDENTIFIER, *KEYWORDS.values()]:
if self.check(tt): if self.check(tt):
return True return True
@@ -395,7 +639,14 @@ class MidasParser(Parser):
def member_stmt(self) -> MemberStmt: def member_stmt(self) -> MemberStmt:
"""Parse a member statement """Parse a member statement
A type member statement is written `prop name: Type` or `def name: Type` A member statement is written consists of:
- the `prop` (for a property) or `def` (for a method) keyword
- an name (identifier)
- a colon `:`
- a type expression (see :func:`type_expr`)
Raises:
ParsingError: if the first token is neither `prop` nor `def`
Returns: Returns:
MemberStmt: the parsed member statement MemberStmt: the parsed member statement
@@ -422,7 +673,13 @@ class MidasParser(Parser):
def extend_declaration(self) -> ExtendStmt: def extend_declaration(self) -> ExtendStmt:
"""Parse an extension definition """Parse an extension definition
An extension is written `extend Type { operations }` or `extend[S <: T, U] Type { operations }` An extension statement consists of:
- the `extend` keyword
- a type name (identifier)
- (optional) type parameters (see :func:`type_params`)
- an opening brace `{`
- zero or more member statements (see :func:`member_stmt`)
- a closing brace `}`
Returns: Returns:
ExtendStmt: the parsed extension statement ExtendStmt: the parsed extension statement
@@ -447,58 +704,103 @@ class MidasParser(Parser):
def predicate_declaration(self) -> PredicateStmt: def predicate_declaration(self) -> PredicateStmt:
"""Parse a predicate declaration """Parse a predicate declaration
A predicate is written `predicate Name(subject: Type) = constraint_expression` A predicate statement consists of:
- the `predicate` keyword
- a name (identifier)
- (optional) zero or more parameter specs (see :func:`function_params`)
- an equal sign `=`
- a body, a constraint expression (see :func:`constraint`)
Returns: Returns:
PredicateStmt: the parsed predicate declaration statement PredicateStmt: the parsed predicate declaration statement
""" """
keyword: Token = self.previous() keyword: Token = self.previous()
name: Token = self.consume_identifier("Expected predicate name") name: Token = self.consume_identifier("Expected predicate name")
self.consume(TokenType.LEFT_PAREN, "Expected '(' before predicate subject")
subject: Token = self.consume_identifier("Expected subject name") params: list[ParamSpec] = []
self.consume(TokenType.COLON, "Expected ':' after subject name") while self.check(TokenType.LEFT_PAREN):
type: Type = self.type_expr() params.append(self.function_params())
self.consume(TokenType.RIGHT_PAREN, "Expected ')' after predicate subject")
self.consume(TokenType.EQUAL, "Expected '=' after predicate subject") self.consume(TokenType.EQUAL, "Expected '=' after predicate subject")
condition: Expr = self.constraint() body: Expr = self.constraint()
return PredicateStmt( return PredicateStmt(
location=keyword.location_to(self.previous()), location=keyword.location_to(self.previous()),
name=name, name=name,
subject=subject, params=params,
type=type, body=body,
condition=condition,
) )
def function(self) -> FunctionType: def function(self) -> FunctionType:
"""Parse a function type expression
A function consists of:
- the `fn` keyword
- a parameter spec (see :func:`function_params`)
- the arrow keyword `->`
- a result type expression (see :func:`type_expr`)
Returns:
FunctionType: the parsed function type expression
"""
params: ParamSpec = self.function_params()
self.consume(TokenType.ARROW, "Expected '->' before result type")
result: Type = self.type_expr()
return FunctionType(
location=params.l_paren.location_to(self.previous()),
params=params,
returns=result,
)
def function_params(self) -> ParamSpec:
"""Parse a parameter spec
A parameter spec consists of zero or more comma-separated parameters,
wrapped in parentheses.
Like in Python, it can contain positional-only, mixed and keyword-only
parameters (separated by `/` and `*`).
Each parameter has a type (see :func:`type_expr`),
preceded by a name (identifier) and a colon `:` (not required for
positional-only parameters).
Returns:
ParamSpec: the parsed parameter spec
"""
l_paren: Token = self.consume( l_paren: Token = self.consume(
TokenType.LEFT_PAREN, "Expected '(' before function parameters" TokenType.LEFT_PAREN, "Expected '(' before function parameters"
) )
pos_args: list[FunctionType.Argument] = [] pos: list[FunctionType.Parameter] = []
args: list[FunctionType.Argument] = [] mixed: list[FunctionType.Parameter] = []
kw_args: list[FunctionType.Argument] = [] kw: list[FunctionType.Parameter] = []
args_first_tokens: list[Token] = [] mixed_first_tokens: list[Token] = []
section: int = 0 section: int = 0
while not self.is_at_end() and not self.check(TokenType.RIGHT_PAREN): while not self.is_at_end() and not self.check(TokenType.RIGHT_PAREN):
match section: match section:
case 0 if self.match(TokenType.SLASH): case 0 if self.match(TokenType.SLASH):
pos_args = args pos = mixed
args = [] mixed = []
args_first_tokens = [] mixed_first_tokens = []
section = 1 section = 1
case 0 | 1 if self.match(TokenType.STAR): case 0 | 1 if self.match(TokenType.STAR):
section = 2 section = 2
case _: case _:
# Record first token of mixed argument for errors if unnamed # Record first token of mixed parameters for errors if unnamed
if section != 2: if section != 2:
args_first_tokens.append(self.peek()) mixed_first_tokens.append(self.peek())
name: Optional[Token] = None name: Optional[Token] = None
if section == 2: if section == 2:
name = self.consume_identifier("Expected keyword argument name") name = self.consume_identifier(
"Expected keyword parameter name"
)
self.consume( self.consume(
TokenType.COLON, "Expected ':' after argument name" TokenType.COLON, "Expected ':' after parameter name"
) )
elif self.check_identifier() and self.check_next(TokenType.COLON): elif self.check_identifier() and self.check_next(TokenType.COLON):
name = self.advance() name = self.advance()
@@ -506,34 +808,24 @@ class MidasParser(Parser):
type: Type = self.type_expr() type: Type = self.type_expr()
optional: bool = self.match(TokenType.QMARK) optional: bool = self.match(TokenType.QMARK)
arg = FunctionType.Argument( param = FunctionType.Parameter(
location=None, location=None,
name=name, name=name,
type=type, type=type,
required=not optional, required=not optional,
) )
if section == 2: if section == 2:
kw_args.append(arg) kw.append(param)
else: else:
args.append(arg) mixed.append(param)
if not self.match(TokenType.COMMA): if not self.match(TokenType.COMMA):
break break
for arg, token in zip(args, args_first_tokens): for param, token in zip(mixed, mixed_first_tokens):
if arg.name is None: if param.name is None:
# Not raised because we can keep parsing # Not raised because we can keep parsing
self.error(token, "Unnamed mixed argument") self.error(token, "Unnamed mixed parameter")
self.consume(TokenType.RIGHT_PAREN, "Expected ')' after function parameters") self.consume(TokenType.RIGHT_PAREN, "Expected ')' after function parameters")
return ParamSpec(l_paren=l_paren, pos=pos, mixed=mixed, kw=kw)
self.consume(TokenType.ARROW, "Expected '->' before result type")
result: Type = self.type_expr()
return FunctionType(
location=l_paren.location_to(self.previous()),
pos_args=pos_args,
args=args,
kw_args=kw_args,
returns=result,
)

View File

@@ -16,13 +16,17 @@ from midas.ast.python import (
ForStmt, ForStmt,
FrameColumn, FrameColumn,
FrameType, FrameType,
FromImportStmt,
Function, Function,
GetExpr, GetExpr,
IfStmt, IfStmt,
ImportAlias,
ImportStmt,
ListExpr, ListExpr,
LiteralExpr, LiteralExpr,
LogicalExpr, LogicalExpr,
MidasType, MidasType,
ParamSpec,
RawExpr, RawExpr,
RawStmt, RawStmt,
ReturnStmt, ReturnStmt,
@@ -30,6 +34,7 @@ from midas.ast.python import (
Stmt, Stmt,
SubscriptExpr, SubscriptExpr,
TernaryExpr, TernaryExpr,
TupleExpr,
TypeAssign, TypeAssign,
UnaryExpr, UnaryExpr,
VariableExpr, VariableExpr,
@@ -48,7 +53,10 @@ class UnsupportedSyntaxError(Exception):
class PythonParser: class PythonParser:
"""A parser to convert raw Python `ast` nodes in custom IR nodes"""
CAST_FUNCTION = "cast" CAST_FUNCTION = "cast"
UNSAFE_CAST_FUNCTION = "unsafe_cast"
def parse_module(self, node: ast.Module) -> list[Stmt]: def parse_module(self, node: ast.Module) -> list[Stmt]:
statements: list[Stmt] = [] statements: list[Stmt] = []
@@ -100,10 +108,34 @@ class PythonParser:
case ast.For(orelse=[]): case ast.For(orelse=[]):
return self.parse_for(node) return self.parse_for(node)
case ast.Import(names=imports):
return ImportStmt(
location=location,
imports=self._parse_imports(imports),
)
case ast.ImportFrom(module=module, names=imports, level=level):
return FromImportStmt(
location=location,
module=module,
imports=self._parse_imports(imports),
level=level,
)
case _: case _:
print(f"Unsupported statement: {ast.unparse(node)}") print(f"Unsupported statement: {ast.unparse(node)}")
return RawStmt(location=location, stmt=node) return RawStmt(location=location, stmt=node)
def _parse_imports(self, imports: list[ast.alias]) -> list[ImportAlias]:
return [
ImportAlias(
location=Location.from_ast(import_),
name=import_.name,
alias=import_.asname,
)
for import_ in imports
]
def parse_annotation_assign(self, node: ast.AnnAssign) -> list[Stmt]: def parse_annotation_assign(self, node: ast.AnnAssign) -> list[Stmt]:
statements: list[Stmt] = [] statements: list[Stmt] = []
loc: Location = Location.from_ast(node) loc: Location = Location.from_ast(node)
@@ -210,27 +242,10 @@ class PythonParser:
match node: match node:
case ast.FunctionDef( case ast.FunctionDef(
name=name, name=name,
args=ast.arguments( args=args,
posonlyargs=posonlyargs,
args=args,
vararg=sink,
kwonlyargs=kwonlyargs,
kwarg=kw_sink,
defaults=defaults,
kw_defaults=kw_defaults,
),
returns=returns, returns=returns,
body=raw_body, body=raw_body,
): ):
def parse_args(
args_list: list[ast.arg], defaults: list[Optional[Expr]]
) -> list[Function.Argument]:
return [
self._parse_function_argument(arg, default)
for arg, default in zip(args_list, defaults)
]
body: list[Stmt] = [] body: list[Stmt] = []
for stmt in raw_body: for stmt in raw_body:
stmts = self.parse_stmt(stmt) stmts = self.parse_stmt(stmt)
@@ -239,54 +254,58 @@ class PythonParser:
elif stmts is not None: elif stmts is not None:
body.extend(stmts) body.extend(stmts)
parsed_defaults: list[Optional[Expr]] = [
self.parse_expr(default) for default in defaults
]
n_posargs: int = len(posonlyargs)
n_args: int = len(args)
n_all_posargs = n_posargs + n_args
parsed_defaults = [
None,
] * (n_all_posargs - len(defaults)) + parsed_defaults
posargs_defaults: list[Optional[Expr]] = parsed_defaults[:n_posargs]
args_defaults: list[Optional[Expr]] = parsed_defaults[n_posargs:]
kwargs_defaults: list[Optional[Expr]] = [
self.parse_expr(default) if default is not None else None
for default in kw_defaults
]
return Function( return Function(
location=loc, location=loc,
name=name, name=name,
posonlyargs=parse_args(posonlyargs, posargs_defaults), params=self._parse_param_spec(args),
args=parse_args(args, args_defaults),
sink=(
self._parse_function_argument(sink, None)
if sink is not None
else None
),
kwonlyargs=parse_args(kwonlyargs, kwargs_defaults),
kw_sink=(
self._parse_function_argument(kw_sink, None)
if kw_sink is not None
else None
),
returns=self._parse_type(returns) if returns is not None else None, returns=self._parse_type(returns) if returns is not None else None,
body=body, body=body,
) )
case _: case _:
print(f"Unsupported function definition: {ast.unparse(node)}") print(f"Unsupported function definition: {ast.unparse(node)}")
def _parse_function_argument( def _parse_param_spec(self, args: ast.arguments) -> ParamSpec:
def parse_params(
args_list: list[ast.arg], defaults: list[Optional[Expr]]
) -> list[Function.Parameter]:
return [
self._parse_function_parameter(arg, default)
for arg, default in zip(args_list, defaults)
]
defaults: list[ast.expr] = args.defaults
parsed_defaults: list[Optional[Expr]] = [
self.parse_expr(default) for default in defaults
]
n_pos: int = len(args.posonlyargs)
n_mixed: int = len(args.args)
n_all_pos = n_pos + n_mixed
parsed_defaults = [
None,
] * (n_all_pos - len(defaults)) + parsed_defaults
pos_defaults: list[Optional[Expr]] = parsed_defaults[:n_pos]
mixed_defaults: list[Optional[Expr]] = parsed_defaults[n_pos:]
kw_defaults: list[Optional[Expr]] = [
self.parse_expr(default) if default is not None else None
for default in args.kw_defaults
]
return ParamSpec(
pos=parse_params(args.posonlyargs, pos_defaults),
mixed=parse_params(args.args, mixed_defaults),
kw=parse_params(args.kwonlyargs, kw_defaults),
)
def _parse_function_parameter(
self, arg: ast.arg, default: Optional[Expr] self, arg: ast.arg, default: Optional[Expr]
) -> Function.Argument: ) -> Function.Parameter:
loc: Location = Location.from_ast(arg) loc: Location = Location.from_ast(arg)
name: str = arg.arg name: str = arg.arg
type: Optional[MidasType] = None type: Optional[MidasType] = None
if arg.annotation is not None: if arg.annotation is not None:
type = self._parse_type(arg.annotation) type = self._parse_type(arg.annotation)
return Function.Argument( return Function.Parameter(
location=loc, location=loc,
name=name, name=name,
type=type, type=type,
@@ -299,26 +318,28 @@ class PythonParser:
case ast.Subscript(value=ast.Name(id="Frame"), slice=schema): case ast.Subscript(value=ast.Name(id="Frame"), slice=schema):
return self._parse_frame_type(schema) return self._parse_frame_type(schema)
case ast.Subscript(value=ast.Name(id=name), slice=param): case ast.Subscript(value=ast.Name(id=name), slice=arg):
args: tuple[MidasType, ...] = (
tuple(self._parse_type(a) for a in arg.elts)
if isinstance(arg, ast.Tuple)
else (self._parse_type(arg),)
)
return BaseType( return BaseType(
location=loc, location=loc,
base=name, base=name,
param=self._parse_type(param), args=args,
) )
case ast.Name(id=name): case ast.Name(id=name):
return BaseType( return BaseType(
location=loc, location=loc,
base=name, base=name,
param=None, args=(),
) )
case ast.BinOp(left=left_expr, op=ast.Add(), right=right_expr): case ast.BinOp(left=left_expr, op=ast.Add(), right=right_expr):
left = self._parse_type(left_expr) left = self._parse_type(left_expr)
match left: match left:
case None:
raise InvalidSyntaxError()
# If chained constraints, separate base type and rebuild constraint # If chained constraints, separate base type and rebuild constraint
case ConstraintType(type=left_type, constraint=left_constraint): case ConstraintType(type=left_type, constraint=left_constraint):
constraint = ast.BinOp( constraint = ast.BinOp(
@@ -344,7 +365,7 @@ class PythonParser:
return BaseType( return BaseType(
location=loc, location=loc,
base="None", base="None",
param=None, args=(),
) )
case _: case _:
@@ -359,7 +380,7 @@ class PythonParser:
for col in cols: for col in cols:
columns.append(self._parse_frame_column(col)) columns.append(self._parse_frame_column(col))
case ast.Slice() | ast.Name(): case ast.Slice() | ast.Name() | ast.Subscript():
columns.append(self._parse_frame_column(schema)) columns.append(self._parse_frame_column(schema))
case _: case _:
@@ -370,7 +391,7 @@ class PythonParser:
def _parse_frame_column(self, column: ast.expr) -> FrameColumn: def _parse_frame_column(self, column: ast.expr) -> FrameColumn:
loc: Location = Location.from_ast(column) loc: Location = Location.from_ast(column)
match column: match column:
case ast.Name(): case ast.Name() | ast.Subscript():
return FrameColumn( return FrameColumn(
location=loc, location=loc,
name=None, name=None,
@@ -423,6 +444,9 @@ class PythonParser:
case ast.Call(func=ast.Name(id=self.CAST_FUNCTION)): case ast.Call(func=ast.Name(id=self.CAST_FUNCTION)):
return self.parse_cast(node) return self.parse_cast(node)
case ast.Call(func=ast.Name(id=self.UNSAFE_CAST_FUNCTION)):
return self.parse_cast(node)
case ast.Call(): case ast.Call():
return self.parse_call(node) return self.parse_call(node)
@@ -473,6 +497,12 @@ class PythonParser:
step=self.parse_expr(step) if step is not None else None, step=self.parse_expr(step) if step is not None else None,
) )
case ast.Tuple(elts=items):
return TupleExpr(
location=location,
items=tuple(self.parse_expr(item) for item in items),
)
case _: case _:
print(f"Unsupported expression: {ast.unparse(node)}") print(f"Unsupported expression: {ast.unparse(node)}")
return RawExpr(location=location, expr=node) return RawExpr(location=location, expr=node)
@@ -527,16 +557,19 @@ class PythonParser:
return expr return expr
def parse_cast(self, node: ast.Call) -> CastExpr: def parse_cast(self, node: ast.Call) -> CastExpr:
assert isinstance(node.func, ast.Name)
func: str = node.func.id
match node: match node:
case ast.Call(args=[type, expr], keywords=[]): case ast.Call(args=[type, expr], keywords=[]):
return CastExpr( return CastExpr(
location=Location.from_ast(node), location=Location.from_ast(node),
type=self._parse_type(type), type=self._parse_type(type),
expr=self.parse_expr(expr), expr=self.parse_expr(expr),
unsafe=func == self.UNSAFE_CAST_FUNCTION,
) )
case _: case _:
raise InvalidSyntaxError( raise InvalidSyntaxError(
f"Invalid call to {self.CAST_FUNCTION}, expected type and expression" f"Invalid call to {func}, expected type and expression"
) )
def parse_call(self, node: ast.Call) -> CallExpr: def parse_call(self, node: ast.Call) -> CallExpr:

54
midas/typing.py Normal file
View File

@@ -0,0 +1,54 @@
from typing import Generic, TypeVar
from typing import cast as typing_cast
from pandas import DataFrame, Series
cast = typing_cast
"""### Midas documentation
Cast a value to a type.
- **Compile-time**: tells the type checker that the return value has the designated type.
- **Run-time**: generates assertions to ensure the value can be interpreted as the given type.
---
<br>
<br>
<br>
_**Internal Python documentation**_
"""
unsafe_cast = typing_cast
"""### Midas documentation
Cast a value to a type.
- **Compile-time**: tells the type checker that the return value has the designated type.
- **Run-time**: -
This operation is unsound, use at your own risk!
---
<br>
<br>
<br>
_**Internal Python documentation**_
"""
T = TypeVar("T")
class Frame(DataFrame, Generic[T]):
"""A `Frame` is the abstract type implemented by `DataFrame`
A frame contains any number of named columns (see :class:`Column`)
"""
class Column(Series, Generic[T]):
"""A `Column` is the abstract type implemented by `Series`
A column contains a any number of values of the same type
"""

View File

@@ -3,6 +3,7 @@ from typing import Any, Callable, Optional
import midas.ast.python as p import midas.ast.python as p
from midas.checker.types import Type from midas.checker.types import Type
from midas.generator.collector import AssertionCollector
AllowRepeat = Callable[[object], bool] AllowRepeat = Callable[[object], bool]
@@ -62,3 +63,5 @@ class UniversalJSONDumper:
class TypedAST: class TypedAST:
stmts: list[p.Stmt] stmts: list[p.Stmt]
judgements: list[tuple[p.Expr, Type]] judgements: list[tuple[p.Expr, Type]]
evaluated_casts: list[p.CastExpr]
assertions: AssertionCollector

View File

@@ -8,7 +8,11 @@ authors = [
{ name = "Louis Heredero", email = "louis.heredero@students.hevs.ch" }, { name = "Louis Heredero", email = "louis.heredero@students.hevs.ch" },
] ]
classifiers = ["Programming Language :: Python :: 3"] classifiers = ["Programming Language :: Python :: 3"]
dependencies = ["click>=8.4.1"] dependencies = [
"black>=26.5.1",
"click>=8.4.1",
"watchdog>=6.0.0",
]
[project.urls] [project.urls]
Homepage = "https://git.kbk28.ch/HEL/midas" Homepage = "https://git.kbk28.ch/HEL/midas"

View File

@@ -1,20 +1,8 @@
identifier ::= '[a-zA-Z][a-zA-Z_]*' Identifier ::= '[a-zA-Z][a-zA-Z_]*'
integer ::= '\d+' TypeArgs ::= "[" (Type ("," Type)*)? "]"
number ::= integer ["." integer]
boolean ::= "False" | "True"
none ::= "None"
value ::= number | boolean | none FrameColumn ::= ((Identifier | "_") ":")? Type
lambda-value ::= "_" | value FrameSchema ::= "[" (FrameColumn ("," FrameColumn)*)? "]"
lambda-operator ::= ">" | "<" | ">=" | "<=" | "==" | "!="
lambda ::= lambda-value lambda-operator lambda-value
constraint ::= identifier | "(" lambda ")" Type ::= "Frame" FrameSchema | Identifier TypeArgs?
base-type ::= identifier
type ::= base-type { "+" constraint }
column-type ::= type | "_"
column-def ::= [ identifier ":" ] column-type
frame-def ::= column-def { "," column-def }

View File

@@ -1,64 +1,72 @@
#import "@preview/fervojo:0.1.1": render #import "@preview/fervojo:0.1.1": default-css, render
#let value = ``` #let extra-css = ```css
{[`value` < svg.railroad .terminal rect {
[`number` 'digit' * ! <!, ["." 'digit' * !]>], fill: #F7DCD4;
[`boolean` <"False", "True">], }
[`none` "None"] ```
#let css = default-css() + bytes(extra-css.text)
#let type-args = ```
{[`type-args` "[" <!, 'type'*","> "]"]}
```
#let frame-schema = ```
{[`frame-schema` "[" <!, [[<'identifier', "_"> ":"]? 'type']*","> "]"]}
```
#let type = ```
{[`type` <
["Frame" 'frame-schema'],
['identifier' <!, 'type-args'>]
>]} >]}
``` ```
#let constraint = ```
{[`constraint` <"_", 'value'> <">", "<", ">=", "<=", "==", "!="> <"_", 'value'>]}
```
#let type-with-constraints = ```
{[`type-with-constraints` 'identifier' <!, ["+" "(" 'constraint' ")"] * !>]}
```
#let column-def = ```
{[`column-def` <!, ['identifier' ":"]> <"_", 'type-with-constraints'>]}
```
#let frame-def = ```
{[`frame-def` 'column-def' * ","]}
```
#let annotation = ```
{[`annotation` 'identifier' <!, ["[" 'frame-def' "]"]>]}
```
#let rules = ( #let rules = (
value, type-args: type-args,
constraint, frame-schema: frame-schema,
type-with-constraints, type: type,
column-def, )
frame-def,
annotation, #let inline = (
"type-args",
"frame-schema",
) )
#set text(font: "Source Sans 3") #set text(font: "Source Sans 3")
= Type annotation syntax #title[Supported Python annotation syntax]
#for rule in rules { = Outline
render(rule)
}
/* #box(
#let by-name = ( columns(
annotation: annotation, 2,
frame-def: frame-def, outline(title: none),
column-def: column-def, ),
type-with-constraints: type-with-constraints, height: 9cm,
constraint: constraint, stroke: 1pt,
value: value, inset: 1em,
) )
= Statements and expressions
#for (name, rule) in rules.pairs().rev() {
[== #name]
render(rule, css: css)
}
#let substitute(base-rule) = { #let substitute(base-rule) = {
let new-rule = base-rule let new-rule = base-rule
for (key, rule) in by-name.pairs() { for name in inline {
new-rule = new-rule.replace("'" + key + "'", rule.text.slice(1, -1)) let rule = rules.at(name)
let replacement = rule.text.slice(1, -1).replace(regex("\[`.*?`"), "[")
replacement = "[" + replacement + "#`" + name + "`]"
new-rule = new-rule.replace(
"'" + name + "'",
replacement,
)
} }
if new-rule != base-rule { if new-rule != base-rule {
new-rule = substitute(new-rule) new-rule = substitute(new-rule)
@@ -66,9 +74,16 @@
return new-rule return new-rule
} }
#let combined = raw(substitute(annotation.text))
#set page(flipped: true) #set page(flipped: true)
#render(combined)
*/
= Combined rules
#for (name, rule) in rules.pairs() {
if not name in inline {
[== #name]
let combined = substitute(rule.text)
render(raw(combined), css: css)
//raw(block: true, combined)
}
}

View File

@@ -4,40 +4,85 @@ Identifier ::= [a-zA-Z_] [a-zA-Z_0-9]*
Integer ::= '\d+' Integer ::= '\d+'
Number ::= "-"? Integer ("." Integer)? Number ::= "-"? Integer ("." Integer)?
Boolean ::= "False" | "True" Boolean ::= "False" | "True"
String ::= '(".*?")|(\'.*?\')'
None ::= "None" None ::= "None"
Value ::= Number | Boolean | None Literal ::= Number | Boolean | String | None
UnaryOp ::= "+" | "-" | "!"
FactorOp ::= "*" | "/"
TermOp ::= "+" | "-"
ComparisonOp ::= ">" | "<" | ">=" | "<=" ComparisonOp ::= ">" | "<" | ">=" | "<="
EqualityOp ::= "==" | "!=" EqualityOp ::= "==" | "!="
Grouping ::= "(" Constraint ")" PosArg ::= Expression
Primary ::= "_" | Value | Identifier | Grouping KwArg ::= Identifier "=" Expression
PosArgs ::= PosArg ("," PosArg)*
KwArgs ::= KwArg ("," KwArg)*
Args ::= (
PosArgs
| KwArgs
| PosArgs "," KwArgs
)
Grouping ::= "(" Expression ")"
Primary ::= "_" | Literal | Identifier | Grouping
Reference ::= Primary ("." Identifier)* Reference ::= Primary ("." Identifier)*
Unary ::= "-"? Unary | Reference CallArgs ::= "(" Args ")"
Comparison ::= Unary (ComparisonOp Unary)* Call ::= Reference CallArgs*
Unary ::= UnaryOp Unary | Call
Factor ::= Unary (FactorOp Unary)*
Term ::= Factor (TermOp Factor)*
Comparison ::= Term (ComparisonOp Term)*
Equality ::= Comparison (EqualityOp Comparison)* Equality ::= Comparison (EqualityOp Comparison)*
Constraint ::= Equality ("&" Equality)* Expression ::= Equality ("&" Equality)*
Constraint ::= Expression
TemplateParam ::= Identifier ("<:" Type)? TemplateParam ::= Identifier ("<:" Type)?
Template ::= "[" (TemplateParam ("," TemplateParam)*)? "]" Template ::= "[" (TemplateParam ("," TemplateParam)*)? "]"
ParamType ::= Type "?"?
PosParam ::= (Identifier ":")? ParamType
KwParam ::= Identifier ":" ParamType
PosParams ::= (
(PosParam ("," PosParam)* ("," "/")?)
| "/"
)
MixedParams ::= KwParam ("," KwParam)
KwParams ::= (
(("*", ",")? KwParam ("," KwParam)*)
| "*"
)
Params ::= (
PosParams
| MixedParams
| KwParams
| (PosParams "," MixedParams)
| (PosParams "," KwParams)
| (MixedParams "," KwParams)
| (PosParams "," MixedParams "," KwParams)
)
ParamSpec ::= "(" Params? ")"
TypeProperty ::= Identifier ":" Type
ComplexType ::= "{" TypeProperty* "}"
NamedType ::= Identifier NamedType ::= Identifier
TypeParams ::= "[" (Type ("," Type)*)? "]" TypeArgs ::= "[" (Type ("," Type)*)? "]"
GenericType ::= NamedType TypeParams? FrameColumn ::= TOKEN ":" Type
FrameSchema ::= "[" (FrameColumn ("," FrameColumn)*)? "]"
GenericType ::= "Frame" FrameSchema | NamedType TypeArgs?
GroupedType ::= "(" Type ")" GroupedType ::= "(" Type ")"
BaseType ::= GroupedType | ComplexType | GenericType BaseType ::= GroupedType | GenericType
ConstraintType ::= BaseType ("where" Constraint)? ConstraintType ::= BaseType ("where" Constraint)?
FuncType ::= "fn" ParamSpec "->" Type
Type ::= ConstraintType Type ::= ConstraintType
OpDefinition ::= "op" Identifier "(" Type ")" "->" Type MemberStatement ::= ("prop" | "def") Identifier ":" Type
ExtendBody ::= "{" OpDefinition* "}" ExtendBody ::= "{" MemberStatement* "}"
AliasStatement ::= "alias" Identifier "=" Type
TypeStatement ::= "type" Identifier Template? "=" Type TypeStatement ::= "type" Identifier Template? "=" Type
ExtendStatement ::= "extend" Type ExtendBody ExtendStatement ::= "extend" Type ExtendBody
PredicateStatement ::= "predicate" Identifier "(" Identifier ":" Type ")" "=" Constraint PredicateStatement ::= "predicate" Identifier ParamSpec* "=" Constraint
Statement ::= TypeStatement | ExtendStatement | PredicateStatement Statement ::= AliasStatement | TypeStatement | ExtendStatement | PredicateStatement

View File

@@ -7,40 +7,61 @@ svg.railroad .terminal rect {
``` ```
#let css = default-css() + bytes(extra-css.text) #let css = default-css() + bytes(extra-css.text)
#let value = ``` #let literal = ```
{[`value` < {[`literal` <
[`number` 'digit' * ! <!, ["." 'digit' * !]>], [`number` 'digit' * ! <!, ["." 'digit' * !]>],
[`boolean` <"False", "True">], [`boolean` <"False", "True">],
[`string` <["\"" 'char'*! "\""], ["'" 'char'*! "'"]>],
[`none` "None"] [`none` "None"]
>]} >]}
``` ```
#let grouping = ``` #let grouping = ```
{[`grouping` "(" 'constraint' ")"]} {[`grouping` "(" 'expression' ")"]}
``` ```
#let primary = ``` #let primary = ```
{[`primary` <"_", 'value', 'identifier', 'grouping'>]} {[`primary` <"_", 'literal', 'identifier', 'grouping'>]}
``` ```
#let reference = ``` #let reference = ```
{[`reference` 'primary' <!, ["." 'identifier']*!>]} {[`reference` 'primary' <!, ["." 'identifier']*!>]}
``` ```
#let call-args = ```
{[`call-args` "(" <!, <'expression', ['identifier' "=" 'expression']>*","#`Same rules as Python`> ")"]}
```
#let call = ```
{[`call` 'reference' <!, 'call-args'*!>]}
```
#let unary = ``` #let unary = ```
{[`unary` <[<!, "-"> 'unary'], 'reference'>]} {[`unary` <[<"+", "-", "!"> 'unary'], 'call'>]}
```
#let factor = ```
{[`factor` 'unary'*<"*", "/">]}
```
#let term = ```
{[`term` 'factor'*<"+", "-">]}
``` ```
#let comparison = ``` #let comparison = ```
{[`comparison` 'unary'*<">", "<", ">=", "<=">]} {[`comparison` 'term'*<">", "<", ">=", "<=">]}
``` ```
#let equality = ``` #let equality = ```
{[`equality` 'comparison'*<"==", "!=">]} {[`equality` 'comparison'*<"==", "!=">]}
``` ```
#let expression = ```
{[`expression` 'equality'*"&"]}
```
#let constraint = ``` #let constraint = ```
{[`constraint` 'equality'*"&"]} {[`constraint` 'expression']}
``` ```
#let template-param = ``` #let template-param = ```
@@ -51,24 +72,20 @@ svg.railroad .terminal rect {
{[`template` "[" <!, 'template-param'*","> "]"]} {[`template` "[" <!, 'template-param'*","> "]"]}
``` ```
#let type-property = ```
{[`type-property` 'identifier' ":" 'type']}
```
#let complex-type = ```
{[`complex-type` "{" <!, 'type-property'*!> "}"]}
```
#let named-type = ``` #let named-type = ```
{[`named-type` 'identifier']} {[`named-type` 'identifier']}
``` ```
#let type-params = ``` #let type-args = ```
{[`type-params` "[" <!, 'type'*","> "]"]} {[`type-args` "[" <!, 'type'*","> "]"]}
```
#let frame-schema = ```
{[`frame-schema` "[" <!, ['TOKEN' ":" 'type']*","> "]"]}
``` ```
#let generic-type = ``` #let generic-type = ```
{[`generic-type` 'named-type' <!, 'type-params'>]} {[`generic-type` <["Frame" 'frame-schema'], ['named-type' <!, 'type-args'>]>]}
``` ```
#let grouped-type = ``` #let grouped-type = ```
@@ -76,59 +93,88 @@ svg.railroad .terminal rect {
``` ```
#let base-type = ``` #let base-type = ```
{[`base-type` <'grouped-type', 'complex-type', 'generic-type'>]} {[`base-type` <'grouped-type', 'generic-type'>]}
``` ```
#let constraint-type = ``` #let constraint-type = ```
{[`constraint-type` 'base-type' <!, ["where" 'constraint']>]} {[`constraint-type` 'base-type' <!, ["where" 'constraint']>]}
``` ```
#let pos-param = ```
{[`pos-param` <!, ['identifier' ":"]> 'type' <!, "?">]}
```
#let kw-param = ```
{[`kw-param` 'identifier' ":" 'type' <!, "?">]}
```
#let param-spec = ```
{[`param-spec` "(" <!, <'pos-param', "/", "*", 'kw-param'>*",">#`Same rules as Python` ")"]}
```
#let func-type = ```
{[`func-type` "fn" 'param-spec' "->" 'type']}
```
#let type = ``` #let type = ```
{[`type` 'constraint-type']} {[`type` <'func-type', 'constraint-type'>]}
```
#let alias-statement = ```
{[`alias-statement` "alias" 'identifier' "=" 'type']}
``` ```
#let type-statement = ``` #let type-statement = ```
{[`type-statement` "type" 'identifier' <!, 'template'> "=" 'type']} {[`type-statement` "type" 'identifier' <!, 'template'> "=" 'type']}
``` ```
#let op-definition = ``` #let member-stmt = ```
{[`op-definition` "op" 'identifier' "(" 'type' ")" "->" 'type']} {[`member-stmt` <"prop", "def"> 'identifier' ":" 'type']}
``` ```
#let extend-statement = ``` #let extend-statement = ```
{[`extend-statement` "extend" 'type' "{" <!, 'op-definition'*!> "}"]} {[`extend-statement` "extend" 'type' "{" <!, 'member-stmt'*!> "}"]}
``` ```
#let predicate-statement = ``` #let predicate-statement = ```
{[`predicate-statement` "predicate" 'identifier' "(" 'identifier' ":" 'type' ")" "=" 'constraint']} {[`predicate-statement` "predicate" 'identifier' <!, 'param-spec'*!> "=" 'constraint']}
``` ```
#let statement = ``` #let statement = ```
{[`statement` <'type-statement', 'extend-statement', 'predicate-statement'>]} {[`statement` <'alias-statement', 'type-statement', 'extend-statement', 'predicate-statement'>]}
``` ```
#let rules = ( #let rules = (
value: value, literal: literal,
grouping: grouping, grouping: grouping,
primary: primary, primary: primary,
reference: reference, reference: reference,
call-args: call-args,
call: call,
unary: unary, unary: unary,
factor: factor,
term: term,
comparison: comparison, comparison: comparison,
equality: equality, equality: equality,
expression: expression,
constraint: constraint, constraint: constraint,
template-param: template-param, template-param: template-param,
template: template, template: template,
type-property: type-property, type-property: type-property,
complex-type: complex-type,
named-type: named-type, named-type: named-type,
type-params: type-params, type-args: type-args,
generic-type: generic-type, generic-type: generic-type,
grouped-type: grouped-type, grouped-type: grouped-type,
base-type: base-type, base-type: base-type,
constraint-type: constraint-type, constraint-type: constraint-type,
pos-param: pos-param,
kw-param: kw-param,
param-spec: param-spec,
func-type: func-type,
type: type, type: type,
alias-statement: alias-statement,
type-statement: type-statement, type-statement: type-statement,
op-definition: op-definition, member-stmt: member-stmt,
extend-statement: extend-statement, extend-statement: extend-statement,
predicate-statement: predicate-statement, predicate-statement: predicate-statement,
statement: statement, statement: statement,
@@ -136,18 +182,22 @@ svg.railroad .terminal rect {
#let inline = ( #let inline = (
"grouping", "grouping",
"value", "literal",
"template-param", "template-param",
"template", "template",
"type-property", "type-property",
"complex-type", "call-args",
"type-params", "type-args",
"named-type", "named-type",
"grouped-type", "grouped-type",
"generic-type", "generic-type",
"base-type", "base-type",
"constraint-type", "constraint-type",
"op-definition", "pos-param",
"kw-param",
"func-type",
"member-stmt",
"alias-statement",
"type-statement", "type-statement",
"extend-statement", "extend-statement",
"predicate-statement", "predicate-statement",
@@ -164,7 +214,7 @@ svg.railroad .terminal rect {
2, 2,
outline(title: none), outline(title: none),
), ),
height: 9cm, height: 15cm,
stroke: 1pt, stroke: 1pt,
inset: 1em, inset: 1em,
) )

49
tests/__main__.py Normal file
View File

@@ -0,0 +1,49 @@
import sys
from typing import Type
from midas.cli.ansi import Ansi
from tests.base import Tester, TestsSummary
from tests.checker import CheckerTester
from tests.generator import GeneratorTester
from tests.midas import MidasTester
from tests.python import PythonTester
def print_banner(name: str):
horizontal: str = "+" + "-" * (len(name) + 2) + "+"
print(horizontal)
print(f"| {name} |")
print(horizontal)
def run_tests(tester_cls: Type[Tester]) -> TestsSummary:
print_banner(tester_cls.__name__)
tester: Tester = tester_cls()
summary: TestsSummary = tester.run_all_tests()
print()
return summary
def main():
testers: list[Type[Tester]] = [
PythonTester,
MidasTester,
CheckerTester,
GeneratorTester,
]
summaries: list[TestsSummary] = list(
map(run_tests, testers)
) # list to avoid early stop
summary: TestsSummary = TestsSummary.concat(*summaries)
if summary.success:
print(Ansi.FG(Ansi.BRIGHT_GREEN) + "All tests passed!" + Ansi.RESET)
else:
print(Ansi.FG(Ansi.BRIGHT_RED) + "Some tests failed!" + Ansi.RESET)
summary.print()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -4,14 +4,54 @@ import argparse
import difflib import difflib
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Iterator, Protocol from typing import Iterator, Protocol
from midas.cli.ansi import Ansi
class CaseResult(Protocol): class CaseResult(Protocol):
def dumps(self) -> str: ... def dumps(self) -> str: ...
@dataclass
class TestsSummary:
tests: list[tuple[str, bool]] = field(default_factory=list)
@property
def success(self) -> bool:
return all(map(lambda t: t[1], self.tests))
@property
def successes(self) -> int:
return sum(map(lambda t: int(t[1]), self.tests))
@property
def failures(self) -> int:
return len(self.tests) - self.successes
def add(self, name: str, success: bool):
self.tests.append((name, success))
@staticmethod
def concat(*summaries: TestsSummary) -> TestsSummary:
return TestsSummary(
tests=sum(
map(lambda s: s.tests, summaries),
start=[],
),
)
def print(self):
print("Tests summary:")
tests: list[tuple[str, bool]] = sorted(self.tests, key=lambda t: t[0])
for test, success in tests:
print(f" - [{'.' if success else 'X'}] {test}")
print("-" * 20)
print(f"passed: {self.successes}, failed: {self.failures}")
class Tester(ABC): class Tester(ABC):
"""A test runner to check for regressions in the lexer and parser""" """A test runner to check for regressions in the lexer and parser"""
@@ -32,37 +72,37 @@ class Tester(ABC):
@abstractmethod @abstractmethod
def _list_tests(self) -> list[Path]: ... def _list_tests(self) -> list[Path]: ...
def run_all_tests(self) -> bool: def run_all_tests(self) -> TestsSummary:
paths: list[Path] = sorted(self._list_tests()) paths: list[Path] = sorted(self._list_tests())
return self.run_tests(paths) return self.run_tests(paths)
def run_tests(self, tests: list[Path]) -> bool: def run_tests(self, tests: list[Path]) -> TestsSummary:
rule: str = "-" * 80 rule: str = "-" * 80
n: int = len(tests) n: int = len(tests)
successes: int = 0
failures: int = 0 summary: TestsSummary = TestsSummary()
print(rule) print(rule)
for i, test in enumerate(tests): for i, test in enumerate(tests):
print(f"Case {i+1}/{n}: {test.resolve().relative_to(self.CASES_DIR)}") path: Path = test.resolve().relative_to(self.CASES_DIR)
print(f"{Ansi.FG(Ansi.BRIGHT_CYAN)}Case {i+1}/{n}: {path}{Ansi.RESET}")
success: bool = self._run_test(test) success: bool = self._run_test(test)
if success: summary.add(str(path), success)
successes += 1
else:
failures += 1
print(rule) print(rule)
print(f"Success: {successes}/{n}") print(f"Success: {summary.successes}/{n}")
print(f"Failed: {failures}/{n}") print(f"Failed: {summary.failures}/{n}")
print(rule) print(rule)
return failures == 0 return summary
def _run_test(self, path: Path) -> bool: def _run_test(self, path: Path) -> bool:
result_path: Path = self._result_path(path) result_path: Path = self._result_path(path)
if not result_path.exists(): if not result_path.exists():
print("Missing snapshot. Please run the update command first") print("Missing snapshot. Please run the update command first")
return False return False
print(Ansi.DIM, end="")
result: CaseResult = self._exec_case(path) result: CaseResult = self._exec_case(path)
print(Ansi.RESET, end="")
expected: str = result_path.read_text() expected: str = result_path.read_text()
actual: str = result.dumps() actual: str = result.dumps()
@@ -138,16 +178,19 @@ class Tester(ABC):
else: else:
tester.update_tests(args.FILE) tester.update_tests(args.FILE)
case "run": case "run":
success: bool summary: TestsSummary
if args.all: if args.all:
success = tester.run_all_tests() summary = tester.run_all_tests()
else: else:
success = tester.run_tests(args.FILE) summary = tester.run_tests(args.FILE)
if not success: if not summary.success:
summary.print()
sys.exit(1) sys.exit(1)
case None: case None:
print("No subcommand provided. Available subcommands: run, update") summary: TestsSummary = tester.run_all_tests()
sys.exit(1) if not summary.success:
summary.print()
sys.exit(1)
case _: case _:
print(f"Unknown subcommand '{args.subcommand}'") print(f"Unknown subcommand '{args.subcommand}'")
sys.exit(1) sys.exit(1)

View File

@@ -4,7 +4,35 @@
"type": "Warning", "type": "Warning",
"location": { "location": {
"start": [ "start": [
6, 8,
12
],
"end": [
8,
43
]
},
"message": "ConstraintType not yet supported"
},
{
"type": "Warning",
"location": {
"start": [
10,
10
],
"end": [
10,
18
]
},
"message": "Unknown type 'datetime'"
},
{
"type": "Warning",
"location": {
"start": [
13,
4 4
], ],
"end": [ "end": [
@@ -12,7 +40,7 @@
5 5
] ]
}, },
"message": "FrameType not yet supported" "message": "Unknown type '_'"
} }
], ],
"judgments": [] "judgments": []

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,19 @@
{ {
"diagnostics": [], "diagnostics": [],
"judgments": [ "judgments": [
{
"location": {
"from": "L4:30",
"to": "L4:36"
},
"expr": {
"_type": "LiteralExpr",
"value": 123.45
},
"type": {
"name": "float"
}
},
{ {
"location": { "location": {
"from": "L4:18", "from": "L4:18",
@@ -11,12 +24,13 @@
"type": { "type": {
"_type": "BaseType", "_type": "BaseType",
"base": "Meter", "base": "Meter",
"param": null "args": []
}, },
"expr": { "expr": {
"_type": "LiteralExpr", "_type": "LiteralExpr",
"value": 123.45 "value": 123.45
} },
"unsafe": false
}, },
"type": { "type": {
"name": "Meter", "name": "Meter",
@@ -25,6 +39,19 @@
} }
} }
}, },
{
"location": {
"from": "L5:28",
"to": "L5:31"
},
"expr": {
"_type": "LiteralExpr",
"value": 6.7
},
"type": {
"name": "float"
}
},
{ {
"location": { "location": {
"from": "L5:15", "from": "L5:15",
@@ -35,12 +62,13 @@
"type": { "type": {
"_type": "BaseType", "_type": "BaseType",
"base": "Second", "base": "Second",
"param": null "args": []
}, },
"expr": { "expr": {
"_type": "LiteralExpr", "_type": "LiteralExpr",
"value": 6.7 "value": 6.7
} },
"unsafe": false
}, },
"type": { "type": {
"name": "Second", "name": "Second",

View File

@@ -100,41 +100,6 @@
"name": "float" "name": "float"
} }
}, },
{
"location": {
"from": "L11:5",
"to": "L11:12"
},
"expr": {
"_type": "VariableExpr",
"name": "maximum"
},
"type": {
"pos_args": [],
"args": [
{
"pos": 0,
"name": "a",
"type": {
"name": "float"
},
"required": true
},
{
"pos": 1,
"name": "b",
"type": {
"name": "float"
},
"required": true
}
],
"kw_args": [],
"returns": {
"name": "float"
}
}
},
{ {
"location": { "location": {
"from": "L11:13", "from": "L11:13",
@@ -161,6 +126,45 @@
"name": "float" "name": "float"
} }
}, },
{
"location": {
"from": "L11:5",
"to": "L11:12"
},
"expr": {
"_type": "VariableExpr",
"name": "maximum"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "a",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
},
{
"pos": 1,
"name": "b",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{ {
"location": { "location": {
"from": "L11:5", "from": "L11:5",

View File

@@ -0,0 +1,59 @@
// T is invariant (unused)
type Unused[T] = object
// T is covariant
type Covariant[T] = object
// T is contravariant
type Contravariant[T] = object
// T is invariant
type Invariant[T] = object
extend Covariant[T] {
def foo: fn() -> T
}
extend Contravariant[T] {
def foo: fn(T, /) -> None
}
extend Invariant[T] {
def foo: fn(T, /) -> T
}
// T is covariant
type Coco[T] = object
extend Coco[T] {
def foo: fn() -> Covariant[T]
}
// T is contravariant
type Cocontra[T] = object
extend Cocontra[T] {
def foo: fn() -> Contravariant[T]
}
// T is contravariant
type Contraco[T] = object
extend Contraco[T] {
def foo: fn(Covariant[T], /) -> None
}
// T is covariant
type Contracontra[T] = object
extend Contracontra[T] {
def foo: fn(Contravariant[T], /) -> None
}
type T1[T] = object
type T2[T] = object
extend T1[T] {
def foo: fn() -> T2[T]
}
extend T2[T] {
def foo: fn() -> T1[T]
}

View File

@@ -0,0 +1,52 @@
from _ import (
T1,
T2,
Coco,
Cocontra,
Contraco,
Contracontra,
Contravariant,
Covariant,
Invariant,
Unused,
)
unused: Unused = object()
covariant: Covariant = object()
contravariant: Contravariant = object()
invariant: Invariant = object()
coco: Coco = object()
cocontra: Cocontra = object()
contraco: Contraco = object()
contracontra: Contracontra = object()
t1: T1 = object()
t2: T2 = object()
# Dummy print to prudce judgements for the expressions
print(
unused,
covariant,
contravariant,
invariant,
coco,
cocontra,
contraco,
contracontra,
t1,
t2,
)
cov1: Covariant[float] = object()
cov2: Covariant[int] = object()
cov1 = cov2 # Ok because int <: float => Covariant[int] <: Covariant[float]
cov2 = cov1 # Invalid
contra1: Contravariant[float] = object()
contra2: Contravariant[int] = object()
contra1 = contra2 # Invalid
contra2 = contra1 # Ok because int <: float => Covariant[float] <: Covariant[int]
inv1: Invariant[float] = object()
inv2: Invariant[int] = object()
inv1 = inv2 # Invalid
inv2 = inv1 # Invalid

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
def double(value: float) -> float:
return value * 2
def is_odd(value: int) -> bool:
return bool(value % 2)
floats: list[float] = [0.2, 0.5, 0.1, 1.2]
ints: list[int] = [1, 2, 6, -3]
doubled_floats = map(double, floats)
doubled_ints = map(double, ints)
odd_ints = map(is_odd, ints)

View File

@@ -0,0 +1,907 @@
{
"diagnostics": [
{
"type": "Error",
"location": {
"start": [
13,
15
],
"end": [
13,
32
]
},
"message": "Could not unify map[T, U]=(transform: (v: T, /) -> U, iterable: list[T], /) -> list[U] with pos=[(value: float) -> float, list[int]] and kw={}"
}
],
"judgments": [
{
"location": {
"from": "L2:11",
"to": "L2:16"
},
"expr": {
"_type": "VariableExpr",
"name": "value"
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L2:19",
"to": "L2:20"
},
"expr": {
"_type": "LiteralExpr",
"value": 2
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L2:11",
"to": "L2:20"
},
"expr": {
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "value"
},
"operator": "*",
"right": {
"_type": "LiteralExpr",
"value": 2
}
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L6:16",
"to": "L6:21"
},
"expr": {
"_type": "VariableExpr",
"name": "value"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L6:24",
"to": "L6:25"
},
"expr": {
"_type": "LiteralExpr",
"value": 2
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L6:16",
"to": "L6:25"
},
"expr": {
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "value"
},
"operator": "%",
"right": {
"_type": "LiteralExpr",
"value": 2
}
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L6:11",
"to": "L6:15"
},
"expr": {
"_type": "VariableExpr",
"name": "bool"
},
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "object",
"type": {},
"required": false,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "bool"
}
}
},
{
"location": {
"from": "L6:11",
"to": "L6:26"
},
"expr": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "bool"
},
"arguments": [
{
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "value"
},
"operator": "%",
"right": {
"_type": "LiteralExpr",
"value": 2
}
}
],
"keywords": {}
},
"type": {
"name": "bool"
}
},
{
"location": {
"from": "L9:23",
"to": "L9:26"
},
"expr": {
"_type": "LiteralExpr",
"value": 0.2
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L9:28",
"to": "L9:31"
},
"expr": {
"_type": "LiteralExpr",
"value": 0.5
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L9:33",
"to": "L9:36"
},
"expr": {
"_type": "LiteralExpr",
"value": 0.1
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L9:38",
"to": "L9:41"
},
"expr": {
"_type": "LiteralExpr",
"value": 1.2
},
"type": {
"name": "float"
}
},
{
"location": {
"from": "L9:22",
"to": "L9:42"
},
"expr": {
"_type": "ListExpr",
"items": [
{
"_type": "LiteralExpr",
"value": 0.2
},
{
"_type": "LiteralExpr",
"value": 0.5
},
{
"_type": "LiteralExpr",
"value": 0.1
},
{
"_type": "LiteralExpr",
"value": 1.2
}
]
},
"type": {
"name": "list",
"args": [
{
"name": "float"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L10:19",
"to": "L10:20"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L10:22",
"to": "L10:23"
},
"expr": {
"_type": "LiteralExpr",
"value": 2
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L10:25",
"to": "L10:26"
},
"expr": {
"_type": "LiteralExpr",
"value": 6
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L10:29",
"to": "L10:30"
},
"expr": {
"_type": "LiteralExpr",
"value": 3
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L10:28",
"to": "L10:30"
},
"expr": {
"_type": "UnaryExpr",
"operator": "-",
"right": {
"_type": "LiteralExpr",
"value": 3
}
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L10:18",
"to": "L10:31"
},
"expr": {
"_type": "ListExpr",
"items": [
{
"_type": "LiteralExpr",
"value": 1
},
{
"_type": "LiteralExpr",
"value": 2
},
{
"_type": "LiteralExpr",
"value": 6
},
{
"_type": "UnaryExpr",
"operator": "-",
"right": {
"_type": "LiteralExpr",
"value": 3
}
}
]
},
"type": {
"name": "list",
"args": [
{
"name": "int"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L12:21",
"to": "L12:27"
},
"expr": {
"_type": "VariableExpr",
"name": "double"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "value",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L12:29",
"to": "L12:35"
},
"expr": {
"_type": "VariableExpr",
"name": "floats"
},
"type": {
"name": "list",
"args": [
{
"name": "float"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L12:17",
"to": "L12:20"
},
"expr": {
"_type": "VariableExpr",
"name": "map"
},
"type": {
"name": "map",
"params": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"params": {
"pos": [
{
"pos": 0,
"name": "transform",
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "v",
"type": {
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
},
"required": true,
"unsupported": false
},
{
"pos": 1,
"name": "iterable",
"type": {
"name": "list",
"args": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "list",
"args": [
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
}
}
}
},
{
"location": {
"from": "L12:17",
"to": "L12:36"
},
"expr": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "map"
},
"arguments": [
{
"_type": "VariableExpr",
"name": "double"
},
{
"_type": "VariableExpr",
"name": "floats"
}
],
"keywords": {}
},
"type": {
"name": "list",
"args": [
{
"name": "float"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L13:19",
"to": "L13:25"
},
"expr": {
"_type": "VariableExpr",
"name": "double"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "value",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L13:27",
"to": "L13:31"
},
"expr": {
"_type": "VariableExpr",
"name": "ints"
},
"type": {
"name": "list",
"args": [
{
"name": "int"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L13:15",
"to": "L13:18"
},
"expr": {
"_type": "VariableExpr",
"name": "map"
},
"type": {
"name": "map",
"params": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"params": {
"pos": [
{
"pos": 0,
"name": "transform",
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "v",
"type": {
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
},
"required": true,
"unsupported": false
},
{
"pos": 1,
"name": "iterable",
"type": {
"name": "list",
"args": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "list",
"args": [
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
}
}
}
},
{
"location": {
"from": "L13:15",
"to": "L13:32"
},
"expr": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "map"
},
"arguments": [
{
"_type": "VariableExpr",
"name": "double"
},
{
"_type": "VariableExpr",
"name": "ints"
}
],
"keywords": {}
},
"type": {}
},
{
"location": {
"from": "L14:15",
"to": "L14:21"
},
"expr": {
"_type": "VariableExpr",
"name": "is_odd"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "value",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "bool"
}
}
},
{
"location": {
"from": "L14:23",
"to": "L14:27"
},
"expr": {
"_type": "VariableExpr",
"name": "ints"
},
"type": {
"name": "list",
"args": [
{
"name": "int"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L14:11",
"to": "L14:14"
},
"expr": {
"_type": "VariableExpr",
"name": "map"
},
"type": {
"name": "map",
"params": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"params": {
"pos": [
{
"pos": 0,
"name": "transform",
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "v",
"type": {
"name": "T",
"bound": null,
"variance": "INVARIANT"
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
},
"required": true,
"unsupported": false
},
{
"pos": 1,
"name": "iterable",
"type": {
"name": "list",
"args": [
{
"name": "T",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "list",
"args": [
{
"name": "U",
"bound": null,
"variance": "INVARIANT"
}
],
"body": {
"name": "list"
}
}
}
}
},
{
"location": {
"from": "L14:11",
"to": "L14:28"
},
"expr": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "map"
},
"arguments": [
{
"_type": "VariableExpr",
"name": "is_odd"
},
{
"_type": "VariableExpr",
"name": "ints"
}
],
"keywords": {}
},
"type": {
"name": "list",
"args": [
{
"name": "bool"
}
],
"body": {
"name": "list"
}
}
}
]
}

View File

@@ -0,0 +1,118 @@
# type: ignore
# ruff: disable [F821]
import pandas as pd
df1 = cast(Frame[i:int, a:int, b:float], pd.DataFrame())
df2 = cast(Frame[i:int, a:int, b:float], pd.DataFrame())
_: Any
# Arithmetic
_ = df1 + df2
_ = df1 - df2
_ = df1 * df2
_ = df1 / df2
_ = df1 // df2
_ = df1 % df2
_ = df1**df2
# Comparisons
_ = df1 < df2
_ = df1 > df2
_ = df1 <= df2
_ = df1 >= df2
_ = df1 != df2
_ = df1 == df2
# Aggregate
_ = df1.kurt()
_ = df1.kurtosis()
_ = df1.max()
_ = df1.mean()
_ = df1.median()
_ = df1.min()
_ = df1.mode()
_ = df1.prod()
_ = df1.product()
_ = df1.std()
_ = df1.sum()
_ = df1.var()
# Groupby
df_gb = df1.groupby(by="i")
_ = df_gb.kurt()
_ = df_gb.max()
_ = df_gb.mean()
_ = df_gb.median()
_ = df_gb.min()
_ = df_gb.prod()
_ = df_gb.std()
_ = df_gb.sum()
_ = df_gb.var()
# Columns
col1 = df1["a"]
col2 = df1["a"]
# Arithmetic
_ = col1 + col2
_ = col1 - col2
_ = col1 * col2
_ = col1 / col2
_ = col1 // col2
_ = col1 % col2
_ = col1**col2
# Comparisons
_ = col1 < col2
_ = col1 > col2
_ = col1 <= col2
_ = col1 >= col2
_ = col1 != col2
_ = col1 == col2
# Aggregate
_ = col1.kurt()
_ = col1.kurtosis()
_ = col1.max()
_ = col1.mean()
_ = col1.median()
_ = col1.min()
_ = col1.mode()
_ = col1.prod()
_ = col1.product()
_ = col1.std()
_ = col1.sum()
_ = col1.var()
# Groupby
col_gb = col1.groupby(level=0)
_ = col_gb.kurt()
_ = col_gb.max()
_ = col_gb.mean()
_ = col_gb.median()
_ = col_gb.min()
_ = col_gb.prod()
_ = col_gb.std()
_ = col_gb.sum()
_ = col_gb.var()
# Attributes
_ = df1.ndim # int
_ = df1.size # int
_ = df1.shape # (int, int)
_ = col1.ndim # int
_ = col1.size # int
_ = col1.shape # (int)
_ = col1.T # Column[int]
# Misc
_ = df1.head()
_ = df1.tail()
_ = col1.head()
_ = col1.tail()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
# type: ignore
# ruff: disable[F821]
_: Any
_ = undeclared
declared: int
_ = declared
half_defined1: int
half_defined2: int
if False:
half_defined1 = 0
else:
half_defined2 = 1
_ = half_defined1
_ = half_defined2
fully_defined: int
if False:
fully_defined = 0
else:
fully_defined = 1
_ = fully_defined
defined: int = 0
_ = defined
no_annotation = 0
_ = no_annotation
self_ref1 = self_ref1
self_ref2: int = self_ref2
def fact(n: int) -> int:
if n <= 1:
return 1
return n * fact(n - 1)
for i in [1, 2, 3]:
_ = i
_ = i

View File

@@ -0,0 +1,651 @@
{
"diagnostics": [
{
"type": "Error",
"location": {
"start": [
9,
4
],
"end": [
9,
12
]
},
"message": "Variable 'declared' is declared but may not be defined"
},
{
"type": "Error",
"location": {
"start": [
17,
4
],
"end": [
17,
17
]
},
"message": "Variable 'half_defined1' is declared but may not be defined"
},
{
"type": "Error",
"location": {
"start": [
18,
4
],
"end": [
18,
17
]
},
"message": "Variable 'half_defined2' is declared but may not be defined"
},
{
"type": "Error",
"location": {
"start": [
34,
17
],
"end": [
34,
26
]
},
"message": "Variable 'self_ref2' is declared but may not be defined"
},
{
"type": "Warning",
"location": {
"start": [
6,
4
],
"end": [
6,
14
]
},
"message": "Unknown variable"
},
{
"type": "Warning",
"location": {
"start": [
33,
12
],
"end": [
33,
21
]
},
"message": "Unknown variable"
},
{
"type": "Warning",
"location": {
"start": [
45,
4
],
"end": [
45,
5
]
},
"message": "Unknown variable"
}
],
"judgments": [
{
"location": {
"from": "L6:4",
"to": "L6:14"
},
"expr": {
"_type": "VariableExpr",
"name": "undeclared"
},
"type": {}
},
{
"location": {
"from": "L9:4",
"to": "L9:12"
},
"expr": {
"_type": "VariableExpr",
"name": "declared"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L13:3",
"to": "L13:8"
},
"expr": {
"_type": "LiteralExpr",
"value": false
},
"type": {
"name": "bool"
}
},
{
"location": {
"from": "L14:20",
"to": "L14:21"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L16:20",
"to": "L16:21"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L17:4",
"to": "L17:17"
},
"expr": {
"_type": "VariableExpr",
"name": "half_defined1"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L18:4",
"to": "L18:17"
},
"expr": {
"_type": "VariableExpr",
"name": "half_defined2"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L21:3",
"to": "L21:8"
},
"expr": {
"_type": "LiteralExpr",
"value": false
},
"type": {
"name": "bool"
}
},
{
"location": {
"from": "L22:20",
"to": "L22:21"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L24:20",
"to": "L24:21"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L25:4",
"to": "L25:17"
},
"expr": {
"_type": "VariableExpr",
"name": "fully_defined"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L27:15",
"to": "L27:16"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L28:4",
"to": "L28:11"
},
"expr": {
"_type": "VariableExpr",
"name": "defined"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L30:16",
"to": "L30:17"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L31:4",
"to": "L31:17"
},
"expr": {
"_type": "VariableExpr",
"name": "no_annotation"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L33:12",
"to": "L33:21"
},
"expr": {
"_type": "VariableExpr",
"name": "self_ref1"
},
"type": {}
},
{
"location": {
"from": "L34:17",
"to": "L34:26"
},
"expr": {
"_type": "VariableExpr",
"name": "self_ref2"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L38:7",
"to": "L38:8"
},
"expr": {
"_type": "VariableExpr",
"name": "n"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L38:12",
"to": "L38:13"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L38:7",
"to": "L38:13"
},
"expr": {
"_type": "CompareExpr",
"left": {
"_type": "VariableExpr",
"name": "n"
},
"operator": "<=",
"right": {
"_type": "LiteralExpr",
"value": 1
}
},
"type": {
"name": "bool"
}
},
{
"location": {
"from": "L39:15",
"to": "L39:16"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:11",
"to": "L40:12"
},
"expr": {
"_type": "VariableExpr",
"name": "n"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:20",
"to": "L40:21"
},
"expr": {
"_type": "VariableExpr",
"name": "n"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:24",
"to": "L40:25"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:20",
"to": "L40:25"
},
"expr": {
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "n"
},
"operator": "-",
"right": {
"_type": "LiteralExpr",
"value": 1
}
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:15",
"to": "L40:19"
},
"expr": {
"_type": "VariableExpr",
"name": "fact"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "n",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "int"
}
}
},
{
"location": {
"from": "L40:15",
"to": "L40:26"
},
"expr": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "fact"
},
"arguments": [
{
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "n"
},
"operator": "-",
"right": {
"_type": "LiteralExpr",
"value": 1
}
}
],
"keywords": {}
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L40:11",
"to": "L40:26"
},
"expr": {
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "n"
},
"operator": "*",
"right": {
"_type": "CallExpr",
"callee": {
"_type": "VariableExpr",
"name": "fact"
},
"arguments": [
{
"_type": "BinaryExpr",
"left": {
"_type": "VariableExpr",
"name": "n"
},
"operator": "-",
"right": {
"_type": "LiteralExpr",
"value": 1
}
}
],
"keywords": {}
}
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L43:10",
"to": "L43:11"
},
"expr": {
"_type": "LiteralExpr",
"value": 1
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L43:13",
"to": "L43:14"
},
"expr": {
"_type": "LiteralExpr",
"value": 2
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L43:16",
"to": "L43:17"
},
"expr": {
"_type": "LiteralExpr",
"value": 3
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L43:9",
"to": "L43:18"
},
"expr": {
"_type": "ListExpr",
"items": [
{
"_type": "LiteralExpr",
"value": 1
},
{
"_type": "LiteralExpr",
"value": 2
},
{
"_type": "LiteralExpr",
"value": 3
}
]
},
"type": {
"name": "list",
"args": [
{
"name": "int"
}
],
"body": {
"name": "list"
}
}
},
{
"location": {
"from": "L43:4",
"to": "L43:5"
},
"expr": {
"_type": "VariableExpr",
"name": "i"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L44:8",
"to": "L44:9"
},
"expr": {
"_type": "VariableExpr",
"name": "i"
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L45:4",
"to": "L45:5"
},
"expr": {
"_type": "VariableExpr",
"name": "i"
},
"type": {}
}
]
}

View File

@@ -7,68 +7,14 @@ Module(
alias(name='Meter'), alias(name='Meter'),
alias(name='Second')], alias(name='Second')],
level=0), level=0),
Assign(
targets=[
Name(id='__midas_alias_0__')],
value=Constant(value=123.45)),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_alias_0__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='01_simple_types.py:L3:19: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_alias_0__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assign( Assign(
targets=[ targets=[
Name(id='distance')], Name(id='distance')],
value=Name(id='__midas_alias_0__')), value=Constant(value=123.45)),
Delete(
targets=[
Name(id='__midas_alias_0__')]),
Assign(
targets=[
Name(id='__midas_alias_1__')],
value=Constant(value=6.7)),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_alias_1__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='01_simple_types.py:L4:16: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_alias_1__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assign( Assign(
targets=[ targets=[
Name(id='time')], Name(id='time')],
value=Name(id='__midas_alias_1__')), value=Constant(value=6.7)),
Delete(
targets=[
Name(id='__midas_alias_1__')]),
Assign( Assign(
targets=[ targets=[
Name(id='speed')], Name(id='speed')],

View File

@@ -0,0 +1,14 @@
// Inline
type T1 = float where _ > 0
// Named
predicate is_positive(v: float) = v > 0
type T2 = float where is_positive(_)
// Curried
predicate in_range(mn: float, mx: float)(v: float) = v >= mn & v < mx
type T3 = float where in_range(100, 200)(_)
// Alias
predicate minor = in_range(0, 18)
type T4 = float where minor(_)

View File

@@ -0,0 +1,8 @@
from midas import T1, T2, T3, T4, cast
t: float = 12.5
t1: T1 = cast(T1, t)
t2: T2 = cast(T2, t)
t3: T3 = cast(T3, t)
t4: T4 = cast(T4, t)

View File

@@ -0,0 +1,333 @@
Module(
body=[
FunctionDef(
name='__midas_p0__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='_',
annotation=Constant(value='Any'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Compare(
left=Name(id='_'),
ops=[
Gt()],
comparators=[
Constant(value=0)]))],
decorator_list=[],
returns=Constant(value='bool')),
FunctionDef(
name='__midas_is_positive__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='v',
annotation=Constant(value='float'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Compare(
left=Name(id='v'),
ops=[
Gt()],
comparators=[
Constant(value=0)]))],
decorator_list=[],
returns=Constant(value='bool')),
FunctionDef(
name='__midas_p1__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='_',
annotation=Constant(value='Any'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Call(
func=Name(id='__midas_is_positive__'),
args=[
Name(id='_')],
keywords=[]))],
decorator_list=[],
returns=Constant(value='bool')),
FunctionDef(
name='__midas_in_range__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='mn',
annotation=Constant(value='float')),
arg(
arg='mx',
annotation=Constant(value='float'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
FunctionDef(
name='inner0',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='v',
annotation=Constant(value='float'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=BoolOp(
op=And(),
values=[
Compare(
left=Name(id='v'),
ops=[
GtE()],
comparators=[
Name(id='mn')]),
Compare(
left=Name(id='v'),
ops=[
Lt()],
comparators=[
Name(id='mx')])]))],
decorator_list=[],
returns=Constant(value='bool')),
Return(
value=Name(id='inner0'))],
decorator_list=[],
returns=Constant(value='Callable[[float], bool]')),
FunctionDef(
name='__midas_p2__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='_',
annotation=Constant(value='Any'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Call(
func=Call(
func=Name(id='__midas_in_range__'),
args=[
Constant(value=100),
Constant(value=200)],
keywords=[]),
args=[
Name(id='_')],
keywords=[]))],
decorator_list=[],
returns=Constant(value='bool')),
Assign(
targets=[
Name(id='__midas_minor__')],
value=Call(
func=Name(id='__midas_in_range__'),
args=[
Constant(value=0),
Constant(value=18)],
keywords=[])),
FunctionDef(
name='__midas_p3__',
args=arguments(
posonlyargs=[],
args=[
arg(
arg='_',
annotation=Constant(value='Any'))],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Call(
func=Name(id='__midas_minor__'),
args=[
Name(id='_')],
keywords=[]))],
decorator_list=[],
returns=Constant(value='bool')),
ImportFrom(
module='midas',
names=[
alias(name='T1'),
alias(name='T2'),
alias(name='T3'),
alias(name='T4'),
alias(name='cast')],
level=0),
Assign(
targets=[
Name(id='t')],
value=Constant(value=12.5)),
Assign(
targets=[
Name(id='__midas_a0__')],
value=Name(id='t')),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_a0__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='02_constraints.py:L5:10: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_a0__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assert(
test=Call(
func=Name(id='__midas_p0__'),
args=[
Name(id='__midas_a0__')],
keywords=[]),
msg=Constant(value="02_constraints.py:L5:10: ConstraintError: Value does not fit constraint '_ > 0'")),
Assign(
targets=[
Name(id='t1')],
value=Name(id='__midas_a0__')),
Delete(
targets=[
Name(id='__midas_a0__')]),
Assign(
targets=[
Name(id='__midas_a1__')],
value=Name(id='t')),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_a1__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='02_constraints.py:L6:10: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_a1__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assert(
test=Call(
func=Name(id='__midas_p1__'),
args=[
Name(id='__midas_a1__')],
keywords=[]),
msg=Constant(value="02_constraints.py:L6:10: ConstraintError: Value does not fit constraint 'is_positive(_)'")),
Assign(
targets=[
Name(id='t2')],
value=Name(id='__midas_a1__')),
Delete(
targets=[
Name(id='__midas_a1__')]),
Assign(
targets=[
Name(id='__midas_a2__')],
value=Name(id='t')),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_a2__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='02_constraints.py:L7:10: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_a2__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assert(
test=Call(
func=Name(id='__midas_p2__'),
args=[
Name(id='__midas_a2__')],
keywords=[]),
msg=Constant(value="02_constraints.py:L7:10: ConstraintError: Value does not fit constraint 'in_range(100, 200)(_)'")),
Assign(
targets=[
Name(id='t3')],
value=Name(id='__midas_a2__')),
Delete(
targets=[
Name(id='__midas_a2__')]),
Assign(
targets=[
Name(id='__midas_a3__')],
value=Name(id='t')),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_a3__'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='02_constraints.py:L8:10: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_a3__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assert(
test=Call(
func=Name(id='__midas_p3__'),
args=[
Name(id='__midas_a3__')],
keywords=[]),
msg=Constant(value="02_constraints.py:L8:10: ConstraintError: Value does not fit constraint 'minor(_)'")),
Assign(
targets=[
Name(id='t4')],
value=Name(id='__midas_a3__')),
Delete(
targets=[
Name(id='__midas_a3__')])],
type_ignores=[])

View File

@@ -9,26 +9,22 @@ type Longitude = float where (-180 <= _ <= 180)
type Difference[T] = T type Difference[T] = T
// Complex custom type, containing two values accessible through properties // Complex custom type, containing two values accessible through properties
type GeoLocation = { type GeoLocation = object
extend GeoLocation {
prop lat: Latitude prop lat: Latitude
prop lon: Longitude prop lon: Longitude
} }
// Define operations on our custom type type GeoLocationDifference = object
extend GeoLocation {
// This type is compatible with the `-` operation with another GeoLocation
// i.e. you can subtract a GeoLocation from another GeoLocation, resulting
// in a Difference of GeoLocations
def __sub__: fn(GeoLocation, /) -> Difference[GeoLocation]
}
// For complex generics, you need to specify how the genericity the properties extend GeoLocationDifference {
// are handled
type Difference[GeoLocation] = {
prop lat: Difference[Latitude] prop lat: Difference[Latitude]
prop lon: Difference[Longitude] prop lon: Difference[Longitude]
} }
// Define operations on our custom type
// Simple operation defined on our custom types // Simple operation defined on our custom types
extend Latitude { extend Latitude {
def __sub__: fn(Latitude, /) -> Difference[Latitude] def __sub__: fn(Latitude, /) -> Difference[Latitude]
@@ -38,13 +34,23 @@ extend Longitude {
def __sub__: fn(Longitude, /) -> Difference[Longitude] def __sub__: fn(Longitude, /) -> Difference[Longitude]
} }
extend GeoLocation {
// This type is compatible with the `-` operation with another GeoLocation
// i.e. you can subtract a GeoLocation from another GeoLocation, resulting
// in a GeoLocationDifference
def __sub__: fn(GeoLocation, /) -> GeoLocationDifference
}
// Predefined custom predicates that can be referenced in other definitions // Predefined custom predicates that can be referenced in other definitions
predicate Positive(v: float) = v >= 0 predicate Positive(v: float) = v >= 0
predicate StrictlyPositive(v: float) = v > 0 predicate StrictlyPositive(v: float) = v > 0
predicate Equatorial(loc: GeoLocation) = (-10 <= loc.lat <= 10) predicate Equatorial(loc: GeoLocation) = (-10 <= loc.lat <= 10)
predicate Arctic(loc: GeoLocation) = (loc.lat >= 66) predicate Arctic(loc: GeoLocation) = (loc.lat >= 66)
type Person = { type Person = object
extend Person {
prop name: str prop name: str
// Property with an inline constraint // Property with an inline constraint

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More