118 Commits

Author SHA1 Message Date
12c5309cff Merge pull request 'Avoid raising exceptions, report diagnostics instead' (#42) from fix/remove-raises into main
All checks were successful
Tests / tests (push) Successful in 6s
Reviewed-on: #42
2026-07-10 09:35:02 +00:00
720a82c221 fix(checker): remove redundant match case
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-10 11:30:10 +02:00
90e9d167bf fix(checker): better handle invalid generic instantiation 2026-07-10 11:23:29 +02:00
4866ab2090 fix(checker): handle already defined predicates 2026-07-10 11:20:39 +02:00
3ede752cae fix(checker): handle already defined types 2026-07-10 11:19:06 +02:00
244e5f351a fix(checker): handle subscript and error in typevar bound 2026-07-10 11:13:32 +02:00
e61c4070da fix(checker): handle returns outside functions 2026-07-10 11:04:49 +02:00
00fdda5034 fix(checker): catch UndefinedMethodException where needed 2026-07-10 11:00:23 +02:00
e302ed2377 Merge pull request 'Add Contributing and License sections to README' (#41) from feat/readme-additions into main
All checks were successful
Tests / tests (push) Successful in 6s
Reviewed-on: #41
2026-07-09 22:39:09 +00:00
1d927e532f chore: add icon to README
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-10 00:38:34 +02:00
bfe4f854ea chore: add Contributing and License to README 2026-07-10 00:38:34 +02:00
3e06a236b5 Merge pull request 'Add Apache 2.0 license' (#40) from feat/license into main
All checks were successful
Tests / tests (push) Successful in 6s
Reviewed-on: #40
2026-07-09 22:20:37 +00:00
ab53ac67d5 chore: add Apache 2.0 license
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-10 00:19:31 +02:00
b3ff5a7b96 Merge pull request 'Tidy examples and minor housekeeping' (#39) from feat/tidy-examples into main
All checks were successful
Tests / tests (push) Successful in 5s
Reviewed-on: #39
2026-07-09 21:41:48 +00:00
dce35391b4 chore: add justfile
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-09 23:41:04 +02:00
28be45eb8f chore: add CI badge 2026-07-09 23:41:03 +02:00
b64110bb06 chore: remove draft architecture diagram 2026-07-09 23:41:02 +02:00
edc2a65492 chore: remove test file 2026-07-09 23:41:01 +02:00
dade8eb0f4 chore: move currency demo in subfolder 2026-07-09 23:41:01 +02:00
9f3dfd686b chore: remove syntax prototype examples 2026-07-09 23:41:00 +02:00
5ef21917ae chore: update and clean some examples 2026-07-09 23:40:59 +02:00
34072de270 Merge pull request 'Minor fixes and more tests' (#38) from feat/add-tests into main
All checks were successful
Tests / tests (push) Successful in 5s
Reviewed-on: #38
2026-07-09 20:19:42 +00:00
2b09a29165 tests: add frame constraint generation test
All checks were successful
Tests / tests (pull_request) Successful in 6s
2026-07-09 22:16:28 +02:00
309e1d4a5b tests: add generator test for simple syntax 2026-07-09 22:04:39 +02:00
74472c974b tests: add test covering some function subtyping rules 2026-07-09 21:58:51 +02:00
a9a3164c24 fix: implement missing methods on printer and highlighter 2026-07-09 18:57:26 +02:00
88ab9dc14d fix: add final decorator to Visitor subclasses 2026-07-09 18:30:06 +02:00
f41dbb528c tests: add custom JSON encoder to for checker output 2026-07-09 18:27:51 +02:00
44963db425 tests: add test for all supported Python syntaxes 2026-07-09 17:59:20 +02:00
b67c940112 tests: add test for all Midas syntaxes 2026-07-09 17:47:18 +02:00
ad40db98d0 fix(checker): always type check unary operand 2026-07-09 17:46:52 +02:00
7a021b2450 fix(checker): type check none literal 2026-07-09 17:39:11 +02:00
a7b62e752b fix(checker): adjust error message location
when constraint doesn't evaluate to a boolean, report error on the constraint expression instead of the whole type
2026-07-09 17:38:53 +02:00
44984af8a3 fix(checker): avoid raising on unknown variable
when an unknown variable is referenced, avoid raising an error and only report it with a diagnostic, returning `UnknownType` instead
2026-07-09 17:37:49 +02:00
0c851cbc9b Merge pull request 'Cleanup and minor improvements' (#37) from feat/tidying into main
All checks were successful
Tests / tests (push) Successful in 5s
Reviewed-on: #37
2026-07-09 14:59:29 +00:00
54b2058b86 chore: fix name and complete language configuration
All checks were successful
Tests / tests (pull_request) Successful in 7s
2026-07-09 16:54:38 +02:00
60ff529226 chore: add midas snippets 2026-07-09 16:54:37 +02:00
c3b243288d fix(checker): remove redundant instance check 2026-07-09 15:59:55 +02:00
cd9b80d22b docs: fix some typos in manual 2026-07-09 15:59:29 +02:00
21b648e18f docs: fix some docstrings 2026-07-09 15:56:56 +02:00
742693fa38 chore: add script to check docstrings 2026-07-09 15:55:06 +02:00
4d343a61ff feat!: remove Python constraint type
this feature was only partially implemented (parsing) and the syntax was not ideal so this commit removes it entirely
2026-07-09 14:15:36 +02:00
f314a95e87 fix(checker): fix function subtyping logic 2026-07-09 12:32:51 +02:00
99350a9505 docs: add description of function subtyping 2026-07-09 12:32:27 +02:00
aaa6d945d1 fix: tidy some TODOs 2026-07-09 00:25:23 +02:00
653612ee87 feat(checker): add variance manager to improve recursive types 2026-07-09 00:02:25 +02:00
2df6bca948 fix(gen): improve cast assert message for columns 2026-07-08 23:28:27 +02:00
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
120 changed files with 29612 additions and 4076 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

4
.gitignore vendored
View File

@@ -6,4 +6,6 @@ venv
*.pyc *.pyc
uv.lock uv.lock
.python-version .python-version
/out /out
/examples/**/build/
/examples/**/*.pyi

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Louis Heredero
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,5 +1,11 @@
<h1>Midas</h1> <h1>Midas</h1>
<p align="center">
<img src="assets/icon.svg" width="64">
</p>
<img src="https://git.kb28.ch/HEL/midas/actions/workflows/tests.yaml/badge.svg">
*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.
*Midas* aims at providing Python developers with a simple annotation system to enable compile-time integrity and data type checks, as well as generating runtime assertions. *Midas* aims at providing Python developers with a simple annotation system to enable compile-time integrity and data type checks, as well as generating runtime assertions.
@@ -22,6 +28,8 @@ This framework is being developed as part of a Bachelor's Thesis by Louis Herede
- [Showing Type Judgements](#showing-type-judgements) - [Showing Type Judgements](#showing-type-judgements)
- [Validating Definitions](#validating-definitions) - [Validating Definitions](#validating-definitions)
- [Tests](#tests) - [Tests](#tests)
- [Contributing](#contributing)
- [License](#license)
</details> </details>
@@ -51,16 +59,7 @@ This framework is being developed as part of a Bachelor's Thesis by Louis Herede
## Commands ## Commands
<!-- 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`.
check
compile
format
highlight
parse
dump_registry
types
validate
-->
### Type Checking ### Type Checking
@@ -78,6 +77,10 @@ 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.
> [!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 ### Formatting
```shell ```shell
@@ -123,7 +126,7 @@ This command processes the given Midas definitions and dumps the contents of the
midas stubs types.midas -o stubs.pyi midas stubs types.midas -o stubs.pyi
``` ```
This command generate Python stubs from a Midas definition file This command generates Python stubs from a Midas definition file
### Showing Type Judgements ### Showing Type Judgements
@@ -143,7 +146,7 @@ This command lets you validate a Midas definition file by running the parser and
## 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
@@ -152,8 +155,30 @@ uv run -m tests.checker run -a
uv run -m tests.generator 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`
- Update specific tests: `update tests/cases/test1.py tests/cases/test2.py ...` - Update specific tests: `update tests/cases/test1.py tests/cases/test2.py ...`
## Contributing
Feel free to [open an issue](https://git.kb28.ch/HEL/midas/issues) to report a bug or if you experience any issues with Midas.
Contributions are also welcome so feel free to [open a pull request](https://git.kb28.ch/HEL/midas/pulls).
> [!NOTE]
> As this is part of my bachelor project, I will not accept any contributions before my defense
## License
Midas is distributed under the terms of the Apache-2.0 license. See [LICENSE](LICENSE) for details.

View File

@@ -1,150 +0,0 @@
#import "@preview/cetz:0.5.2": canvas, draw
#let diagram-only = false
#set document(
title: [Midas Architecture],
//author: "Louis Heredero",
)
#set text(
font: "Source Sans 3",
)
#let diagram = canvas({
let framed = draw.content.with(
padding: (x: .8em, y: 1em),
frame: "rect",
stroke: black,
)
let arrow = draw.line.with(mark: (end: ">", fill: black))
framed(
(0, 0),
name: "python-parser",
)[Python parser]
draw.content(
(rel: (0, 1), to: "python-parser.north"),
padding: 5pt,
anchor: "south",
name: "source-py",
)[_`source.py`_]
arrow("source-py", "python-parser")
framed(
(rel: (3, 0), to: "python-parser.east"),
anchor: "west",
name: "custom-parser",
align(center)[Custom python\ parser],
)
arrow("python-parser", "custom-parser", name: "arrow-python-ast")
draw.content(
"arrow-python-ast",
anchor: "south",
padding: 5pt,
)[`ast.Module`]
framed(
(rel: (-3, -2), to: "custom-parser.south"),
anchor: "east",
name: "python-resolver",
)[Python Resolver]
arrow(
"custom-parser",
((), "|-", "python-resolver.east"),
"python-resolver",
name: "arrow-python-custom-ast",
)
draw.content(
(rel: (1.5, 0), to: "arrow-python-custom-ast.end"),
padding: 5pt,
anchor: "south",
)[P-AST#footnote[#strong[P]ython *AST*]<fn-past>]
draw.content(
"python-resolver.west",
padding: 5pt,
anchor: "south-east",
)[Resolved P-AST@fn-past]
draw.circle(
(rel: (1, -2), to: "custom-parser.south-east"),
radius: .4,
name: "midas-loader",
)
arrow(
"custom-parser",
"midas-loader",
name: "arrow-load-midas",
mark: (end: (symbol: ">", fill: black), start: "o"),
)
draw.content(
"arrow-load-midas",
anchor: "west",
padding: 5pt,
)[```python midas.using("types.midas")```]
framed(
(rel: (0, -2), to: "midas-loader.south"),
name: "midas-parser",
)[Midas lexer/parser]
arrow("midas-loader", "midas-parser", name: "arrow-midas-source")
draw.content(
"arrow-midas-source",
anchor: "west",
padding: 5pt,
)[_`types.midas`_]
framed(
(rel: (-2, 0), to: "midas-parser.west"),
anchor: "east",
name: "midas-resolver",
)[Midas Resolver]
arrow("midas-parser", "midas-resolver", name: "arrow-midas-ast")
draw.content(
"arrow-midas-ast",
anchor: "south",
padding: 5pt,
)[M-AST#footnote[#strong[M]idas *AST*]<fn-mast>]
framed(
(rel: (-3, 0), to: "midas-resolver.west"),
anchor: "east",
name: "checker",
)[Checker]
arrow("midas-resolver", "checker", name: "arrow-type-ctx")
arrow(
"python-resolver",
((), "-|", "checker.north"),
"checker",
)
draw.content(
"arrow-type-ctx",
anchor: "south",
padding: 5pt,
)[Types context]
})
#show: doc => if diagram-only {
set page(width: auto, height: auto, margin: .5cm)
diagram
} else { doc }
#align(center, title())
#v(1cm)
#figure(
diagram,
caption: [Midas type-checker architecture],
)
== Components
- *Python parser*: builtin Python AST parser, extracts abstract syntax from the raw Python source (```python ast.parse(...)```)
- *Custom python parser*: converts the raw Python AST into custom, more suitable constructs, especially for type annotations
- *Python resolver*: resolves bindings and references, tracks binding scopes
- *Midas lexer/parser*: parses a Midas type definition file and extracts its AST
- *Midas resolver*: walks the AST and fills the environment with the defined types and operations
- *Checker*: evaluates expressions and checks type coherence

2
docs/fonts.yaml Normal file
View File

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

View File

@@ -0,0 +1,97 @@
#import "@preview/curryst:0.6.0": prooftree, rule, rule-set
#import "@preview/gentle-clues:1.3.1" as gc
#import "@preview/lovelace:0.3.1": pseudocode-list
#set text(font: "Source Sans 3")
#show link: set text(fill: blue)
#set document(
title: [Function subtyping rule],
)
#let req = math.op("req")
#align(center, title())
This document formalizes the logic used when checking whether a function is a subtype of another.
= Definitions
A *Parameter specification* has a list of positional-only parameters $P$, mixed parameters $M$ and keyword-only parameters $K$:
$ S = (P, M, K) $
A *Parameter* has an index $i$, a name $n$ and a required flag $r$:
$ p = (i, n, r) $
A *Function* has a param spec $S$ and a return type $R$:
$ F = (S, R) $
= Main rules
We want to define a rule for checking structural subtyping of functions, i.e. to check when $F_1 <: F_2$
There are two conditions to check:
#align(center, prooftree(
rule(
$Gamma tack S_2 <: S_1$,
$Gamma tack R_1 <: R_2$,
$Gamma tack F_1 <: F_2$,
),
))
The second condition is trivial to check.
The first condition is a bit more tricky.
For a parameter specification $S_2$ to be a subtype of another $S_1$, the latter needs to be fully compatible with any usages of the former. What this means is that #link(<cond-1>)[(1)] any argument that can be passed to $S_2$ must be accepted by $S_1$, and #link(<cond-2>)[(2)] $S_1$ must not have additional required arguments that are not in $S_2$. However, $S_1$ can have additional optional parameters not present in $S_2$.
After mapping parameters of $S_1$ and $S_2$, types must be checked such that if a parameter $p_i: T in S_1$ is mapped to a parameter $q_j: U in S_2$, $U <: T$.
= Detailed rules for *ParamSpec* subtyping
#gc.info(title: [Notation])[
In the following equations:
- the notation $S in.rev a^i: T$ will be used to denote that a parameter spec $S$ accepts an argument named $a$ at index $i$ of type $T$
- the special name $alpha$ will be used to denote any argument without a specific name
- the special name $phi$ will be used to denote any parameter without a specific name
- $AA$ denotes the group of all arguments, i.e. $alpha in AA$
- $PP$ denotes the group of all parameters, i.e. $p in PP$
- $req(S, alpha)$ is a predicate checking whether $alpha$ is required in $S$
]
== Arguments of $S_2$ are compatible with $S_1$ <cond-1>
Formally, the condition is:
$
forall alpha in AA, S_2 in.rev alpha => S_1 in.rev alpha
$
Let $S_1 = (P_1, M_1, K_1)$ and $S_2 = (P_2, M_2, K_2)$.
For each positional-only parameter $phi_i in P_2$, $phi_i in P_1 or phi_i in M_1$. A positional-only parameter of $S_2$ can either be positional-only or mixed in $S_1$. Additionally, $not req(S_2, phi_i) => not req(S_1, phi_i)$. If $phi_i$ is optional in $S_2$, it must also be optional in $S_1$ so that a call omitting it is valid.
Similarly, for each keyword-only parameter $p in K_2$, $p in K_1 or p in M_1$. A keyword-only parameter of $S_2$ can either be keyword-only or mixed in $S_1$. Additionally, $not req(S_2, p) => not req(S_1, p)$. If $p$ is optional in $S_2$, it must also be optional in $S_1$ so that a call omitting it is valid.
Finally, for mixed parameters, the rule is slightly more complex. Either there is a corresponding mixed parameter in $S_1$, or the parameter is covered by both a positional/mixed and a keyword/mixed parameter. In the second case, we must keep in mind that only one of the match will made at runtime, or maybe even none if the parameter is optional in $S_2$, meaning the parameters in $S_1$ _must_ be optional.
We can thus split the rule in two. $forall p_i in M_2$:
$
cases(
p_i in M_1 and not req(S_2, p_i) => not req(S_1, p_i),
"or",
underbrace((phi_i in P_1 or phi_i in M_1), "Positional") and
underbrace((p in K_1 or p in M_1), "Keyword") and
underbrace((not req(S_1, phi_i) and not req(S_1, p)), "Optional")
)
$
== No additional required arguments in $S_1$ <cond-2>
Formally, the condition is:
$
forall alpha in AA, S_1 in.rev alpha and req(S_1, alpha) => S_2 in.rev alpha and req(S_2, alpha)
$
Each parameter in $S_1$ that is not matched by a parameter in $S_2$ must be optional:
$
forall p_i in S_1, p_i in.not S_2 => not req(S_1, p_i)
$

12735
docs/manual.pdf Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,20 @@
#import "@preview/codly-languages:0.1.10": codly-languages #import "@preview/codly-languages:0.1.10": codly-languages
#import "template.typ": TODO, project #import "template.typ": TODO, project
#import "@preview/gentle-clues:1.3.1" as gc #import "@preview/gentle-clues:1.3.1" as gc
#import "@preview/treet:1.0.0": tree-list
#let midas-version = toml("../pyproject.toml").project.version #let midas-version = toml("../pyproject.toml").project.version
#let head-ref = read("../.git/HEAD").split(":").at(1).trim()
#let commit-hash = read("../.git/" + head-ref).slice(0, 8) #let commit-hash = if "hash" in sys.inputs {
sys.inputs.hash.slice(0, 8)
} else {
let head-ref = read("../.git/HEAD").split(":").at(1, default: "").trim()
if head-ref.len() != 0 {
read("../.git/" + head-ref).slice(0, 8)
} else {
none
}
}
#show: project.with( #show: project.with(
title: [Midas User Manual], title: [Midas User Manual],
@@ -371,6 +381,7 @@ Notice that you don't need to specify `Column` types.
height: float where _ >= 0 height: float where _ >= 0
] ]
```, ```,
caption: [Simple frame type definition],
) <simple-frame> ) <simple-frame>
#pagebreak() #pagebreak()
@@ -613,7 +624,7 @@ For example:
== Control flow == Control flow
Some control flow features are supported. For the limited code of this project, not all constructs are supported. The following are those currently handled and typ checked by Midas. Some control flow features are supported. For the limited code of this project, not all constructs are supported. The following are those currently handled and type checked by Midas.
=== `if` / `elif` / `else` <if-else> === `if` / `elif` / `else` <if-else>
@@ -746,7 +757,7 @@ If the value passed to `cast` or `unsafe_cast` is a literal (e.g. an integer, a
Vanilla Python already lets you use type hints to specify the type of variables and function parameters. Vanilla Python already lets you use type hints to specify the type of variables and function parameters.
Midas use them to type check your code. Additionally, it allows you to use a special syntax to define a `Frame` types directly in these annotations. Midas use them to type check your code. Additionally, it allows you to use a special syntax to define a `Frame` type directly in these annotations.
Because these annotations are not interpretable by Python, your integrated type checker might complain loudly about them being invalid. Because these annotations are not interpretable by Python, your integrated type checker might complain loudly about them being invalid.
A workaround is to silence it by adding a type comment at the end of the line, as shown in @silence-errors. A workaround is to silence it by adding a type comment at the end of the line, as shown in @silence-errors.
@@ -773,18 +784,213 @@ The example of @python-frame-type shows how you can annotate a dataframe with so
= Commands <commands> = Commands <commands>
#TODO Midas offers several commands to parse, check and compile your code.
All commands presented in this chapter are subcommands of the `midas` command.
For example:
#figure(
```sh
midas compile script.py -t types.midas
```,
caption: [Example midas command],
)
== Type Checking (`check`) <cmd-check> == Type Checking (`check`) <cmd-check>
#figure(
```sh
midas check -t types.midas source.py
```,
caption: [Example usage of `midas check`],
)
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.
=== Parameters
- `FILE`: the Python file to type check
=== Options
- `-t` / `--types` `FILENAME`: Midas files defining type definitions. This option can be used multiple times. Files should end with `.midas`
- `-l` / `--highlight` `FILENAME`: if set, a highlighted version of the Python file showing inline diagnostics will be written to the given file
== Compiling (`compile`) <cmd-compile> == Compiling (`compile`) <cmd-compile>
#figure(
```sh
midas compile -t types.midas source.py
```,
caption: [Example usage of `midas compile`],
)
With the `compile` command, you can process a source Python file, with any number of custom type definition files, and the type checker will verify the coherence of your program and generate the runnable code with valid syntax and runtime assertions.
=== Parameters
- `FILE`: the Python file to compile
=== Options
#let output = {
set text(size: .8em)
tree-list[
- build/
- midas/
- stubs1.py
- misc.py
- source.py
]
}
- `-t` / `--types` `FILENAME`: Midas files defining type definitions. This option can be used multiple times. Files should end with `.midas`
- `-s` / `--stubs` `TEXT`: the file name of generated stub files. Each instance of this option maps to an instance of `-t`.
For example:
```sh
midas -t types1.midas -s stubs1 -t types2.midas -s misc source.py
```
will compile to the following files:
#output
- `--ignore-errors`: if set, error diagnostics will not prevent the generator from running
== Formatting (`format`) <cmd-format> == Formatting (`format`) <cmd-format>
#figure(
```sh
midas format types.midas
midas format types.midas -o formatted.midas
```,
caption: [Example usage of `midas format`],
)
This command parses the given Midas file and outputs a pretty printed file from the AST.
=== Parameters
- `FILE`: the Midas file to format
=== Options
- `-o` / `--output` `FILENAME`: if set, the output is written to the given file instead of STDOUT
== Highlighting (`highlight`) <cmd-highlight> == Highlighting (`highlight`) <cmd-highlight>
#figure(
```sh
midas highlight source.py
midas highlight source.py -o highlighted.html
midas highlight types.midas
midas highlight types.midas -o highlighted.html
```,
caption: [Example usage of `midas highlight`],
)
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.)
=== Parameters
- `FILE`: the Python or Midas file to highlight
=== Options
- `-o` / `--output` `FILENAME`: if set, the output is written to the given file instead of STDOUT
== Dumping the AST (`parse`) <cmd-parse> == Dumping the AST (`parse`) <cmd-parse>
#figure(
```sh
midas parse source.py
midas parse types.midas
```,
caption: [Example usage of `midas parse`],
)
For debugging purposes, you can use this command to output the AST parsed from a Python or Midas file
=== Parameters
- `FILE`: the Python or Midas file to parse
=== Options
- `--raw`: if `FILE` is a Python file, the raw AST is returned, as produced by the builtin `ast` module, instead of the custom AST nodes used by Midas internally. This flag has no effect on Midas files
== Dumping the Registry (`dump-registry`) <cmd-registry> == Dumping the Registry (`dump-registry`) <cmd-registry>
#figure(
```sh
midas dump-registry -t types.midas
```,
caption: [Example usage of `midas dump-registry`],
)
This command processes the given Midas definitions and dumps the contents of the types registry.
=== Options
- `-t` / `--types` `FILENAME`: Midas files defining type definitions. This option can be used multiple times. Files should end with `.midas`
== Generating Stubs (`stubs`) <cmd-stubs> == Generating Stubs (`stubs`) <cmd-stubs>
#figure(
```sh
midas stubs types.midas
```,
caption: [Example usage of `midas stubs`],
)
This command generates Python stubs from a Midas definition file
=== Parameters
- `FILE`: the source Midas file
=== Options
- `-o` / `--output` `FILENAME`: if set, the stubs are written to the given file instead of the default `FILE.pyi`
- `-w` / `--watch`: if set, the input file will be watched and any changes to it will regenerate the stubs, otherwise the command exits after generating the stubs once
== Showing Type Judgements (`types`) <cmd-types> == Showing Type Judgements (`types`) <cmd-types>
#figure(
```sh
midas types -t types.midas source.py
```,
caption: [Example usage of `midas types`],
)
This command type checks the given Python source file and logs all typing judgements made by the type checker.
=== Parameters
- `FILE`: the Python file to type check
=== Options
- `-t` / `--types` `FILENAME`: Midas files defining type definitions. This option can be used multiple times. Files should end with `.midas`
- `-l` / `--highlight` `FILENAME`: if set, a highlighted version of the Python file showing inline diagnostics will be written to the given file
== Validating Definitions (`validate`) <cmd-validate> == Validating Definitions (`validate`) <cmd-validate>
#figure(
```sh
midas validate types.midas
```,
caption: [Example usage of `midas validate`],
)
This command lets you validate a Midas definition file by running the parser and type checker, verifying syntax and references.
=== Parameters
- `FILE`: the Midas file to validate
=== Options
- `-l` / `--highlight` `FILENAME`: if set, a highlighted version of the Midas file showing inline diagnostics will be written to the given file
= Known limitations <limitations> = Known limitations <limitations>
== Eager evaluation in runtime assertions <eager-eval> == Eager evaluation in runtime assertions <eager-eval>

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"
}
}

View File

@@ -52,7 +52,7 @@
title: none, title: none,
author: none, author: none,
version: "0.0.1", version: "0.0.1",
hash: "abcdefgh", hash: none,
icon-path: none, icon-path: none,
doc, doc,
) = { ) = {
@@ -61,6 +61,7 @@
set document( set document(
title: title, title: title,
author: author, author: author,
date: none,
) )
set text( set text(
font: "Source Sans 3", font: "Source Sans 3",
@@ -69,13 +70,17 @@
set raw(syntaxes: path("midas.sublime-syntax")) set raw(syntaxes: path("midas.sublime-syntax"))
let front-page() = { let front-page() = {
let version-name = [v#version]
if hash != none {
version-name = [#version-name - #hash]
}
align(center)[ align(center)[
#{ #{
set text(size: 1.5em) set text(size: 1.5em)
std.title() std.title()
} }
v#version - #hash #version-name
#if icon-path != none { #if icon-path != none {
v(1cm) v(1cm)

View File

@@ -1,16 +0,0 @@
# type: ignore
# ruff: disable[F821]
from __future__ import annotations
# A simple data-frame with different column of various simple types
# Columns can be named and/or typed
df: Frame[
verified: bool,
birth_year: int,
height: float + ( _ > 0 ) + ( _ < 250 ),
name: str,
date: datetime,
float, # unnamed
unknown: _, # untyped
_ # unnamed and untyped
]

View File

@@ -1,24 +0,0 @@
// Simple custom type derived from floats
type Latitude<float>
type Longitude<float>
// Complex custom type, containing two values accessible through properties
type GeoLocation<Latitude, Longitude> {
lat: Latitude
lon: Longitude
}
type LatitudeDiff<float>
type LongitudeDiff<float>
// Simple operation defined on our custom types
op <Latitude> - <Latitude> = <LatitudeDiff>
op <Longitude> - <Longitude> = <LongitudeDiff>
// Simple custom type with a constraint
type Age<int + (0 <= _) + (_ < 150)>
// Predefined custom constraints that can be referenced in other definitions
constraint Positive = _ >= 0
constraint StrictlyPositive = _ > 0
//constraint Even = _ % 2 == 0

View File

@@ -1,30 +0,0 @@
# type: ignore
# ruff: disable[F821]
from __future__ import annotations
# A data-frame using a custom type
df: Frame[
location: GeoLocation
]
# Properties of a type can be used on a column of that type
lat: Column[GeoLocation] = df["location"].lat
lon: Column[GeoLocation] = df["location"].lon
# Unregistered operations between types are not permitted
lat + lon # Invalid operation
# Registered operations are permitted
lat1: Latitude = lat[0]
lat2: Latitude = lat[1]
lat_diff: Difference[Latitude] = lat2 - lat1 # Valid operation
# In addition to the type, a column can have one or more constraints, either defined inline or in a separate file
df2: Frame[
age: int + (_ >= 0),
height: float + (_ >= 0),
]
df2_bis: Frame[
age: int + Positive,
height: float + Positive,
]

View File

@@ -1,73 +0,0 @@
// Simple custom type derived from float
type Custom(float)
// Simple custom types with constraints
type Latitude(float) where (-90 <= _ <= 90)
type Longitude(float) where (-180 <= _ <= 180)
// Generic custom type (a Difference of T is derived from T, e.g. a difference of floats is a float
type Difference[T](T)
// Complex custom type, containing two values accessible through properties
type GeoLocation {
lat: Latitude
lon: Longitude
}
// Define operations on our custom type
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
op __sub__(GeoLocation) -> Difference[GeoLocation]
}
// For complex generics, you need to specify how the genericity the properties
// are handled
type Difference[GeoLocation] {
lat: Difference[Latitude]
lon: Difference[Longitude]
}
// Simple operation defined on our custom types
extend Latitude {
op __sub__(Latitude) -> Difference[Latitude]
}
extend Longitude {
op __sub__(Longitude) -> Difference[Longitude]
}
// Predefined custom predicates that can be referenced in other definitions
predicate Positive(v: float) = v >= 0
predicate StrictlyPositive(v: float) = v > 0
predicate Equatorial(loc: GeoLocation) = (-10 <= loc.lat <= 10)
predicate Arctic(loc: GeoLocation) = (loc.lat >= 66)
type Person {
name: str
// Property with an inline constraint
age: int? where (0 <= _ < 150)
// Property referencing a predicate
height: float where StrictlyPositive
home: GeoLocation
}
// Custom complex type derived from another complex type, with a constraint
// on a property
// Multiple proposed syntaxes, not yet defined
// Explicit, but new keyword
type EquatorialPerson refines Person where Equatorial(_.home)
// Explicit with existing keyword, might be confusing if expectations regarding 'is'
type EquatorialPerson is Person where Equatorial(_.home)
// Consistent and Python-friendly but can be confused with structural extension
type EquatorialPerson(Person) where Equatorial(_.home)
// Allow new properties, probably not useful
type EquatorialPerson extends Person where Equatorial(_.home)

View File

@@ -1,15 +0,0 @@
# type: ignore
# ruff: disable[F821]
from __future__ import annotations
def func(
col1: Column[float + (0 <= _ <= 1)],
col2: Column[float + (0 <= _ <= 1)],
) -> Column[float + (0 <= _ <= 2)]:
result: Column[float + (0 <= _ <= 2)] = col1 + col2
return result
def func2(a: int, /, b: float, *, c: str):
pass

View File

@@ -1,33 +0,0 @@
type Foo1 = float
type Foo2 = float where (_ > 3)
type Foo3 = int | float
type Foo4 = int where (_ > 3) | float where (_ > 3)
type Foo5 = (int | float) where (_ > 3)
type Foo6 = {
foo: float
bar: float where (_ > 3)
}
type Foo7[T] = T where (_ > 3)
type Foo8[A, B<:int] = {
a: A
b: B
}
type Complex = {
a: int
b: int
}
type Complex2 = Complex where (_.a > 3 & _.b < 5)
predicate Positive(n: int) = n >= 0
extend Foo1 {
op __add__(Foo1) -> Foo1
}
extend Foo7[T] {
op __add__(Foo7[T]) -> Foo7[T]
}
type Optional[T] = None | T

View File

@@ -6,7 +6,7 @@ c = a + b # -> int
c = "invalid" # -> can't assign str to int variable c = "invalid" # -> can't assign str to int variable
d = True d = True
e = d + d e = d + d # -> addition not defined between booleans
f: float = a f: float = a

View File

@@ -1,6 +1,7 @@
# type: ignore # type: ignore
# ruff: disable [F821] # ruff: disable [F821]
distance: Meter = cast(Meter, 123.45) distance = cast(Meter, 123.45)
time: Second = cast(Second, 6.7) time = cast(Second, 6.7)
speed = distance / time speed = distance / time
print(speed)

View File

@@ -1,3 +1,5 @@
# Return types must have a LUB
# Valid
def minimum(x: int, y: int): def minimum(x: int, y: int):
if x < y: if x < y:
return x return x
@@ -5,18 +7,28 @@ def minimum(x: int, y: int):
return y return y
# Invalid
def func(a: int):
if a < 5:
return "Oops"
return True
a = 15 a = 15
b = 72 b = 72
c = minimum(a, b) c = minimum(a, b)
# Recursive but typable thanks to return hint
def factorial(n: int) -> int: def factorial(n: int) -> int:
if n <= 1: if n <= 1:
return 1 return 1
return n * factorial(n - 1) return n * factorial(n - 1)
category = "Category 1" if a < 10 else "Category 2" # Branches must be of the same type
category = "Category 1" if a < 10 else "Category 2" # Valid
category = "Category 1" if a < 10 else 3 # Invalid
def foo() -> None: def foo() -> None:

View File

@@ -15,7 +15,9 @@ extend Coordinate {
type Difference[T <: float] = T type Difference[T <: float] = T
type MeterDifference = Difference[Meter] type MeterDifference = Difference[Meter]
type CompDiff[T <: float] = { type CompDiff[T <: float] = object
extend CompDiff[T <: float] {
prop d1: Difference[T] prop d1: Difference[T]
prop d2: Difference[T] prop d2: Difference[T]
} }

View File

@@ -1,20 +1,20 @@
# type: ignore # type: ignore
# ruff: disable [F821] # ruff: disable [F821]
p1: Coordinate p1 = cast(Coordinate, object())
p2: Coordinate p2 = cast(Coordinate, object())
diff_x = p2.x - p1.x diff_x = p2.x - p1.x
diff_y = p2.y - p1.y diff_y = p2.y - p1.y
dist = diff_x + diff_y dist = diff_x + diff_y
p2.x += cast(Meter, 1) p2.x += cast(Meter, 1.0)
p2.y = True # invalid, wrong type p2.y = True # invalid, wrong type
p2.z = 3 # invalid, no property 'z' on Coordinate p2.z = 3 # invalid, no property 'z' on Coordinate
p2.x.a = 3 # invalid, no properties on Meter p2.x.a = 3 # invalid, no properties on Meter
foo: list[float] = [] foo = cast(list[float], [])
append = foo.append append = foo.append
@@ -23,7 +23,7 @@ foo.append(2)
append(True) # invalid, must be float append(True) # invalid, must be float
append(2) append(2)
bar: list[list[Meter]] bar = cast(list[list[Meter]], [])
bar.append([p2.x]) bar.append([p2.x])

View File

@@ -1,9 +1,9 @@
# type: ignore # type: ignore
# ruff: disable [F821] # ruff: disable [F821]
foo: Foo foo = cast(Foo, object())
t1: T1 t1 = cast(T1, object())
t2: T2 t2 = cast(T2, object())
a = foo.bar(t1) a = foo.bar(t1)
b = foo.bar(t2) b = foo.bar(t2)
@@ -13,6 +13,6 @@ func = foo.bar
c = func(t1) c = func(t1)
d = func(t2) d = func(t2)
t2b: T2b t2b = cast(T2b, object())
e = foo.bar(t2b) e = foo.bar(t2b)

View File

@@ -1,14 +0,0 @@
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

@@ -136,15 +136,6 @@ class ConstraintType:
constraint: Expr constraint: Expr
class ComplexType:
members: list[MemberStmt]
class ExtensionType:
base: Type
extension: ComplexType
class FunctionType: class FunctionType:
params: ParamSpec params: ParamSpec
returns: Type returns: Type

View File

@@ -24,6 +24,17 @@ class ParamSpec:
return self.pos + self.mixed + self.kw 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
###< ###<
@@ -33,11 +44,6 @@ class BaseType:
args: tuple[MidasType, ...] args: tuple[MidasType, ...]
class ConstraintType:
type: MidasType
constraint: ast.expr
class FrameColumn: class FrameColumn:
name: Optional[str] name: Optional[str]
type: Optional[MidasType] type: Optional[MidasType]
@@ -99,6 +105,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

14
justfile Normal file
View File

@@ -0,0 +1,14 @@
# Local Variables:
# mode: makefile
# End:
set shell := ["bash", "-uc"]
build-docs:
typst c --root . docs/manual.typ
typst c --root . docs/function_subtyping.typ
tests:
uv run -m tests
check-docstrings:
uv run scripts/docstring_checker.py

View File

@@ -256,12 +256,6 @@ 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: ...
@@ -295,23 +289,6 @@ 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):
params: ParamSpec params: ParamSpec

View File

@@ -1,6 +1,9 @@
from typing import final
import midas.ast.midas as m import midas.ast.midas as m
@final
class MidasPrinter( class MidasPrinter(
m.Expr.Visitor[str], m.Expr.Visitor[str],
m.Stmt.Visitor[str], m.Stmt.Visitor[str],
@@ -124,19 +127,6 @@ class MidasPrinter(
res += " where " + type.constraint.accept(self) res += " where " + type.constraint.accept(self)
return res 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: def visit_function_type(self, type: m.FunctionType) -> str:
spec: str = self._visit_param_spec(type.params) spec: str = self._visit_param_spec(type.params)
return f"fn {spec} -> {type.returns.accept(self)}" return f"fn {spec} -> {type.returns.accept(self)}"

View File

@@ -1,7 +1,10 @@
from typing import final
import midas.ast.midas as m import midas.ast.midas as m
from midas.ast.printer.base import AstPrinter from midas.ast.printer.base import AstPrinter
@final
class MidasAstPrinter( class MidasAstPrinter(
AstPrinter, AstPrinter,
m.Expr.Visitor[None], m.Expr.Visitor[None],
@@ -177,21 +180,6 @@ class MidasAstPrinter(
with self._child_level(single=True): with self._child_level(single=True):
type.constraint.accept(self) type.constraint.accept(self)
def visit_complex_type(self, type: m.ComplexType) -> None:
self._write_line("ComplexType")
with self._child_level():
self._write_sequence("members", type.members, last=True)
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: def visit_function_type(self, type: m.FunctionType) -> None:
self._write_line("FunctionType") self._write_line("FunctionType")
with self._child_level(): with self._child_level():

View File

@@ -1,9 +1,11 @@
import ast import ast
from typing import final
import midas.ast.python as p import midas.ast.python as p
from midas.ast.printer.base import AstPrinter from midas.ast.printer.base import AstPrinter
@final
class PythonAstPrinter( class PythonAstPrinter(
AstPrinter, AstPrinter,
p.MidasType.Visitor[None], p.MidasType.Visitor[None],
@@ -18,14 +20,6 @@ class PythonAstPrinter(
self._write_line(f"base: {node.base}") self._write_line(f"base: {node.base}")
self._write_sequence("args", node.args, last=True) 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: def visit_frame_column(self, node: p.FrameColumn) -> None:
self._write_line("FrameColumn") self._write_line("FrameColumn")
with self._child_level(): with self._child_level():
@@ -123,6 +117,24 @@ class PythonAstPrinter(
stmt.iterator.accept(self) stmt.iterator.accept(self)
self._write_sequence("body", stmt.body, last=True) self._write_sequence("body", stmt.body, last=True)
def visit_import_stmt(self, stmt: p.ImportStmt) -> None:
self._write_line("ImportStmt")
with self._child_level(single=True):
self._write_sequence("imports", stmt.imports, print_func=self._print_import)
def visit_from_import_stmt(self, stmt: p.FromImportStmt) -> None:
self._write_line("FromImportStmt")
with self._child_level():
self._write_line(f'module: "{stmt.module}"')
self._write_sequence("imports", stmt.imports, print_func=self._print_import)
self._write_line(f"level: {stmt.level}", last=True)
def _print_import(self, import_: p.ImportAlias) -> None:
self._write_line("ImportAlias")
with self._child_level():
self._write_line(f'name: "{import_.name}"')
self._write_line(f'alias: "{import_.alias}"')
def visit_raw_stmt(self, stmt: p.RawStmt) -> None: def visit_raw_stmt(self, stmt: p.RawStmt) -> None:
self._write_line("RawStmt") self._write_line("RawStmt")
with self._child_level(single=True): with self._child_level(single=True):

View File

@@ -25,6 +25,17 @@ class ParamSpec:
return self.pos + self.mixed + self.kw 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 #
#################### ####################
@@ -41,9 +52,6 @@ class MidasType(ABC):
@abstractmethod @abstractmethod
def visit_base_type(self, node: BaseType) -> T: ... def visit_base_type(self, node: BaseType) -> T: ...
@abstractmethod
def visit_constraint_type(self, node: ConstraintType) -> T: ...
@abstractmethod @abstractmethod
def visit_frame_column(self, node: FrameColumn) -> T: ... def visit_frame_column(self, node: FrameColumn) -> T: ...
@@ -60,15 +68,6 @@ class BaseType(MidasType):
return visitor.visit_base_type(self) return visitor.visit_base_type(self)
@dataclass(frozen=True)
class ConstraintType(MidasType):
type: MidasType
constraint: ast.expr
def accept(self, visitor: MidasType.Visitor[T]) -> T:
return visitor.visit_constraint_type(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class FrameColumn(MidasType): class FrameColumn(MidasType):
name: Optional[str] name: Optional[str]
@@ -123,6 +122,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: ...
@@ -207,6 +212,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

View File

@@ -26,7 +26,11 @@ Circular dependencies and diamond inheritance MUST be avoided
def define_builtins(reg: TypesRegistry): def define_builtins(reg: TypesRegistry):
"""Define builtin types and operations""" """Define builtin types and operations
Args:
reg (TypesRegistry): the types registry
"""
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"))

View File

@@ -18,9 +18,6 @@ class Diagnostic:
Holds a location, a diagnostic type and a message. Holds a location, a diagnostic type and a message.
Optionally bound to a file path Optionally bound to a file path
Returns:
_type_: _description_
""" """
file_path: Optional[str] file_path: Optional[str]
@@ -30,7 +27,7 @@ class Diagnostic:
@property @property
def location_str(self) -> str: def location_str(self) -> str:
"""The diagnostic type and location as a human readable string """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>", 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" for example: "Error in /home/user/Desktop/script.py from L12:5 to L12:8"
@@ -39,7 +36,7 @@ class Diagnostic:
If the location's end is not specified, the formulation "at L<start_line>:<start_col>" is used. If the location's end is not specified, the formulation "at L<start_line>:<start_col>" is used.
Returns: Returns:
str: _description_ 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}"

View File

@@ -26,10 +26,13 @@ class HasLocation(Protocol):
E = TypeVar("E", bound=HasLocation) E = TypeVar("E", bound=HasLocation)
TypedExpr = tuple[E, Type] TypedExpr = tuple[E, Type]
"""An expression and its type"""
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class MappedArgument(Generic[E]): class MappedArgument(Generic[E]):
"""An argument passed in a call and the corresponding parameter"""
arg_expr: E arg_expr: E
arg_type: Type arg_type: Type
parameter: Function.Parameter parameter: Function.Parameter
@@ -37,11 +40,15 @@ class MappedArgument(Generic[E]):
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class OverloadCandidate: class OverloadCandidate:
"""An overloaded function call candidate with its mapped arguments"""
function: Function function: Function
mapped: list[MappedArgument] mapped: list[MappedArgument]
class CallError(StrEnum): class CallError(StrEnum):
"""Reason of a call error"""
INVALID_ARGS = "Invalid arguments" INVALID_ARGS = "Invalid arguments"
NO_MATCHING_OVERLOAD = "No matching overload" NO_MATCHING_OVERLOAD = "No matching overload"
IMPOSSIBLE_UNIFICATION = "Parameters unification failed" IMPOSSIBLE_UNIFICATION = "Parameters unification failed"
@@ -50,16 +57,28 @@ class CallError(StrEnum):
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class CallResult: class CallResult:
"""The result of a function call
Holds a return type, an optional error reason and message
"""
error: Optional[CallError] = None error: Optional[CallError] = None
"""The reason of the error, if there is one"""
result: Type = UnknownType() result: Type = UnknownType()
"""The result type. `UnknownType()` if the call is invalid"""
message: Optional[str] = None message: Optional[str] = None
"""An optional error message"""
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
"""Whether the call is valid (i.e. no error)"""
return self.error is None return self.error is None
@property @property
def error_message(self) -> str: def error_message(self) -> str:
"""A descriptive message for the error, if there is one"""
if self.message is not None: if self.message is not None:
return self.message return self.message
if self.error is not None: if self.error is not None:
@@ -68,12 +87,26 @@ class CallResult:
class CallDispatcher(Generic[E]): 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: def __init__(self, types: TypesRegistry, reporter: FileReporter) -> None:
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.reporter: FileReporter = reporter self.reporter: FileReporter = reporter
self.logger: logging.Logger = logging.getLogger("CallDispatcher") self.logger: logging.Logger = logging.getLogger("CallDispatcher")
def set_reporter(self, reporter: FileReporter): def set_reporter(self, reporter: FileReporter):
"""Set the current reporter
Args:
reporter (FileReporter): the new file reporter
"""
self.reporter = reporter self.reporter = reporter
def get_result( def get_result(
@@ -86,22 +119,21 @@ class CallDispatcher(Generic[E]):
) -> CallResult: ) -> CallResult:
"""Get the result type of a function call """Get the result type of a function call
If the function has overloads, the function will try to resolve the If the callee has overloads, this function will try to resolve the
appropriate signature. appropriate signature.
Argument types are matched to the defined parameters. Argument types are matched with the defined parameters.
The function doesn't take the raw expression as a parameter to accommodate This function doesn't take the raw expression as a parameter to
for desugared calls such as for operators. accommodate for desugared calls such as for operators.
Args: Args:
location (Location): the call location location (Location): the call location
callee (Type): the called function callee (Type): the called function
positional (list[TypedExpr]): the list positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments keywords (dict[str, TypedExpr[E]]): the map of keyword arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
Type: the return type of the call, or `None` if either CallResult: the call result, either a type or an error
the call is invalid or no overload matched the arguments uniquely
""" """
match callee: match callee:
case Function() as function: case Function() as function:
@@ -179,6 +211,18 @@ class CallDispatcher(Generic[E]):
positional: list[TypedExpr[E]], positional: list[TypedExpr[E]],
keywords: dict[str, TypedExpr[E]], keywords: dict[str, TypedExpr[E]],
) -> Union[tuple[Function, None], tuple[None, CallError]]: ) -> 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: match callee:
case DerivedType(type=base): case DerivedType(type=base):
return self._unwrap_function(base, positional, keywords) return self._unwrap_function(base, positional, keywords)
@@ -211,7 +255,7 @@ class CallDispatcher(Generic[E]):
"""Check whether the passed argument types correspond to their matched parameter definitions """Check whether the passed argument types correspond to their matched parameter definitions
Args: Args:
arguments (list[MappedArgument]): the list of argument/parameter pairs arguments (list[MappedArgument[E]]): the list of argument/parameter pairs
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
@@ -219,6 +263,12 @@ class CallDispatcher(Generic[E]):
""" """
valid: bool = True valid: bool = True
for arg in arguments: 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 not self.types.is_subtype(arg.arg_type, arg.parameter.type):
if report_errors: if report_errors:
self.reporter.error( self.reporter.error(
@@ -241,13 +291,14 @@ class CallDispatcher(Generic[E]):
Args: Args:
overloads (list[Type]): the list of possible overloads overloads (list[Type]): the list of possible overloads
location (Location): the call location location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keywords arguments keywords (dict[str, TypedExpr[E]]): the map of keywords arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
Optional[Function]: the resolved function signature if it can be Union[tuple[Function, None], tuple[None, str]]: a tuple containing
determined unambiguously, or `None`. the resolved function signature if it can be determined
unambiguously, or `None`, and an error message, or `None`
""" """
candidates: list[OverloadCandidate] = [] candidates: list[OverloadCandidate] = []
errors: list[CallError] = [] errors: list[CallError] = []
@@ -339,13 +390,13 @@ class CallDispatcher(Generic[E]):
Args: Args:
function (Function): the function definition function (Function): the function definition
location (Location): the call location location (Location): the call location
positional (list[TypedExpr]): the list of positional arguments positional (list[TypedExpr[E]]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments keywords (dict[str, TypedExpr[E]]): the map of keyword arguments
report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True. report_errors (bool, optional): whether type errors should be reported as diagnostics. Defaults to True.
Returns: Returns:
tuple[bool, list[MappedArgument]]: a boolean reporting whether tuple[bool, list[MappedArgument]]: a boolean reporting whether
the call is valid and the list of mapped arguments the call is valid and the list of mapped arguments
""" """
set_params: set[str] = set() set_params: set[str] = set()
@@ -464,12 +515,12 @@ class CallDispatcher(Generic[E]):
of `mapped2`. If any of the parameter type in `mapped1` is not a subtype of `mapped2`. If any of the parameter type in `mapped1` is not a subtype
of the corresponding parameter in `mapped2`, `False` is returned. of the corresponding parameter in `mapped2`, `False` is returned.
This is used to check whether a given overload is This is used to check whether a given overload is a more specific
a more specific function/ a subtype of another. function / a subtype of another.
Args: Args:
mapped1 (list[MappedArgument]): the first argument mappings (subtype) mapped1 (list[MappedArgument[E]]): the first argument mappings (subtype)
mapped2 (list[MappedArgument]): the second argument mappings (supertype) mapped2 (list[MappedArgument[E]]): the second argument mappings (supertype)
Returns: Returns:
bool: `True` if `mapped1` is a subtype of `mapped2`, `False` otherwise bool: `True` if `mapped1` is a subtype of `mapped2`, `False` otherwise

View File

@@ -1,7 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Optional from typing import Any, Callable, Optional, final
import midas.ast.midas as m import midas.ast.midas as m
from midas.ast.location import Location
from midas.checker.preamble import Preamble from midas.checker.preamble import Preamble
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
from midas.checker.reporter import FileReporter from midas.checker.reporter import FileReporter
@@ -11,10 +12,19 @@ from midas.lexer.token import TokenType
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class PartialPredicate(Predicate): class PartialPredicate(Predicate):
"""A partially applied predicate"""
scope: dict[str, Any] scope: dict[str, Any]
"""A dictionary of already applied parameters"""
@final
class Evaluator(m.Expr.Visitor[Any]): 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): def __init__(self, types: TypesRegistry, reporter: Optional[FileReporter] = None):
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.reporter: Optional[FileReporter] = reporter self.reporter: Optional[FileReporter] = reporter
@@ -22,16 +32,51 @@ class Evaluator(m.Expr.Visitor[Any]):
self.scopes: list[dict[str, Any]] = [{}] self.scopes: list[dict[str, Any]] = [{}]
def evaluate(self, expr: m.Expr) -> 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) value: Any = expr.accept(self)
if self.reporter is not None: if self.reporter is not None:
self.reporter.debug(expr.location, f"Value: {value}") self.reporter.debug(expr.location, f"Value: {value}")
return value return value
def get_value(self, name: str) -> Any: 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] scope: dict[str, Any] = self.scopes[-1]
return scope[name] return scope[name]
def set_value(self, name: str, value: Any, force_declare: bool = False): 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: if not force_declare:
for scope in reversed(self.scopes): for scope in reversed(self.scopes):
if name in scope: if name in scope:
@@ -56,6 +101,8 @@ class Evaluator(m.Expr.Visitor[Any]):
left: Any = self.evaluate(expr.left) left: Any = self.evaluate(expr.left)
right: Any = self.evaluate(expr.right) right: Any = self.evaluate(expr.right)
match expr.operator.type: match expr.operator.type:
case TokenType.PLUS:
return left + right
case TokenType.MINUS: case TokenType.MINUS:
return left - right return left - right
case TokenType.STAR: case TokenType.STAR:
@@ -80,8 +127,12 @@ class Evaluator(m.Expr.Visitor[Any]):
def visit_unary_expr(self, expr: m.UnaryExpr) -> Any: def visit_unary_expr(self, expr: m.UnaryExpr) -> Any:
right: Any = self.evaluate(expr.right) right: Any = self.evaluate(expr.right)
match expr.operator.type: match expr.operator.type:
case TokenType.PLUS:
return +right
case TokenType.MINUS: case TokenType.MINUS:
return -right return -right
case TokenType.BANG:
return not right
case _: case _:
raise NotImplementedError raise NotImplementedError
@@ -94,7 +145,7 @@ class Evaluator(m.Expr.Visitor[Any]):
match callee: match callee:
case Predicate(): case Predicate():
return self._evaluate_predicate(callee, args, kwargs) return self._evaluate_predicate(expr.location, callee, args, kwargs)
case _ if callable(callee): case _ if callable(callee):
return callee(*args, **kwargs) return callee(*args, **kwargs)
case _: case _:
@@ -131,8 +182,23 @@ class Evaluator(m.Expr.Visitor[Any]):
return self.get_value("_") return self.get_value("_")
def _evaluate_predicate( def _evaluate_predicate(
self, predicate: Predicate, args: list[Any], kwargs: dict[str, Any] self,
location: Location,
predicate: Predicate,
args: list[Any],
kwargs: dict[str, Any],
) -> Any: ) -> Any:
"""Evaluate a predicate function call
Args:
location (Location): the location of the call expression
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 res: Any = None
if isinstance(predicate, PartialPredicate): if isinstance(predicate, PartialPredicate):
self.scopes.append(predicate.scope) self.scopes.append(predicate.scope)
@@ -140,7 +206,7 @@ class Evaluator(m.Expr.Visitor[Any]):
self.scopes.append({}) self.scopes.append({})
match predicate.type: match predicate.type:
case Function(returns=Function() as inner): case Function(returns=Function() as inner):
self._map_args(predicate.type, args, kwargs) self._map_args(location, predicate.type, args, kwargs)
res = PartialPredicate( res = PartialPredicate(
type=inner, type=inner,
body=predicate.body, body=predicate.body,
@@ -149,7 +215,7 @@ class Evaluator(m.Expr.Visitor[Any]):
) )
case Function(): case Function():
self._map_args(predicate.type, args, kwargs) self._map_args(location, predicate.type, args, kwargs)
res = self.evaluate(predicate.body) res = self.evaluate(predicate.body)
case _: case _:
@@ -157,7 +223,24 @@ class Evaluator(m.Expr.Visitor[Any]):
self.scopes.pop() self.scopes.pop()
return res return res
def _map_args(self, function: Function, args: list[Any], kwargs: dict[str, Any]): 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:
location (Location): the location of the call expression
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] = ( positional: list[Function.Parameter] = (
function.params.pos + function.params.mixed function.params.pos + function.params.mixed
) )
@@ -166,9 +249,20 @@ class Evaluator(m.Expr.Visitor[Any]):
} }
for i, arg in enumerate(args): 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] param: Function.Parameter = positional[i]
self.set_value(param.name, arg) self.set_value(param.name, arg)
for name, arg in kwargs.items(): 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] param: Function.Parameter = keywords[name]
self.set_value(param.name, arg) self.set_value(param.name, arg)

View File

@@ -12,8 +12,8 @@ from midas.checker.types import (
ColumnType, ColumnType,
Function, Function,
ParamSpec, ParamSpec,
TopType,
Type, Type,
UnknownType,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Call: class Call:
"""A column group-by method call, implements :class:`utils.MethodCall`"""
location: Location location: Location
call_expr: p.Expr call_expr: p.Expr
groupby: ColumnGroupBy groupby: ColumnGroupBy
@@ -35,6 +37,8 @@ class Call:
class ColumnGroupByMethodRegistry(MethodRegistry[Call]): class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
"""The method registry for column group-by types"""
NAMED_ARGS: dict[str, str] = { NAMED_ARGS: dict[str, str] = {
"numeric_only": "bool", "numeric_only": "bool",
"skipna": "bool", "skipna": "bool",
@@ -45,10 +49,22 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def _aggregate( def _aggregate(
self, self,
call: Call, call: Call,
method: str,
params: list[str | tuple[str, str, bool]] = [], params: list[str | tuple[str, str, bool]] = [],
*,
preserve_inner_type: bool = False,
) -> Type: ) -> 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] = [] real_params: list[Function.Parameter] = []
for i, param in enumerate(params): for i, param in enumerate(params):
match param: match param:
@@ -68,13 +84,24 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
) )
real_params.append(param) real_params.append(param)
# TODO: maybe better to filter arguments and pass some, in case the
# return type depends on them
# Don't catch UndefinedMethodException because all aggregation
# methods should be defined on columns too
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( signature = Function(
params=ParamSpec(mixed=real_params), params=ParamSpec(mixed=real_params),
returns=( returns=returns,
call.groupby.column
if preserve_inner_type
else ColumnType(type=TopType())
),
) )
result: CallResult = self.dispatcher.get_result( result: CallResult = self.dispatcher.get_result(
@@ -89,6 +116,7 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def kurt(self, call: Call) -> Type: def kurt(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"kurt",
["skipna", "numeric_only"], ["skipna", "numeric_only"],
) )
@@ -96,6 +124,7 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def max(self, call: Call) -> Type: def max(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"max",
[ [
"numeric_only", "numeric_only",
( (
@@ -107,13 +136,13 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
"engine", "engine",
"engine_kwargs", "engine_kwargs",
], ],
preserve_inner_type=True,
) )
@method() @method()
def mean(self, call: Call) -> Type: def mean(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"mean",
["numeric_only", "skipna", "engine", "engine_kwargs"], ["numeric_only", "skipna", "engine", "engine_kwargs"],
) )
@@ -121,14 +150,15 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def median(self, call: Call) -> Type: def median(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"median",
["numeric_only", "skipna"], ["numeric_only", "skipna"],
preserve_inner_type=True,
) )
@method() @method()
def min(self, call: Call) -> Type: def min(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"min",
[ [
"numeric_only", "numeric_only",
( (
@@ -140,13 +170,13 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
"engine", "engine",
"engine_kwargs", "engine_kwargs",
], ],
preserve_inner_type=True,
) )
@method() @method()
def prod(self, call: Call) -> Type: def prod(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"prod",
[ [
"numeric_only", "numeric_only",
( (
@@ -162,6 +192,7 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def std(self, call: Call) -> Type: def std(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"std",
[ [
( (
"ddof", "ddof",
@@ -179,6 +210,7 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def sum(self, call: Call) -> Type: def sum(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"sum",
[ [
"numeric_only", "numeric_only",
( (
@@ -196,6 +228,7 @@ class ColumnGroupByMethodRegistry(MethodRegistry[Call]):
def var(self, call: Call) -> Type: def var(self, call: Call) -> Type:
return self._aggregate( return self._aggregate(
call, call,
"var",
[ [
( (
"var", "var",

View File

@@ -4,17 +4,28 @@ from typing import TYPE_CHECKING, Optional
import midas.ast.python as p import midas.ast.python as p
from midas.ast.location import Location 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 Call as GroupByCall
from midas.checker.frames.column_groupby_methods import ColumnGroupByMethodRegistry from midas.checker.frames.column_groupby_methods import ColumnGroupByMethodRegistry
from midas.checker.frames.column_methods import Call, ColumnMethodRegistry from midas.checker.frames.column_methods import Call, ColumnMethodRegistry
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
from midas.checker.types import ColumnGroupBy, ColumnType, Type from midas.checker.reporter import FileReporter
from midas.checker.types import (
ColumnGroupBy,
ColumnType,
Function,
OverloadedFunction,
ParamSpec,
Type,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from midas.checker.python import PythonTyper, TypedExpr from midas.checker.python import PythonTyper, TypedExpr
class ColumnManager: class ColumnManager:
"""Helper class to handle methods and subscripts on column types"""
def __init__(self, typer: PythonTyper) -> None: def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer self.typer: PythonTyper = typer
self.method_resolver: ColumnMethodRegistry = ColumnMethodRegistry(self.typer) self.method_resolver: ColumnMethodRegistry = ColumnMethodRegistry(self.typer)
@@ -22,6 +33,62 @@ class ColumnManager:
ColumnGroupByMethodRegistry(self.typer) 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 (ColumnType): 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( def call(
self, self,
method: str, method: str,
@@ -32,6 +99,20 @@ class ColumnManager:
positional: list[TypedExpr], positional: list[TypedExpr],
keywords: dict[str, TypedExpr], keywords: dict[str, TypedExpr],
) -> Type: ) -> 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( call: Call = Call(
location=location, location=location,
call_expr=call_expr, call_expr=call_expr,
@@ -52,6 +133,20 @@ class ColumnManager:
positional: list[TypedExpr], positional: list[TypedExpr],
keywords: dict[str, TypedExpr], keywords: dict[str, TypedExpr],
) -> Type: ) -> 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( call: GroupByCall = GroupByCall(
location=location, location=location,
call_expr=call_expr, call_expr=call_expr,
@@ -63,6 +158,15 @@ class ColumnManager:
return self.groupby_method_resolver.call(method, call) return self.groupby_method_resolver.call(method, call)
def get_attribute(self, column: ColumnType, name: str) -> Optional[Type]: 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 types: TypesRegistry = self.typer.types
match name: match name:
case "ndim" | "size": case "ndim" | "size":

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import ast import ast
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Callable, Optional, TypeAlias, Union
import midas.ast.python as p import midas.ast.python as p
from midas.ast.location import Location from midas.ast.location import Location
@@ -12,11 +12,11 @@ from midas.checker.types import (
ColumnGroupBy, ColumnGroupBy,
ColumnType, ColumnType,
Function, Function,
GenericType, OverloadedFunction,
ParamSpec, ParamSpec,
TopType, TopType,
Type, Type,
TypeVar, UnitType,
UnknownType, UnknownType,
unfold_type, unfold_type,
) )
@@ -24,9 +24,36 @@ from midas.checker.types import (
if TYPE_CHECKING: if TYPE_CHECKING:
from midas.checker.python import TypedExpr 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) @dataclass(frozen=True, kw_only=True)
class Call: class Call:
"""A column method call, implements :class:`utils.MethodCall`"""
location: Location location: Location
call_expr: p.Expr call_expr: p.Expr
column: ColumnType column: ColumnType
@@ -40,7 +67,74 @@ class Call:
class ColumnMethodRegistry(MethodRegistry[Call]): class ColumnMethodRegistry(MethodRegistry[Call]):
def _element_binary_op(self, call: Call, method: str) -> ColumnType: """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 """Compute the result of an element-wise binary operation
This function delegates to the inner types for computing the resulting This function delegates to the inner types for computing the resulting
@@ -51,50 +145,63 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
method (str): the method name method (str): the method name
Returns: Returns:
ColumnType: the resulting column type tuple[Type, bool]: the resulting type and a boolean indicating
whether the operand is a column
""" """
column2: Optional[ColumnType] = None if len(call.positional) == 0:
return UnknownType(), False
col_type1: Type = call.column.type col_type1: Type = call.column.type
new_column: Type = ColumnType(type=UnknownType()) operand: TypedExpr = call.positional[0]
if len(call.positional) != 0: unfolded_operand: Type = unfold_type(operand[1])
other: Type = call.positional[0][1] col_type2: Type
unfolded_other: Type = unfold_type(other)
if isinstance(unfolded_other, ColumnType):
column2 = unfolded_other
col_type2: Type = column2.type
new_inner_type = self.typer.result_of_binary_op( column_operand: bool = isinstance(unfolded_operand, ColumnType)
location=call.location,
expr=call.call_expr, # Operand is a column -> get the inner type
left=(call.column_expr, col_type1), if column_operand:
right=(call.positional[0][0], col_type2), col_type2 = unfolded_operand.type
method=method, # Otherwise use the operand type itself
) else:
new_column = ColumnType(type=new_inner_type) col_type2 = operand[1]
return new_column
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: def _element_wise(self, call: Call, method: str) -> Type:
# TODO: support add with scalar """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 # Build signature with new column type and generic operand
param_type: TypeVar = TypeVar(name="T", bound=None) returns, column_operand = self._element_binary_op(call, method)
signature = GenericType( signature = Function(
name="add", params=ParamSpec(
params=[param_type], mixed=[
body=Function( Function.Parameter(
params=ParamSpec( pos=0,
mixed=[ name="other",
Function.Parameter( type=TopType(),
pos=0, required=True,
name="other", ),
type=ColumnType(type=param_type), ],
required=True,
),
],
),
returns=self._element_binary_op(call, method),
), ),
returns=returns,
) )
# Map arguments and compute result type # Map arguments and compute result type
@@ -104,13 +211,83 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
positional=call.positional, positional=call.positional,
keywords=call.keywords, keywords=call.keywords,
) )
if result.is_valid: if result.is_valid and column_operand:
self._assert_same_length( self._assert_same_length(
call.call_expr, call.column_expr, call.positional[0][0] call.call_expr, call.column_expr, call.positional[0][0]
) )
return result.result 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__") @method("add", "__add__")
def add(self, call: Call) -> Type: def add(self, call: Call) -> Type:
return self._element_wise(call, "__add__") return self._element_wise(call, "__add__")
@@ -168,8 +345,33 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
call: Call, call: Call,
kwargs: list[Function.Parameter] = [], kwargs: list[Function.Parameter] = [],
*, *,
preserve_inner_type: bool = False, formula: Optional[Callable[[Type], Formula]] = None,
) -> Type: ) -> 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 (Optional[Callable[[Type], Formula]], optional):
optional formula builder function to compute the return type.<br>
If set, the function should accept the inner column type and
return a formula.<br>
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( signature = Function(
params=ParamSpec( params=ParamSpec(
kw=[ kw=[
@@ -182,7 +384,7 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
*kwargs, *kwargs,
], ],
), ),
returns=call.column if preserve_inner_type else ColumnType(type=TopType()), returns=returns,
) )
result: CallResult = self.dispatcher.get_result( result: CallResult = self.dispatcher.get_result(
@@ -199,27 +401,29 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
@method() @method()
def max(self, call: Call) -> Type: def max(self, call: Call) -> Type:
return self._aggregate(call, preserve_inner_type=True) return self._aggregate(call, formula=lambda t: t)
@method() @method()
def mean(self, call: Call) -> Type: def mean(self, call: Call) -> Type:
return self._aggregate(call) return self._aggregate(
call, formula=lambda t: ((t, "__add__", t), "__truediv__", "int")
)
@method() @method()
def median(self, call: Call) -> Type: def median(self, call: Call) -> Type:
return self._aggregate(call, preserve_inner_type=True) return self._aggregate(call, formula=lambda t: t)
@method() @method()
def min(self, call: Call) -> Type: def min(self, call: Call) -> Type:
return self._aggregate(call, preserve_inner_type=True) return self._aggregate(call, formula=lambda t: t)
@method() @method()
def mode(self, call: Call) -> Type: def mode(self, call: Call) -> Type:
return self._aggregate(call, preserve_inner_type=True) return self._aggregate(call, formula=lambda t: t)
@method("product", "prod") @method("product", "prod")
def product(self, call: Call) -> Type: def product(self, call: Call) -> Type:
return self._aggregate(call) return self._aggregate(call, formula=lambda t: (t, "__mul__", t))
@method() @method()
def std(self, call: Call) -> Type: def std(self, call: Call) -> Type:
@@ -237,7 +441,7 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
@method() @method()
def sum(self, call: Call) -> Type: def sum(self, call: Call) -> Type:
return self._aggregate(call) return self._aggregate(call, formula=lambda t: (t, "__add__", t))
@method() @method()
def var(self, call: Call) -> Type: def var(self, call: Call) -> Type:
@@ -301,6 +505,79 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
) )
return result.result 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() @method()
def groupby(self, call: Call) -> Type: def groupby(self, call: Call) -> Type:
bool_: Type = self.types.get_type("bool") bool_: Type = self.types.get_type("bool")
@@ -344,6 +621,14 @@ class ColumnMethodRegistry(MethodRegistry[Call]):
return result.result return result.result
def _assert_same_length(self, call_expr: p.Expr, column1: p.Expr, column2: p.Expr): 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__" func_name: str = "__midas_column_same_length__"
# Efficiently compute length # Efficiently compute length

View File

@@ -21,6 +21,8 @@ if TYPE_CHECKING:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Call: class Call:
"""A frame group-by method call, implements :class:`utils.MethodCall`"""
location: Location location: Location
call_expr: p.Expr call_expr: p.Expr
groupby: FrameGroupBy groupby: FrameGroupBy
@@ -34,35 +36,42 @@ class Call:
class FrameGroupByMethodRegistry(MethodRegistry[Call]): class FrameGroupByMethodRegistry(MethodRegistry[Call]):
NAMED_ARGS: dict[str, str] = { """The method registry for frame group-by types"""
"numeric_only": "bool",
"skipna": "bool",
"engine": "str",
"engine_kwargs": "dict",
}
def _aggregate(self, call: Call, method: str) -> Type: 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] = [] new_columns: list[DataFrameType.Column] = []
for column in call.groupby.frame.columns: for column in call.groupby.frame.columns:
column_groupby: ColumnGroupBy = ColumnGroupBy(column=column.type) with self.reporter.with_context(f"in column '{column.name}'"):
result_type: Type = self.typer.call_method( column_groupby: ColumnGroupBy = ColumnGroupBy(column=column.type)
location=call.location, # Don't catch UndefinedMethodException because all aggregation
call_expr=call.call_expr, # methods should be defined on columns too
obj=(call.groupby_expr, column_groupby), result_type: Type = self.typer.call_method(
method_name=method, location=call.location,
positional=call.positional, call_expr=call.call_expr,
keywords=call.keywords, obj=(call.groupby_expr, column_groupby),
) method_name=method,
if not isinstance(result_type, ColumnType): positional=call.positional,
result_type = ColumnType(type=UnknownType()) keywords=call.keywords,
new_columns.append( )
DataFrameType.Column( if not isinstance(result_type, ColumnType):
index=column.index, result_type = ColumnType(type=UnknownType())
name=column.name, new_columns.append(
type=result_type, DataFrameType.Column(
index=column.index,
name=column.name,
type=result_type,
)
) )
)
return DataFrameType(columns=new_columns) return DataFrameType(columns=new_columns)

View File

@@ -24,10 +24,20 @@ if TYPE_CHECKING:
def is_list_of_literals(exprs: list[p.Expr]) -> TypeGuard[list[p.LiteralExpr]]: 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) return all(isinstance(expr, p.LiteralExpr) for expr in exprs)
class FrameManager: class FrameManager:
"""Helper class to handle methods and subscripts on frame types"""
def __init__(self, typer: PythonTyper) -> None: def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer self.typer: PythonTyper = typer
self.method_resolver: FrameMethodRegistry = FrameMethodRegistry(self.typer) self.method_resolver: FrameMethodRegistry = FrameMethodRegistry(self.typer)
@@ -43,6 +53,18 @@ class FrameManager:
index: p.Expr, index: p.Expr,
value_type: Type, value_type: 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: match index:
case p.LiteralExpr(value=str() as name): case p.LiteralExpr(value=str() as name):
return self.assign_column(reporter, location, frame, name, value_type) return self.assign_column(reporter, location, frame, name, value_type)
@@ -93,12 +115,24 @@ class FrameManager:
name: str, name: str,
type: Type, type: 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): if not isinstance(type, ColumnType):
reporter.error( reporter.error(
location, location,
f"Cannot assign {type} to dataframe column. Must be a ColumnType", f"Cannot assign {type} to dataframe column. Must be a ColumnType",
) )
return frame return self._set_column(frame, name, ColumnType(type=UnknownType()))
return self._set_column(frame, name, type) return self._set_column(frame, name, type)
def get( def get(
@@ -108,6 +142,17 @@ class FrameManager:
frame: DataFrameType, frame: DataFrameType,
index: p.Expr, index: p.Expr,
) -> Type: ) -> 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: match index:
case p.LiteralExpr(value=str() as name): case p.LiteralExpr(value=str() as name):
column: Optional[ColumnType] = FrameManager._get_column(frame, name) column: Optional[ColumnType] = FrameManager._get_column(frame, name)
@@ -142,6 +187,17 @@ class FrameManager:
groupby: FrameGroupBy, groupby: FrameGroupBy,
index: p.Expr, index: p.Expr,
) -> Type: ) -> 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) result: Type = self.get(reporter, location, groupby.frame, index)
match result: match result:
case ColumnType(): case ColumnType():
@@ -159,6 +215,16 @@ class FrameManager:
def _set_column( def _set_column(
cls, frame: DataFrameType, name: str, column: ColumnType cls, frame: DataFrameType, name: str, column: ColumnType
) -> DataFrameType: ) -> 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] = [] new_columns: list[DataFrameType.Column] = []
index: int = len(frame.columns) index: int = len(frame.columns)
replace: bool = False replace: bool = False
@@ -166,7 +232,7 @@ class FrameManager:
if col.name == name: if col.name == name:
index = i index = i
replace = True replace = True
# TODO: check column type here to prevent changing it # TODO: might want to check column type here to disallow changing it
new_columns.append(col) new_columns.append(col)
new_col: DataFrameType.Column = DataFrameType.Column( new_col: DataFrameType.Column = DataFrameType.Column(
@@ -185,12 +251,31 @@ class FrameManager:
def _set_columns( def _set_columns(
cls, frame: DataFrameType, names: list[str], columns: list[ColumnType] cls, frame: DataFrameType, names: list[str], columns: list[ColumnType]
) -> DataFrameType: ) -> 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): for name, col in zip(names, columns):
frame = cls._set_column(frame, name, col) frame = cls._set_column(frame, name, col)
return frame return frame
@classmethod @classmethod
def _get_column(cls, frame: DataFrameType, name: str) -> Optional[ColumnType]: 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: for col in frame.columns:
if col.name == name: if col.name == name:
return col.type return col.type
@@ -200,6 +285,15 @@ class FrameManager:
def _get_columns( def _get_columns(
cls, frame: DataFrameType, names: list[str] cls, frame: DataFrameType, names: list[str]
) -> list[Optional[ColumnType]]: ) -> 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] return [cls._get_column(frame, name) for name in names]
def call( def call(
@@ -212,6 +306,20 @@ class FrameManager:
positional: list[TypedExpr], positional: list[TypedExpr],
keywords: dict[str, TypedExpr], keywords: dict[str, TypedExpr],
) -> Type: ) -> 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( call: Call = Call(
location=location, location=location,
call_expr=call_expr, call_expr=call_expr,
@@ -232,6 +340,20 @@ class FrameManager:
positional: list[TypedExpr], positional: list[TypedExpr],
keywords: dict[str, TypedExpr], keywords: dict[str, TypedExpr],
) -> Type: ) -> 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( call: GroupByCall = GroupByCall(
location=location, location=location,
call_expr=call_expr, call_expr=call_expr,
@@ -243,6 +365,15 @@ class FrameManager:
return self.groupby_method_resolver.call(method, call) return self.groupby_method_resolver.call(method, call)
def get_attribute(self, frame: DataFrameType, name: str) -> Optional[Type]: 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 types: TypesRegistry = self.typer.types
match name: match name:
case "ndim" | "size": case "ndim" | "size":

View File

@@ -17,6 +17,7 @@ from midas.checker.types import (
ParamSpec, ParamSpec,
TopType, TopType,
Type, Type,
UnitType,
UnknownType, UnknownType,
unfold_type, unfold_type,
) )
@@ -27,6 +28,8 @@ if TYPE_CHECKING:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Call: class Call:
"""A frame method call, implements :class:`utils.MethodCall`"""
location: Location location: Location
call_expr: p.Expr call_expr: p.Expr
frame: DataFrameType frame: DataFrameType
@@ -40,6 +43,27 @@ class Call:
class FrameMethodRegistry(MethodRegistry[Call]): 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( def _get_method_result(
self, self,
call: Call, call: Call,
@@ -78,7 +102,7 @@ class FrameMethodRegistry(MethodRegistry[Call]):
return ColumnType(type=UnknownType()) return ColumnType(type=UnknownType())
return result return result
def _element_binary_op(self, call: Call, method: str) -> DataFrameType: def _element_binary_op(self, call: Call, method: str) -> tuple[Type, bool]:
"""Compute the result of an element-wise binary operation """Compute the result of an element-wise binary operation
This function delegates to the matching columns for computing resulting This function delegates to the matching columns for computing resulting
@@ -91,21 +115,24 @@ class FrameMethodRegistry(MethodRegistry[Call]):
method (str): the method name method (str): the method name
Returns: Returns:
DataFrameType: the resulting frame type 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] = [] new_columns: list[DataFrameType.Column] = []
by_name: dict[str, DataFrameType.Column] = {} by_name: dict[str, DataFrameType.Column] = {}
frame2: Optional[DataFrameType] = None frame2: Optional[DataFrameType] = None
# Get map of operand's columns by name, if there is at least 1 operand, which is a dataframe # Get map of operand's columns by name, if the operand is a dataframe
if len(call.positional) != 0: unfolded_other: Type = unfold_type(operand[1])
operand: TypedExpr = call.positional[0] frame_operand: bool = isinstance(unfolded_other, DataFrameType)
unfolded_other: Type = unfold_type(operand[1]) if frame_operand:
if isinstance(unfolded_other, DataFrameType): frame2 = unfolded_other
frame2 = unfolded_other by_name = {col.name: col for col in frame2.columns if col.name is not None}
by_name = {
col.name: col for col in frame2.columns if col.name is not None
}
# Compute new schema: # Compute new schema:
# Step 1: for all columns in frame1: # Step 1: for all columns in frame1:
@@ -118,11 +145,24 @@ class FrameMethodRegistry(MethodRegistry[Call]):
col_type1: ColumnType = column.type col_type1: ColumnType = column.type
col_type: ColumnType = ColumnType(type=UnknownType()) col_type: ColumnType = ColumnType(type=UnknownType())
if column.name in by_name:
column2 = by_name[column.name]
col_type2: ColumnType = column2.type
col_type = self._get_method_result(call, col_type1, col_type2, method) 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( new_column = DataFrameType.Column(
index=column.index, index=column.index,
@@ -145,10 +185,24 @@ class FrameMethodRegistry(MethodRegistry[Call]):
) )
) )
return DataFrameType(columns=new_columns) return DataFrameType(columns=new_columns), frame_operand
def _element_wise(self, call: Call, method: str) -> Type: def _element_wise(self, call: Call, method: str) -> Type:
# TODO: support scalar, sequence, Series, dict operand """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 # Build signature with new schema and generic operand
signature = Function( signature = Function(
params=ParamSpec( params=ParamSpec(
@@ -156,12 +210,12 @@ class FrameMethodRegistry(MethodRegistry[Call]):
Function.Parameter( Function.Parameter(
pos=0, pos=0,
name="other", name="other",
type=DataFrameType(columns=[]), type=TopType(),
required=True, required=True,
), ),
], ],
), ),
returns=self._element_binary_op(call, method), returns=returns,
) )
# Map arguments and compute result type # Map arguments and compute result type
@@ -171,13 +225,83 @@ class FrameMethodRegistry(MethodRegistry[Call]):
positional=call.positional, positional=call.positional,
keywords=call.keywords, keywords=call.keywords,
) )
if result.is_valid: if result.is_valid and frame_operand:
self._assert_same_length( self._assert_same_length(
call.call_expr, call.frame_expr, call.positional[0][0] call.call_expr, call.frame_expr, call.positional[0][0]
) )
return result.result 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__") @method("add", "__add__")
def add(self, call: Call) -> Type: def add(self, call: Call) -> Type:
return self._element_wise(call, "__add__") return self._element_wise(call, "__add__")
@@ -231,6 +355,16 @@ class FrameMethodRegistry(MethodRegistry[Call]):
return self._element_wise(call, "__eq__") return self._element_wise(call, "__eq__")
def _aggregate(self, call: Call, kwargs: list[Function.Parameter] = []) -> Type: 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( with_axis = Function(
params=ParamSpec( params=ParamSpec(
kw=[ kw=[
@@ -382,8 +516,144 @@ class FrameMethodRegistry(MethodRegistry[Call]):
) )
return result.result 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() @method()
def groupby(self, call: Call) -> Type: 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") bool_: Type = self.types.get_type("bool")
function: Function = Function( function: Function = Function(
params=ParamSpec( params=ParamSpec(
@@ -413,7 +683,7 @@ class FrameMethodRegistry(MethodRegistry[Call]):
) )
], ],
), ),
returns=FrameGroupBy(frame=call.frame), returns=FrameGroupBy(frame=new_frame),
) )
result: CallResult = self.dispatcher.get_result( result: CallResult = self.dispatcher.get_result(
@@ -425,6 +695,14 @@ class FrameMethodRegistry(MethodRegistry[Call]):
return result.result return result.result
def _assert_same_length(self, call_expr: p.Expr, frame1: p.Expr, frame2: p.Expr): 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__" func_name: str = "__midas_frame_same_length__"
# Efficiently compute length # Efficiently compute length

View File

@@ -24,6 +24,12 @@ if TYPE_CHECKING:
class _MethodRegistryMeta(type): 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]] = {} _methods: dict[str, Callable[..., Type]] = {}
def __new__( def __new__(
@@ -42,6 +48,11 @@ class _MethodRegistryMeta(type):
class MethodCall(Protocol): class MethodCall(Protocol):
"""A method call object
Must have at least `location`, `call_expr` and `subject` properties
"""
@property @property
def location(self) -> Location: ... def location(self) -> Location: ...
@@ -56,6 +67,8 @@ T = TypeVar("T", bound=MethodCall)
class MethodRegistry(Generic[T], metaclass=_MethodRegistryMeta): class MethodRegistry(Generic[T], metaclass=_MethodRegistryMeta):
"""A registry of methods"""
def __init__(self, typer: PythonTyper) -> None: def __init__(self, typer: PythonTyper) -> None:
self.typer: PythonTyper = typer self.typer: PythonTyper = typer
@@ -76,6 +89,15 @@ class MethodRegistry(Generic[T], metaclass=_MethodRegistryMeta):
return self.typer.assertions return self.typer.assertions
def call(self, method: str, call: T) -> Type: 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) func: Optional[Callable[[Self, T], Type]] = self._methods.get(method)
if func is None: if func is None:
self.reporter.warning( self.reporter.warning(
@@ -90,6 +112,13 @@ Method = Callable[[_Self, T], Type]
def method(*names: str) -> Callable[[Method[_Self, T]], Method[_Self, T]]: 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]: def wrapper(func: Method[_Self, T]) -> Method[_Self, T]:
names_: tuple[str, ...] = names names_: tuple[str, ...] = names
if len(names_) == 0: if len(names_) == 0:

View File

@@ -1,7 +1,6 @@
import logging import logging
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, final
import midas.ast.midas as m import midas.ast.midas as m
from midas.ast.location import Location from midas.ast.location import Location
@@ -14,11 +13,9 @@ 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 (
ColumnType, ColumnType,
ComplexType,
ConstraintType, ConstraintType,
DataFrameType, DataFrameType,
DerivedType, DerivedType,
ExtensionType,
Function, Function,
GenericType, GenericType,
ParamSpec, ParamSpec,
@@ -27,29 +24,13 @@ from midas.checker.types import (
TypeVar, TypeVar,
UnknownType, UnknownType,
) )
from midas.checker.variance import VarianceInferrer from midas.checker.variance import VarianceManager
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 ReturnException(Exception): @final
pass
@dataclass(frozen=True, kw_only=True)
class MappedArgument:
expr: m.Expr
type: Type
argument: Function.Parameter
@dataclass(frozen=True, kw_only=True)
class OverloadCandidate:
function: Function
mapped: list[MappedArgument]
class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], 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"""
@@ -76,10 +57,21 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
self._preamble: Environment = Preamble(self.types) self._preamble: Environment = Preamble(self.types)
def set_reporter(self, reporter: FileReporter): def set_reporter(self, reporter: FileReporter):
"""Set the file reporter to use for diagnostics
Args:
reporter (FileReporter): the file reporter
"""
self.reporter = reporter self.reporter = reporter
self.dispatcher.set_reporter(reporter) self.dispatcher.set_reporter(reporter)
def process(self, source: str, path: Optional[str]): def process(self, source: str, path: Optional[str]):
"""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) reporter: FileReporter = self.reporter.for_file(path)
self.set_reporter(reporter) self.set_reporter(reporter)
@@ -92,6 +84,14 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
self.resolve(stmts) self.resolve(stmts)
def type_of(self, expr: m.Expr) -> Type: 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) type: Type = expr.accept(self)
return type return type
@@ -111,7 +111,20 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], 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: def get_variable(self, location: Location, 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:
location (Location): the location of the variable reference
name (str): the name of the variable
Returns:
Type: the type of the variable
"""
if name in self._predicate_params: if name in self._predicate_params:
return self._predicate_params[name] return self._predicate_params[name]
predicate: Optional[Predicate] = self.types.lookup_predicate(name) predicate: Optional[Predicate] = self.types.lookup_predicate(name)
@@ -122,7 +135,8 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
if global_ is not None: if global_ is not None:
return global_ return global_
raise NameError(f"Unknown variable '{name}'") self.reporter.error(location, f"Unknown variable '{name}'")
return UnknownType()
def resolve(self, stmts: list[m.Stmt]): def resolve(self, stmts: list[m.Stmt]):
"""Process a sequence of statements """Process a sequence of statements
@@ -133,12 +147,15 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], 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(): manager: VarianceManager = VarianceManager(self.types)
if isinstance(type, GenericType): manager.infer_all()
inferrer = VarianceInferrer(self.types)
self.types._types[name] = inferrer.infer(type)
def assert_bool(self, expr: m.Expr): 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) type: Type = self.type_of(expr)
if not self.types.is_subtype(type, self._bool): if not self.types.is_subtype(type, self._bool):
self.reporter.error(expr.location, f"Must be a boolean but is {type}") self.reporter.error(expr.location, f"Must be a boolean but is {type}")
@@ -153,7 +170,10 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
type = GenericType(name=name, params=params, body=type) type = GenericType(name=name, params=params, body=type)
else: else:
type = DerivedType(name=name, type=type) type = DerivedType(name=name, type=type)
self.types.define_type(name, type) try:
self.types.define_type(name, type)
except ValueError:
self.reporter.error(stmt.location, f"Type {name} already defined")
self._local_variables.clear() self._local_variables.clear()
self._current_name = None self._current_name = None
@@ -161,7 +181,10 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
name: str = stmt.name.lexeme name: str = stmt.name.lexeme
self._current_name = name self._current_name = name
type: Type = stmt.type.accept(self) type: Type = stmt.type.accept(self)
self.types.define_type(name, type) try:
self.types.define_type(name, type)
except ValueError:
self.reporter.error(stmt.location, f"Type {name} already defined")
self._current_name = None self._current_name = None
def visit_member_stmt(self, stmt: m.MemberStmt) -> None: ... def visit_member_stmt(self, stmt: m.MemberStmt) -> None: ...
@@ -184,6 +207,7 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
) )
def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None: def visit_predicate_stmt(self, stmt: m.PredicateStmt) -> None:
name: str = stmt.name.lexeme
for spec in stmt.params: for spec in stmt.params:
for param in spec.mixed: for param in spec.mixed:
assert param.name is not None assert param.name is not None
@@ -205,16 +229,29 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
returns=type, returns=type,
) )
self._predicate_params = {} self._predicate_params = {}
self.types.define_predicate( try:
stmt.name.lexeme, self.types.define_predicate(
Predicate( name,
type=type, Predicate(
body=stmt.body, type=type,
alias=len(params) == 0, body=stmt.body,
), alias=len(params) == 0,
) ),
)
except ValueError:
self.reporter.error(stmt.location, f"Predicate {name} already defined")
def _is_valid_predicate(self, body: Type) -> bool: def _is_valid_predicate(self, body: Type) -> bool:
"""Check whether the given type is valid as a predicate's body
Accepted types are either subtypes of `bool` or valid predicates
Args:
body (Type): the potential predicate body
Returns:
bool: `True` if `body` can be a predicate body, `False` otherwise
"""
match body: match body:
case Function(returns=returns): case Function(returns=returns):
return self._is_valid_predicate(returns) return self._is_valid_predicate(returns)
@@ -240,7 +277,11 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
return self._visit_binary_expr(expr.location, expr.left, expr.right, method) return self._visit_binary_expr(expr.location, expr.left, expr.right, method)
def _visit_binary_expr( def _visit_binary_expr(
self, location: Location, left_expr: m.Expr, right_expr: m.Expr, method: str self,
location: Location,
left_expr: m.Expr,
right_expr: m.Expr,
method: str,
) -> Type: ) -> Type:
left: Type = self.type_of(left_expr) left: Type = self.type_of(left_expr)
right: Type = self.type_of(right_expr) right: Type = self.type_of(right_expr)
@@ -262,6 +303,14 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
return result.result return result.result
def visit_unary_expr(self, expr: m.UnaryExpr) -> Type: def visit_unary_expr(self, expr: m.UnaryExpr) -> Type:
# First evaluate operand to surface all errors
operand: Type = self.type_of(expr.right)
# 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) method: Optional[str] = MIDAS_UNARY_METHODS.get(expr.operator.type)
if method is None: if method is None:
self.logger.warning(f"Unsupported operator {expr.operator.lexeme}") self.logger.warning(f"Unsupported operator {expr.operator.lexeme}")
@@ -270,7 +319,6 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
) )
return UnknownType() return UnknownType()
operand: Type = self.type_of(expr.right)
operation: Optional[Type] = self.types.lookup_member(operand, method) operation: Optional[Type] = self.types.lookup_member(operand, method)
if operation is None: if operation is None:
self.reporter.error( self.reporter.error(
@@ -314,7 +362,7 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
return member return member
def visit_variable_expr(self, expr: m.VariableExpr) -> Type: def visit_variable_expr(self, expr: m.VariableExpr) -> Type:
return self.get_variable(expr.name.lexeme) return self.get_variable(expr.location, expr.name.lexeme)
def visit_grouping_expr(self, expr: m.GroupingExpr) -> Type: def visit_grouping_expr(self, expr: m.GroupingExpr) -> Type:
return expr.expr.accept(self) return expr.expr.accept(self)
@@ -329,12 +377,14 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], m.Type.Visitor[Type
return self.types.get_type("float") return self.types.get_type("float")
case str(): case str():
return self.types.get_type("str") return self.types.get_type("str")
case None:
return self.types.get_type("None")
case _: case _:
self.reporter.warning(expr.location, f"Unknown literal {expr}") self.reporter.warning(expr.location, f"Unknown literal {expr}")
return UnknownType() return UnknownType()
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> Type: def visit_wildcard_expr(self, expr: m.WildcardExpr) -> Type:
return self.get_variable("_") return self.get_variable(expr.location, "_")
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
@@ -348,6 +398,16 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], 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:
@@ -357,24 +417,21 @@ class MidasTyper(m.Stmt.Visitor[None], m.Expr.Visitor[Type], 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:
base_type: Type = type.type.accept(self)
self._predicate_params["_"] = base_type
constraint_type: Type = self.type_of(type.constraint)
self._predicate_params = {}
if not self.types.is_subtype(constraint_type, self._bool):
self.reporter.error(
type.constraint.location,
f"Constraint must evaluate to a boolean, got {constraint_type}",
)
return ConstraintType( return ConstraintType(
type=type.type.accept(self), type=base_type,
constraint=type.constraint, constraint=type.constraint,
) )
def visit_complex_type(self, type: m.ComplexType) -> ComplexType:
return ComplexType(
members={
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:
return Function( return Function(
params=self._visit_param_spec(type.params), params=self._visit_param_spec(type.params),

View File

@@ -23,11 +23,14 @@ 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._python_funcs: dict[str, Callable[..., Any]] = {}
self.define("__name__", self._types.get_type("str"))
self._def_type_constructor("object", object) self._def_type_constructor("object", object)
self._def_type_constructor("float", float) self._def_type_constructor("float", float)
self._def_type_constructor("int", int) self._def_type_constructor("int", int)

View File

@@ -1,7 +1,6 @@
import ast import ast
import logging import logging
from dataclasses import dataclass from typing import Any, Optional, final
from typing import Any, Optional
import midas.ast.python as p import midas.ast.python as p
from midas.ast.location import Location from midas.ast.location import Location
@@ -49,26 +48,16 @@ TypedExpr = tuple[p.Expr, Type]
class ReturnException(Exception): class ReturnException(Exception):
pass def __init__(self, stmt: p.Stmt):
super().__init__()
self.stmt: p.Stmt = stmt
class UndefinedMethodException(Exception): class UndefinedMethodException(Exception):
pass pass
@dataclass(frozen=True, kw_only=True) @final
class MappedArgument:
expr: p.Expr
type: Type
argument: Function.Parameter
@dataclass(frozen=True, kw_only=True)
class OverloadCandidate:
function: Function
mapped: list[MappedArgument]
class PythonTyper( class PythonTyper(
p.Stmt.Visitor[None], p.Stmt.Visitor[None],
p.Expr.Visitor[Type], p.Expr.Visitor[Type],
@@ -97,17 +86,31 @@ class PythonTyper(
self.assertions: AssertionCollector = AssertionCollector() self.assertions: AssertionCollector = AssertionCollector()
def set_reporter(self, reporter: FileReporter): def set_reporter(self, reporter: FileReporter):
"""Set the file reporter to use for diagnostics
Args:
reporter (FileReporter): the file reporter
"""
self.reporter = reporter self.reporter = reporter
self.dispatcher.set_reporter(self.reporter) self.dispatcher.set_reporter(self.reporter)
def process(self, source: str, path: Optional[str]) -> TypedAST: def process(self, source: str, path: Optional[str]) -> TypedAST:
"""Process some Python source code
Args:
source (str): the Python source code
path (Optional[str]): the path of the source file, if known
Returns:
TypedAST: all generated typechecking information
"""
reporter: FileReporter = self.reporter.for_file(path) reporter: FileReporter = self.reporter.for_file(path)
self.set_reporter(reporter) self.set_reporter(reporter)
tree: ast.Module = ast.parse(source, filename=path or "<unknown>") tree: ast.Module = ast.parse(source, filename=path or "<unknown>")
parser = PythonParser() parser = PythonParser()
stmts: list[p.Stmt] = parser.parse_module(tree) stmts: list[p.Stmt] = parser.parse_module(tree)
resolver = Resolver() resolver = Resolver(reporter)
resolver.resolve(*stmts) resolver.resolve(*stmts)
self.env = self.global_env self.env = self.global_env
@@ -115,7 +118,10 @@ class PythonTyper(
self.judgements = [] self.judgements = []
self.evaluated_casts = [] self.evaluated_casts = []
self.check(stmts) try:
self.check(stmts)
except ReturnException as e:
self.reporter.error(e.stmt.location, "Return statement outside of function")
return TypedAST( return TypedAST(
stmts=stmts, stmts=stmts,
@@ -125,7 +131,7 @@ class PythonTyper(
) )
def judge(self, expr: p.Expr, type: Type): def judge(self, expr: p.Expr, type: Type):
"""Record a typing judgement """Record a typing judgement for the given expression
Args: Args:
expr (p.Expr): the judged expression expr (p.Expr): the judged expression
@@ -134,7 +140,7 @@ class PythonTyper(
self.judgements.append((expr, type)) self.judgements.append((expr, type))
def compute_type(self, expr: p.Expr) -> Type: def compute_type(self, expr: p.Expr) -> Type:
"""Evaluate the type of an expression """Evaluate the type of the given expression
Args: Args:
expr (p.Expr): the expression to type expr (p.Expr): the expression to type
@@ -145,7 +151,7 @@ class PythonTyper(
return expr.accept(self) return expr.accept(self)
def type_of(self, expr: p.Expr) -> Type: def type_of(self, expr: p.Expr) -> Type:
"""Evaluate the type of an expression and record the judgement """Evaluate the type of the given expression and record the judgement
Args: Args:
expr (p.Expr): the expression to evaluate expr (p.Expr): the expression to evaluate
@@ -158,9 +164,22 @@ class PythonTyper(
return type return type
def resolve_type_expr(self, expr: p.MidasType) -> Type: def resolve_type_expr(self, expr: p.MidasType) -> Type:
"""Resolve the type of a type expression (annotation)
Args:
expr (p.MidasType): the type expression
Returns:
Type: the resolved type
"""
return expr.accept(self) return expr.accept(self)
def process_stmt(self, stmt: p.Stmt) -> None: def process_stmt(self, stmt: p.Stmt) -> None:
"""Type check the given statement
Args:
stmt (p.Stmt): the statement to type-check
"""
stmt.accept(self) stmt.accept(self)
def process_block(self, block: list[p.Stmt], env: Environment) -> bool: def process_block(self, block: list[p.Stmt], env: Environment) -> bool:
@@ -224,8 +243,29 @@ class PythonTyper(
positional: list[TypedExpr], positional: list[TypedExpr],
keywords: dict[str, TypedExpr], keywords: dict[str, TypedExpr],
) -> Type: ) -> Type:
"""Evaluate a method call on an object
Calls to dataframes and columns types are delegated to the appropriate manager
Args:
location (Location): the location of the call
call_expr (p.Expr): the call expression
obj (TypedExpr): the object on which the method is called
method_name (str): the method name
positional (list[TypedExpr]): the list of positional arguments
keywords (dict[str, TypedExpr]): the map of keyword arguments
Raises:
UndefinedMethodException: if the method is not defined
Returns:
Type: the return type of the call
"""
unfolded: Type = unfold_type(obj[1]) unfolded: Type = unfold_type(obj[1])
match unfolded: match unfolded:
case TopType() | UnknownType():
return UnknownType()
case DataFrameType(): case DataFrameType():
return self.frame_mgr.call( return self.frame_mgr.call(
method=method_name, method=method_name,
@@ -283,6 +323,15 @@ class PythonTyper(
return result.result return result.result
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`
Args:
type1 (Type): the potential "subtype"
type2 (Type): the potential "supertype"
Returns:
bool: whether `type1` is a subtype of `type2`
"""
return self.types.is_subtype(type1, type2) return self.types.is_subtype(type1, type2)
def visit_expression_stmt(self, stmt: p.ExpressionStmt) -> None: def visit_expression_stmt(self, stmt: p.ExpressionStmt) -> None:
@@ -295,10 +344,23 @@ class PythonTyper(
kw: list[Function.Parameter] = [] kw: list[Function.Parameter] = []
def eval_param_type(param: p.Function.Parameter) -> Type: def eval_param_type(param: p.Function.Parameter) -> Type:
if param.type is not None: default_type: Optional[Type] = None
return self.resolve_type_expr(param.type)
if param.default is not None: if param.default is not None:
return self.type_of(param.default) default_type = self.type_of(param.default)
if param.type is not None:
param_type: Type = self.resolve_type_expr(param.type)
if default_type is not None:
if not self.types.is_subtype(default_type, param_type):
self.reporter.error(
param.location or stmt.location,
f"Cannot use default value of type {default_type} for parameter of type {param_type}",
)
return param_type
if default_type is not None:
return default_type
return UnknownType() return UnknownType()
position: int = 0 position: int = 0
@@ -398,7 +460,6 @@ class PythonTyper(
self.env.define(stmt.name, function) self.env.define(stmt.name, function)
def visit_type_assign(self, stmt: p.TypeAssign) -> None: def visit_type_assign(self, stmt: p.TypeAssign) -> None:
# TODO check not yet defined locally
type: Type = self.resolve_type_expr(stmt.type) type: Type = self.resolve_type_expr(stmt.type)
self.env.define(stmt.name, type) self.env.define(stmt.name, type)
@@ -408,6 +469,15 @@ class PythonTyper(
self._assign(stmt.location, target, value_type) self._assign(stmt.location, target, value_type)
def _assign(self, location: Location, target: p.Expr, value_type: Type): def _assign(self, location: Location, target: p.Expr, value_type: Type):
"""Handle an assignment to the given target
Delegate to the appropriate method according to the target type
Args:
location (Location): the location of the assignment
target (p.Expr): the assignment's target
value_type (Type): the value to be assigned
"""
match target: match target:
case p.VariableExpr(): case p.VariableExpr():
self._assign_var(location, target, value_type) self._assign_var(location, target, value_type)
@@ -422,13 +492,19 @@ class PythonTyper(
self._assign_sub(location, var, index, value_type) self._assign_sub(location, var, index, value_type)
case _: case _:
if not isinstance(target, p.VariableExpr): self.logger.warning(f"Unsupported assignment to {target}")
self.logger.warning(f"Unsupported assignment to {target}") self.reporter.warning(
self.reporter.warning( target.location, f"Unsupported assignment to {target}"
target.location, f"Unsupported assignment to {target}" )
)
def _assign_var(self, location: Location, target: p.VariableExpr, value_type: Type): def _assign_var(self, location: Location, target: p.VariableExpr, value_type: Type):
"""Type check assignment to the given target
Args:
location (Location): the location of the assignment
target (p.VariableExpr): the assignment's target
value_type (Type): the value to be assigned
"""
name: str = target.name name: str = target.name
var_type: Optional[Type] = self.look_up_variable(name, target) var_type: Optional[Type] = self.look_up_variable(name, target)
@@ -447,6 +523,14 @@ class PythonTyper(
def _assign_attr( def _assign_attr(
self, location: Location, object: p.Expr, name: str, value_type: Type self, location: Location, object: p.Expr, name: str, value_type: Type
): ):
"""Type check assignment to the given attribute target
Args:
location (Location): the location of the assignment
object (p.Expr): the target attribute's owner object
name (str): the target attribute's name
value_type (Type): the value to be assigned
"""
object_type: Type = self.type_of(object) object_type: Type = self.type_of(object)
member: Optional[Type] = self.types.lookup_member(object_type, name) member: Optional[Type] = self.types.lookup_member(object_type, name)
if member is None: if member is None:
@@ -466,6 +550,17 @@ class PythonTyper(
index: p.Expr, index: p.Expr,
value_type: Type, value_type: Type,
): ):
"""Type check assignment to the given subscript target
Args:
location (Location): the location of the assignment
var (p.VariableExpr): the target subscript's owner. We only allow
a variable expression here because we might modify its type (for
example when assigning a column to a dataframe) and reference
types are not implemented
index (p.Expr): the target subscript's index expression
value_type (Type): the value to be assigned
"""
var_type: Type = self.type_of(var) var_type: Type = self.type_of(var)
unfolded_type: Type = unfold_type(var_type) unfolded_type: Type = unfold_type(var_type)
# TODO: what happens if type is an alias of a dataframe type # TODO: what happens if type is an alias of a dataframe type
@@ -486,7 +581,7 @@ class PythonTyper(
def visit_return_stmt(self, stmt: p.ReturnStmt) -> None: def visit_return_stmt(self, stmt: p.ReturnStmt) -> None:
type: Type = self.type_of(stmt.value) if stmt.value is not None else UnitType() type: Type = self.type_of(stmt.value) if stmt.value is not None else UnitType()
self.env.return_types.append(type) self.env.return_types.append(type)
raise ReturnException() raise ReturnException(stmt)
def visit_if_stmt(self, stmt: p.IfStmt) -> None: def visit_if_stmt(self, stmt: p.IfStmt) -> None:
# Not evaluated in sub-environment because assignments in the test leak out of the if # Not evaluated in sub-environment because assignments in the test leak out of the if
@@ -509,12 +604,16 @@ class PythonTyper(
else_returned: bool = self.process_block(stmt.orelse, env) else_returned: bool = self.process_block(stmt.orelse, env)
self.env.return_types.extend(env.return_types) self.env.return_types.extend(env.return_types)
if body_returned and else_returned: if body_returned and else_returned:
raise ReturnException() raise ReturnException(stmt)
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:
outer_env: Environment = self.env
inner_env: Environment = Environment(self.env)
self.env = inner_env
item_type: Type = UnknownType() item_type: Type = UnknownType()
iterator_type: Type = self.type_of(stmt.iterator) iterator_type: Type = self.type_of(stmt.iterator)
if iterator_type != UnknownType(): if iterator_type != UnknownType():
@@ -528,10 +627,29 @@ class PythonTyper(
self._assign(stmt.location, stmt.target, item_type) self._assign(stmt.location, stmt.target, item_type)
self.judge(stmt.target, item_type) self.judge(stmt.target, item_type)
env: Environment = Environment(self.env) body_returned: bool = self.process_block(stmt.body, inner_env)
body_returned: bool = self.process_block(stmt.body, env)
self.env = outer_env
if body_returned: if body_returned:
raise ReturnException() raise ReturnException(stmt)
def visit_import_stmt(self, stmt: p.ImportStmt) -> None:
self._visit_imports(stmt.location, stmt.imports)
def visit_from_import_stmt(self, stmt: p.FromImportStmt) -> None:
self._visit_imports(stmt.location, stmt.imports)
def _visit_imports(self, location: Location, imports: list[p.ImportAlias]) -> None:
for import_ in imports:
self._assign_var(
location,
p.VariableExpr(
name=import_.imported_name,
location=import_.location,
),
UnknownType(),
)
def visit_raw_stmt(self, stmt: p.RawStmt) -> None: def visit_raw_stmt(self, stmt: p.RawStmt) -> None:
pass pass
@@ -582,6 +700,20 @@ class PythonTyper(
right: TypedExpr, right: TypedExpr,
method: str, method: str,
) -> Type: ) -> Type:
"""Compute the result type of a binary operation method call
This method is called for dunder methods called by binary operators
Args:
location (Location): the location of the operation
expr (p.Expr): the expression which triggered this resolution
left (TypedExpr): the left operand
right (TypedExpr): the right operand
method (str): the method name
Returns:
Type: the result type
"""
try: try:
return self.call_method( return self.call_method(
location=location, location=location,
@@ -599,6 +731,11 @@ class PythonTyper(
return UnknownType() return UnknownType()
def visit_unary_expr(self, expr: p.UnaryExpr) -> Type: def visit_unary_expr(self, expr: p.UnaryExpr) -> Type:
# Special case because there is no __not__ dunder method
match expr.operator:
case ast.Not():
return self.types.get_type("bool")
method: Optional[str] = PY_UNARY_METHODS.get(expr.operator.__class__) method: Optional[str] = PY_UNARY_METHODS.get(expr.operator.__class__)
if method is None: if method is None:
self.logger.warning(f"Unsupported operator {expr.operator}") self.logger.warning(f"Unsupported operator {expr.operator}")
@@ -640,14 +777,21 @@ class PythonTyper(
match expr.callee: match expr.callee:
case p.GetExpr(object=obj, name=method): case p.GetExpr(object=obj, name=method):
obj_type: Type = self.type_of(obj) obj_type: Type = self.type_of(obj)
return self.call_method( try:
location=expr.location, return self.call_method(
call_expr=expr, location=expr.location,
obj=(obj, obj_type), call_expr=expr,
method_name=method, obj=(obj, obj_type),
positional=positional, method_name=method,
keywords=keywords, positional=positional,
) keywords=keywords,
)
except UndefinedMethodException:
self.reporter.error(
expr.location,
f"Unknown method {method} on type {obj_type}",
)
return UnknownType()
callee: Type = self.type_of(expr.callee) callee: Type = self.type_of(expr.callee)
result: CallResult = self.dispatcher.get_result( result: CallResult = self.dispatcher.get_result(
@@ -730,7 +874,7 @@ class PythonTyper(
def visit_ternary_expr(self, expr: p.TernaryExpr) -> Type: def visit_ternary_expr(self, expr: p.TernaryExpr) -> Type:
test_type: Type = self.type_of(expr.test) test_type: Type = self.type_of(expr.test)
# TODO Allow subtypes or any type # Strict: test must be a subtype of bool, or UnknownType
if ( if (
not self.is_subtype(test_type, self.types.get_type("bool")) not self.is_subtype(test_type, self.types.get_type("bool"))
and test_type != UnknownType() and test_type != UnknownType()
@@ -819,6 +963,8 @@ class PythonTyper(
return self._visit_frame_subscript(unfolded, expr) return self._visit_frame_subscript(unfolded, expr)
case FrameGroupBy(): case FrameGroupBy():
return self._visit_frame_groupby_subscript(unfolded, expr) return self._visit_frame_groupby_subscript(unfolded, expr)
case ColumnType():
return self._visit_column_subscript(unfolded, expr)
operation: Optional[Type] = self.types.lookup_member(object, "__getitem__") operation: Optional[Type] = self.types.lookup_member(object, "__getitem__")
if operation is None: if operation is None:
@@ -849,6 +995,15 @@ class PythonTyper(
return UnknownType() return UnknownType()
def visit_base_type(self, node: p.BaseType) -> Type: def visit_base_type(self, node: p.BaseType) -> Type:
if node.base == "Column":
if len(node.args) != 1:
self.reporter.error(
node.location,
f"Column requires 1 type argument, {len(node.args)} provided",
)
return ColumnType(type=UnknownType())
return ColumnType(type=self.resolve_type_expr(node.args[0]))
base: Type base: Type
try: try:
base = self.types.get_type(node.base) base = self.types.get_type(node.base)
@@ -858,13 +1013,13 @@ class PythonTyper(
if len(node.args) != 0: if len(node.args) != 0:
args: list[Type] = [self.resolve_type_expr(arg) for arg in node.args] args: list[Type] = [self.resolve_type_expr(arg) for arg in node.args]
return self.types.apply_generic(base, args) try:
return self.types.apply_generic(base, args)
except Exception as e:
self.reporter.error(node.location, f"Cannot apply generic type: {e}")
return UnknownType()
return base return base
def visit_constraint_type(self, node: p.ConstraintType) -> Type:
self.reporter.warning(node.location, "ConstraintType not yet supported")
return UnknownType()
def visit_frame_column(self, node: p.FrameColumn) -> ColumnType: def visit_frame_column(self, node: p.FrameColumn) -> ColumnType:
return ColumnType( return ColumnType(
type=( type=(
@@ -887,6 +1042,15 @@ class PythonTyper(
) )
def _get_iterator_type(self, expr: p.Expr, type: Type) -> Optional[Type]: def _get_iterator_type(self, expr: p.Expr, type: Type) -> Optional[Type]:
"""Get the item type of an iterator type
Args:
expr (p.Expr): the iterator expression
type (Type): the iterator type
Returns:
Optional[Type]: the item type, or `None` if it cannot be determined
"""
# TODO: lookup __iter__ # TODO: lookup __iter__
getitem: Optional[Type] = self.types.lookup_member(type, "__getitem__") getitem: Optional[Type] = self.types.lookup_member(type, "__getitem__")
if getitem is None: if getitem is None:
@@ -906,6 +1070,16 @@ class PythonTyper(
return result.result return result.result
def define_typevar(self, call: p.CallExpr) -> Optional[TypeVar]: def define_typevar(self, call: p.CallExpr) -> Optional[TypeVar]:
"""Define a type variable from a call to `typing.TypeVar`
Args:
call (p.CallExpr): the call to `typing.TypeVar`
Returns:
Optional[TypeVar]: the define type variable, or `None` if the call
is invalid
"""
def is_kw_true(name: str) -> bool: def is_kw_true(name: str) -> bool:
match call.keywords.get(name): match call.keywords.get(name):
case p.LiteralExpr(value=True): case p.LiteralExpr(value=True):
@@ -920,10 +1094,13 @@ class PythonTyper(
bound: Optional[Type] = None bound: Optional[Type] = None
variance: Variance = Variance.INVARIANT variance: Variance = Variance.INVARIANT
if "bound" in call.keywords: if "bound" in call.keywords:
bound_type: p.MidasType = self._parse_type_from_expr( try:
call.keywords["bound"] bound_type: p.MidasType = self._parse_type_from_expr(
) call.keywords["bound"]
bound = self.resolve_type_expr(bound_type) )
bound = self.resolve_type_expr(bound_type)
except NotImplementedError:
bound = UnknownType()
if is_kw_true("covariant"): if is_kw_true("covariant"):
variance = Variance.COVARIANT variance = Variance.COVARIANT
@@ -938,7 +1115,13 @@ class PythonTyper(
else: else:
variance = Variance.CONTRAVARIANT variance = Variance.CONTRAVARIANT
var: TypeVar = TypeVar(name=name, bound=bound, variance=variance) var: TypeVar = TypeVar(name=name, bound=bound, variance=variance)
self.types.define_type(name, var) try:
self.types.define_type(name, var)
except ValueError:
self.reporter.error(
call.location,
f"A type or type variable with the name {name} is already defined",
)
return var return var
case _: case _:
@@ -948,6 +1131,19 @@ class PythonTyper(
return None return None
def _parse_type_from_expr(self, expr: p.Expr) -> p.MidasType: def _parse_type_from_expr(self, expr: p.Expr) -> p.MidasType:
"""Parse a type expression from a raw expression
This is useful for expressions inside a `TypeVar`'s `bound` parameter
Args:
expr (p.Expr): the expression to parse
Raises:
NotImplementedError: if the expression is not supported
Returns:
p.MidasType: the parsed type node
"""
location: Location = expr.location location: Location = expr.location
parser = PythonParser() parser = PythonParser()
match expr: match expr:
@@ -956,10 +1152,28 @@ class PythonTyper(
return parser._parse_type(node.body) return parser._parse_type(node.body)
case p.VariableExpr(name=name): case p.VariableExpr(name=name):
return p.BaseType(location=location, base=name, args=()) return p.BaseType(location=location, base=name, args=())
case p.SubscriptExpr(object=p.VariableExpr(name=name), index=arg):
args: tuple[p.MidasType, ...] = (
tuple(self._parse_type_from_expr(a) for a in arg.items)
if isinstance(arg, p.TupleExpr)
else (self._parse_type_from_expr(arg),)
)
return p.BaseType(location=location, base=name, args=args)
case _: case _:
raise NotImplementedError raise NotImplementedError
def _get_literal(self, expr: p.Expr) -> tuple[bool, Any]: def _get_literal(self, expr: p.Expr) -> tuple[bool, Any]:
"""Get the literal value of a literal-like expression
Args:
expr (p.Expr): the expression
Returns:
tuple[bool, Any]: a tuple containing a boolean indicating whether
the given expression is literal-like, and the literal value (or
`None` if the first value is `False`)
"""
match expr: match expr:
case p.LiteralExpr(value=value): case p.LiteralExpr(value=value):
return True, value return True, value
@@ -987,8 +1201,9 @@ class PythonTyper(
return False, None return False, None
if key is None: if key is None:
# TODO: check that value is always a dict # If literal value is not a dict, invalid Python -> abort
assert isinstance(value_val, dict) if not isinstance(value_val, dict):
return False, None
pairs.extend(value_val.items()) pairs.extend(value_val.items())
else: else:
pairs.append((key_val, value_val)) pairs.append((key_val, value_val))
@@ -1016,6 +1231,17 @@ class PythonTyper(
def _evaluate_cast_statically( def _evaluate_cast_statically(
self, expr: p.CastExpr, subject_type: Type, target_type: Type, lit_value: Any self, expr: p.CastExpr, subject_type: Type, target_type: Type, lit_value: Any
) -> bool: ) -> bool:
"""Evaluate the given cast expression statically
Args:
expr (p.CastExpr): the cast expression
subject_type (Type): the subject type being casted
target_type (Type): the target type to which the expression is casted
lit_value (Any): the literal value of the expression
Returns:
bool: whether the cast expression could be evaluated successfully
"""
match target_type: match target_type:
case TopType(): case TopType():
return True return True
@@ -1103,9 +1329,7 @@ class PythonTyper(
case BaseType(): case BaseType():
# TODO: do we want to allow cast(float, int)? would require runtime conversion # TODO: do we want to allow cast(float, int)? would require runtime conversion
if not self.types.is_subtype( if not self.types.are_equivalent(subject_type, target_type):
subject_type, target_type
) or not self.types.is_subtype(target_type, subject_type):
self.reporter.error( self.reporter.error(
expr.location, expr.location,
f"Value {lit_value!r} of type {subject_type} cannot be cast as {target_type}", f"Value {lit_value!r} of type {subject_type} cannot be cast as {target_type}",
@@ -1151,3 +1375,14 @@ class PythonTyper(
return self.frame_mgr.groupby_get( return self.frame_mgr.groupby_get(
self.reporter, expr.location, groupby, expr.index self.reporter, expr.location, groupby, expr.index
) )
def _visit_column_subscript(
self, column: ColumnType, expr: p.SubscriptExpr
) -> Type:
index_type: Type = self.type_of(expr.index)
return self.column_mgr.get(
self.reporter,
expr.location,
column,
(expr.index, index_type),
)

View File

@@ -1,6 +1,6 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional, TypeAlias
from midas.ast.midas import MemberKind from midas.ast.midas import MemberKind
from midas.checker.builtins import BUILTIN_SUBTYPES from midas.checker.builtins import BUILTIN_SUBTYPES
@@ -8,11 +8,9 @@ from midas.checker.types import (
AppliedType, AppliedType,
BaseType, BaseType,
ColumnType, ColumnType,
ComplexType,
ConstraintType, ConstraintType,
DataFrameType, DataFrameType,
DerivedType, DerivedType,
ExtensionType,
Function, Function,
GenericType, GenericType,
OverloadedFunction, OverloadedFunction,
@@ -26,14 +24,20 @@ from midas.checker.types import (
substitute_typevars, substitute_typevars,
) )
Match: TypeAlias = tuple[Function.Parameter, Function.Parameter]
@dataclass @dataclass
class Member: class Member:
"""A member of a type (property or method)"""
kind: MemberKind kind: MemberKind
type: Type 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] = {}
@@ -81,6 +85,25 @@ class TypesRegistry:
member_type: Type, member_type: Type,
kind: MemberKind, kind: MemberKind,
): ):
"""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, {}) members: dict[str, Member] = self._members.setdefault(type_name, {})
if member_name in members: if member_name in members:
current: Member = members[member_name] current: Member = members[member_name]
@@ -109,11 +132,29 @@ class TypesRegistry:
members[member_name] = Member(kind=kind, type=member_type) members[member_name] = Member(kind=kind, type=member_type)
def define_predicate(self, name: str, predicate: Predicate): 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: if name in self._predicates:
raise ValueError(f"Predicate {name} already defined") raise ValueError(f"Predicate {name} already defined")
self._predicates[name] = predicate self._predicates[name] = predicate
def is_builtin_subtype(self, name1: str, name2: str) -> bool: 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()) subtypes: set[str] = BUILTIN_SUBTYPES.get(name2, set())
if name1 in subtypes: if name1 in subtypes:
return True return True
@@ -161,14 +202,6 @@ class TypesRegistry:
case (BaseType(name=name1), BaseType(name=name2)): case (BaseType(name=name1), BaseType(name=name2)):
return self.is_builtin_subtype(name1, name2) return self.is_builtin_subtype(name1, name2)
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
case (DataFrameType(columns=columns1), DataFrameType(columns=columns2)): case (DataFrameType(columns=columns1), DataFrameType(columns=columns2)):
# TODO: check order? # TODO: check order?
by_name1: dict[str, DataFrameType.Column] = { by_name1: dict[str, DataFrameType.Column] = {
@@ -182,7 +215,6 @@ class TypesRegistry:
return True return True
case (ColumnType(type=inner1), ColumnType(type=inner2)): case (ColumnType(type=inner1), ColumnType(type=inner2)):
# TODO: invariant, replace ColumnType with simple GenericType
if not self.are_equivalent(inner1, inner2): if not self.are_equivalent(inner1, inner2):
return False return False
return True return True
@@ -218,9 +250,17 @@ class TypesRegistry:
return False return False
def are_equivalent(self, type1: Type, type2: Type) -> bool: 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) return self.is_subtype(type1, type2) and self.is_subtype(type2, type1)
# 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
@@ -231,14 +271,24 @@ class TypesRegistry:
Returns: Returns:
bool: whether `func1` is a subtype of `func2` bool: whether `func1` is a subtype of `func2`
""" """
# Let func1 = (S1, R1) where S1 = (P1, M1, K1)
# Let func2 = (S2, R2) where S2 = (P2, M2, K2)
# We want to check that func1 <: func2
# i.e. R1 <: R2 and S2 <: S1
# R1 <: R2
if not self.is_subtype(func1.returns, func2.returns): if not self.is_subtype(func1.returns, func2.returns):
return False return False
# Extract P1, M1, K1
pos1: list[Function.Parameter] = func1.params.pos pos1: list[Function.Parameter] = func1.params.pos
mixed1: list[Function.Parameter] = func1.params.mixed mixed1: list[Function.Parameter] = func1.params.mixed
kw1: dict[str, Function.Parameter] = { kw1: dict[str, Function.Parameter] = {
param.name: param for param in func1.params.kw param.name: param for param in func1.params.kw
} }
# Extract P2, M2, K2
pos2: list[Function.Parameter] = func2.params.pos pos2: list[Function.Parameter] = func2.params.pos
mixed2: list[Function.Parameter] = func2.params.mixed mixed2: list[Function.Parameter] = func2.params.mixed
kw2: dict[str, Function.Parameter] = { kw2: dict[str, Function.Parameter] = {
@@ -246,94 +296,135 @@ class TypesRegistry:
} }
mixed_by_pos: dict[int, Function.Parameter] = { mixed_by_pos: dict[int, Function.Parameter] = {
param.pos: param for param in mixed2 param.pos: param for param in mixed1
} }
mixed_by_name: dict[str, Function.Parameter] = { mixed_by_name: dict[str, Function.Parameter] = {
param.name: param for param in mixed2 param.name: param for param in mixed1
} }
def is_arg_subtype(sub: Function.Parameter, sup: Function.Parameter) -> bool: matches: list[Match] = []
if not self.is_subtype(sub.type, sup.type):
return False
if not sup.required and sub.required:
return False
return True
for param1 in pos1: # Each parameter at position i in P2 must be valid at position i in S1
param2: Function.Parameter # either as a positional-only parameter in P1
if param1.pos < len(pos2): # or a mixed parameter in M1
param2 = pos2[param1.pos]
elif param1.pos in mixed_by_pos:
param2 = mixed_by_pos[param1.pos]
elif not param1.required:
continue
else:
return False
if not is_arg_subtype(param2, param1):
return False
for name, param1 in kw1.items():
param2: Function.Parameter
if name in kw2:
param2 = kw2[name]
elif name in mixed_by_name:
param2 = mixed_by_name[name]
elif not param1.required:
continue
else:
return False
if not is_arg_subtype(param2, param1):
return False
for param1 in mixed1:
pos_param2: Optional[Function.Parameter] = None
kw_param2: Optional[Function.Parameter] = None
if param1.name in kw2:
kw_param2 = kw2[param1.name]
elif param1.name in mixed_by_name:
kw_param2 = mixed_by_name[param1.name]
if param1.pos < len(pos2):
pos_param2 = pos2[param1.pos]
elif param1.pos in mixed_by_pos:
pos_param2 = mixed_by_pos[param1.pos]
# No match in func2 and arg is required
if pos_param2 is None and kw_param2 is None and param1.required:
return False
# Matching keyword argument
if kw_param2 is not None and not is_arg_subtype(kw_param2, param1):
return False
# Matching positional argument
if pos_param2 is not None and not is_arg_subtype(pos_param2, param1):
return False
mixed_positions: set[int] = {param.pos for param in mixed1}
mixed_names: set[str] = {param.name for param in mixed1}
for param2 in pos2: for param2 in pos2:
if not param2.required: param1: Function.Parameter
continue
if param2.pos >= len(pos1) and param2.pos not in mixed_positions: # In P1
if param2.pos < len(pos1):
param1 = pos1[param2.pos]
# In M1
elif param2.pos in mixed_by_pos:
param1 = mixed_by_pos[param2.pos]
else:
return False return False
# not req(p2) => not req(p1)
if not param2.required and param1.required:
return False
matches.append((param1, param2))
# Each parameter named p in K2 must be valid with name p in S1
# either as a keyword-only parameter in K1
# or a mixed parameter in M1
for name, param2 in kw2.items(): for name, param2 in kw2.items():
if not param2.required: param1: Function.Parameter
continue
if name not in kw1 and name not in mixed_names: # In K1
if name in kw1:
param1 = kw1[name]
# in M1
elif name in mixed_by_name:
param1 = mixed_by_name[name]
else:
return False return False
# not req(p2) => not req(p1)
if not param2.required and param1.required:
return False
matches.append((param1, param2))
# Each parameter named p at position i in M2 must be valid with name p
# in S1 *and* at position i in S1
# either as a single mixed parameter in M1
# or split into a positional parameter in P1/M1
# and a keyword parameter in K1/M1
for param2 in mixed2: for param2 in mixed2:
if param2.required: pos_param1: Optional[Function.Parameter] = None
continue kw_param1: Optional[Function.Parameter] = None
pos_match: bool = param2.pos < len(pos1) or param2.pos in mixed_positions
kw_match: bool = param2.name in kw1 or param2.name in mixed_names # By name in K1
if not pos_match or not kw_match: if param2.name in kw1:
kw_param1 = kw1[param2.name]
# By name in M1
elif param2.name in mixed_by_name:
kw_param1 = mixed_by_name[param2.name]
# By pos in P1
if param2.pos < len(pos1):
pos_param1 = pos1[param2.pos]
# By pos in M1
elif param2.pos in mixed_by_pos:
pos_param1 = mixed_by_pos[param2.pos]
# Not fully covered
if pos_param1 is None or kw_param1 is None:
return False
# Covered by unique mixed parameter in M1
if pos_param1 == kw_param1:
param1: Function.Parameter = pos_param1
# not req(p2) => not req(p1)
if not param2.required and param1.required:
return False
matches.append((param1, param2))
else:
# not req(p1)
if pos_param1.required or kw_param1.required:
return False
matches.append((pos_param1, param2))
matches.append((kw_param1, param2))
def is_matched(param: Function.Parameter) -> bool:
for p1, _ in matches:
if p1 == param:
return True
return False
all_params1: list[Function.Parameter] = pos1 + mixed1 + list(kw1.values())
for param1 in all_params1:
# No new required parameters
if not is_matched(param1) and param1.required:
return False
for param1, param2 in matches:
if not self.is_subtype(param2.type, param1.type):
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 DerivedType(name=name, type=base): case DerivedType(name=name, type=base):
return DerivedType(name=name, type=self.apply_generic(base, args)) return DerivedType(name=name, type=self.apply_generic(base, args))
@@ -399,6 +490,19 @@ 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:
@@ -431,20 +535,6 @@ 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):
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)
case ConstraintType(type=base): case ConstraintType(type=base):
return self.lookup_member(base, member_name) return self.lookup_member(base, member_name)
@@ -459,18 +549,54 @@ class TypesRegistry:
return None return None
def lookup_predicate(self, name: str) -> Optional[Predicate]: 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) return self._predicates.get(name)
def _by_name_or_type(self, name_or_type: str | Type) -> Type: 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): if isinstance(name_or_type, str):
return self.get_type(name_or_type) return self.get_type(name_or_type)
return name_or_type return name_or_type
def list_of(self, item_type: str | Type) -> 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") list_ = self.get_type("list")
return self.apply_generic(list_, [self._by_name_or_type(item_type)]) return self.apply_generic(list_, [self._by_name_or_type(item_type)])
def tuple_of(self, *item_types: str | Type) -> 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") tuple_ = self.get_type("tuple")
return self.apply_generic( return self.apply_generic(
tuple_, tuple_,
@@ -478,6 +604,15 @@ class TypesRegistry:
) )
def dict_of(self, key_type: str | Type, value_type: str | Type) -> Type: 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") dict_ = self.get_type("dict")
return self.apply_generic( return self.apply_generic(
dict_, dict_,

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,68 @@ 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):
"""Push given context for reports inside this manager and pop it on exit
Args:
ctx (str): the context to temporarily push on the stack
"""
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 +107,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,6 +120,12 @@ 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,
@@ -63,6 +133,12 @@ class FileReporter:
) )
def debug(self, location: Location, message: str): 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( self.report(
type=DiagnosticType.DEBUG, type=DiagnosticType.DEBUG,
location=location, location=location,

View File

@@ -1,9 +1,14 @@
from typing import final
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): ...
@final
class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]): class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
"""A variable assignment and reference resolver """A variable assignment and reference resolver
@@ -11,9 +16,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 +31,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 +83,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
@@ -94,7 +108,11 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
""" """
self.begin_scope() self.begin_scope()
for param in function.params.all: 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 +122,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,9 +137,9 @@ 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():
@@ -133,7 +149,9 @@ class Resolver(p.Stmt.Visitor[None], p.Expr.Visitor[None]):
target.accept(self) 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:
@@ -150,23 +168,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
@@ -196,8 +231,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)

View File

@@ -10,12 +10,16 @@ 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:
@@ -24,6 +28,8 @@ class BaseType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class DerivedType: class DerivedType:
"""A derived type, i.e. a named subtype of another type"""
name: str name: str
type: Type type: Type
@@ -33,18 +39,24 @@ class DerivedType:
@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:
"""A function type"""
params: ParamSpec params: ParamSpec
returns: Type returns: Type
@@ -57,14 +69,20 @@ class Function:
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) @dataclass(frozen=True, kw_only=True)
class ParamSpec: class ParamSpec:
"""A function's parameter spec"""
pos: list[Function.Parameter] = field(default_factory=list) pos: list[Function.Parameter] = field(default_factory=list)
mixed: list[Function.Parameter] = field(default_factory=list) mixed: list[Function.Parameter] = field(default_factory=list)
kw: list[Function.Parameter] = field(default_factory=list) kw: list[Function.Parameter] = field(default_factory=list)
@@ -87,31 +105,17 @@ class ParamSpec:
@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 ComplexType:
members: dict[str, Type]
def __str__(self) -> str:
props: list[str] = [f"{name}: {type}" for name, type in self.members.items()]
return f"{{{', '.join(props)}}}"
@dataclass(frozen=True, kw_only=True)
class ExtensionType:
base: Type
extension: ComplexType
def __str__(self) -> str:
return f"{self.base} & {self.extension}"
class Variance(StrEnum): class Variance(StrEnum):
"""The variance of a :class:`TypeVar`"""
INVARIANT = "INVARIANT" INVARIANT = "INVARIANT"
COVARIANT = "COVARIANT" COVARIANT = "COVARIANT"
CONTRAVARIANT = "CONTRAVARIANT" CONTRAVARIANT = "CONTRAVARIANT"
@@ -119,6 +123,8 @@ class Variance(StrEnum):
@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 variance: Variance = Variance.INVARIANT
@@ -136,6 +142,8 @@ class TypeVar:
@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
@@ -146,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
@@ -156,6 +166,8 @@ class AppliedType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class ConstraintType: class ConstraintType:
"""A type with a constraint expression"""
type: Type type: Type
constraint: m.Expr constraint: m.Expr
@@ -166,6 +178,8 @@ class ConstraintType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class TupleType: class TupleType:
"""A tuple type, containing any number of ordered item types"""
items: tuple[Type, ...] items: tuple[Type, ...]
def __str__(self) -> str: def __str__(self) -> str:
@@ -174,6 +188,8 @@ class TupleType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class ColumnType: class ColumnType:
"""A column type containing items of a given unique type"""
type: Type type: Type
def __str__(self) -> str: def __str__(self) -> str:
@@ -182,6 +198,8 @@ class ColumnType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class DataFrameType: class DataFrameType:
"""A data-frame type, containing named columns of specific :class:`ColumnType`"""
columns: list[Column] columns: list[Column]
def __str__(self) -> str: def __str__(self) -> str:
@@ -197,6 +215,8 @@ class DataFrameType:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class FrameGroupBy: class FrameGroupBy:
"""A frame group-by object"""
frame: DataFrameType frame: DataFrameType
def __str__(self) -> str: def __str__(self) -> str:
@@ -205,6 +225,8 @@ class FrameGroupBy:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class ColumnGroupBy: class ColumnGroupBy:
"""A column group-by object"""
column: ColumnType column: ColumnType
def __str__(self) -> str: def __str__(self) -> str:
@@ -212,6 +234,19 @@ class ColumnGroupBy:
def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type: def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type:
"""Substitute type variables in the given type
This function is called recursively on inner type structures
Args:
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): def sub_parameter(param: Function.Parameter):
return Function.Parameter( return Function.Parameter(
pos=param.pos, pos=param.pos,
@@ -266,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,
@@ -345,15 +362,20 @@ def substitute_typevars(type: Type, substitutions: dict[str, Type]) -> Type:
case UnknownType() | UnitType(): case UnknownType() | UnitType():
return type return type
case TopType() | GenericType():
raise NotImplementedError(f"Unsupported type {type}")
# Ensure exhaustiveness # Ensure exhaustiveness
case _: case _:
assert_never(type) 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 DerivedType(type=ref_type): case DerivedType(type=ref_type):
return unfold_type(ref_type) return unfold_type(ref_type)
@@ -362,6 +384,15 @@ def unfold_type(type: Type) -> Type:
def to_annotation(type: Type) -> str: 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: def _params_annotation(spec: ParamSpec) -> str:
if len(spec.kw) != 0: if len(spec.kw) != 0:
return "..." return "..."
@@ -394,9 +425,6 @@ def to_annotation(type: Type) -> str:
case OverloadedFunction(): case OverloadedFunction():
return "Callable" return "Callable"
case ComplexType() | ExtensionType():
raise NotImplementedError
case TypeVar(name=name): case TypeVar(name=name):
return name return name
@@ -406,8 +434,8 @@ def to_annotation(type: Type) -> str:
case AppliedType(name=name, args=args): case AppliedType(name=name, args=args):
return f"{name}[{', '.join(map(to_annotation, args))}]" return f"{name}[{', '.join(map(to_annotation, args))}]"
case ConstraintType(): case ConstraintType(type=base):
return str(type) return to_annotation(base)
case TupleType(items=items): case TupleType(items=items):
return f"Tuple[{', '.join(map(to_annotation, items))}]" return f"Tuple[{', '.join(map(to_annotation, items))}]"
@@ -430,6 +458,8 @@ def to_annotation(type: Type) -> str:
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Predicate: class Predicate:
"""A predicate"""
type: Type type: Type
body: m.Expr body: m.Expr
alias: bool alias: bool
@@ -443,8 +473,6 @@ Type = (
| UnitType | UnitType
| Function | Function
| OverloadedFunction | OverloadedFunction
| ComplexType
| ExtensionType
| TypeVar | TypeVar
| GenericType | GenericType
| AppliedType | AppliedType

View File

@@ -19,6 +19,14 @@ class UnificationError(Exception): ...
class Unifier: 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: def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.logger: logging.Logger = logging.getLogger("Unifier") self.logger: logging.Logger = logging.getLogger("Unifier")
@@ -29,6 +37,16 @@ class Unifier:
positional: list[Type], positional: list[Type],
keywords: dict[str, Type], keywords: dict[str, Type],
) -> Optional[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( concrete_func: Function = Function(
params=ParamSpec( params=ParamSpec(
pos=[ pos=[
@@ -60,6 +78,18 @@ class Unifier:
concrete: Type, concrete: Type,
match_return: bool = True, match_return: bool = True,
) -> Optional[Type]: ) -> 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] substitutions: dict[str, Type]
try: try:
substitutions = self.match(template.body, concrete, match_return) substitutions = self.match(template.body, concrete, match_return)
@@ -81,6 +111,22 @@ class Unifier:
concrete: Type, concrete: Type,
match_return: bool = True, match_return: bool = True,
) -> dict[str, Type]: ) -> 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 # TODO: if concrete is Generic, record bound TypeVar. Then when merging
# substitutions, check that the constraint is respected # substitutions, check that the constraint is respected
match (template, concrete): match (template, concrete):
@@ -150,6 +196,18 @@ class Unifier:
return {} return {}
def merge(self, subs1: dict[str, Type], subs2: dict[str, Type]) -> dict[str, Type]: 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() merged: dict[str, Type] = subs1.copy()
for k, v in subs2.items(): for k, v in subs2.items():
@@ -164,6 +222,15 @@ class Unifier:
def map_params( def map_params(
self, func1: Function, func2: Function self, func1: Function, func2: Function
) -> list[tuple[Function.Parameter, Function.Parameter]]: ) -> 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 pos1: list[Function.Parameter] = func1.params.pos
mixed1: list[Function.Parameter] = func1.params.mixed mixed1: list[Function.Parameter] = func1.params.mixed
kw1: list[Function.Parameter] = func1.params.kw kw1: list[Function.Parameter] = func1.params.kw

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Literal, Optional, cast from typing import Literal, Optional, cast
from midas.checker.registry import Member, TypesRegistry from midas.checker.registry import Member, TypesRegistry
@@ -16,14 +18,27 @@ Polarity = Literal[-1, 0, 1]
class Tracker: class Tracker:
"""Helper class to track the polarity of type parameter references and computer their variance"""
def __init__(self, vars: list[TypeVar]) -> None: def __init__(self, vars: list[TypeVar]) -> None:
self.vars: list[TypeVar] = vars self.vars: list[TypeVar] = vars
self.refs: dict[str, set[Polarity]] = {var.name: set() for var in self.vars} self.refs: dict[str, set[Polarity]] = {var.name: set() for var in self.vars}
def record(self, var: TypeVar, polarity: Polarity): 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) self.refs[var.name].add(polarity)
def get_updated_vars(self) -> list[TypeVar]: 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 [ return [
TypeVar( TypeVar(
name=var.name, bound=var.bound, variance=self.get_variance(var.name) name=var.name, bound=var.bound, variance=self.get_variance(var.name)
@@ -32,6 +47,18 @@ class Tracker:
] ]
def get_variance(self, name: str) -> Variance: 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] refs: set[Polarity] = self.refs[name]
if refs == {-1}: if refs == {-1}:
return Variance.CONTRAVARIANT return Variance.CONTRAVARIANT
@@ -46,11 +73,26 @@ class Tracker:
class VarianceInferrer: class VarianceInferrer:
def __init__(self, types: TypesRegistry) -> None: """Helper class to compute type parameter variance"""
self.types: TypesRegistry = types
def __init__(self, manager: VarianceManager) -> None:
self.manager: VarianceManager = manager
self.tracker: Tracker = Tracker([]) self.tracker: Tracker = Tracker([])
@property
def types(self) -> TypesRegistry:
return self.manager.types
def infer(self, type: GenericType) -> GenericType: 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.tracker = Tracker(type.params)
self.walk(type.body, 1, type.name) self.walk(type.body, 1, type.name)
@@ -71,6 +113,22 @@ class VarianceInferrer:
base_name: str, base_name: str,
path: Optional[list[str]] = None, 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: if path is None:
path = [] path = []
@@ -98,21 +156,23 @@ class VarianceInferrer:
# Get inferred variance of parameters and multiply with current # Get inferred variance of parameters and multiply with current
# polarity to recurse through arguments # polarity to recurse through arguments
case AppliedType(name=name, args=args): case AppliedType(name=name, args=args):
# TODO: handle mutually recursive types if self.manager.is_in_queue(name):
if name == base_name:
return return
generic: Type = self.types.get_type(name) generic: Type = self.types.get_type(name)
assert isinstance(generic, GenericType) assert isinstance(generic, GenericType)
generic = self.manager.infer(name, generic)
params: list[TypeVar] = generic.params params: list[TypeVar] = generic.params
polarities: dict[Variance, Polarity] = { polarities: dict[Variance, Polarity] = {
Variance.INVARIANT: 0, Variance.INVARIANT: 0,
Variance.COVARIANT: 1, Variance.COVARIANT: 1,
Variance.CONTRAVARIANT: -1, Variance.CONTRAVARIANT: -1,
} }
for param, param in zip(args, params): for arg, param in zip(args, params):
param_polarity: Polarity = polarities[param.variance] param_polarity: Polarity = polarities[param.variance]
self.walk( self.walk(
param, arg,
cast(Polarity, polarity * param_polarity), cast(Polarity, polarity * param_polarity),
base_name, base_name,
path + [f"applied:'{name}'"], path + [f"applied:'{name}'"],
@@ -127,3 +187,66 @@ class VarianceInferrer:
case TypeVar(): case TypeVar():
if type in self.tracker: if type in self.tracker:
self.tracker.record(type, polarity) self.tracker.record(type, polarity)
class VarianceManager:
"""Coordinator for VarianceInferrer to handle recursive types"""
def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types
self._queue: list[str] = []
self._inferred: set[str] = set()
def infer_all(self):
"""Infer variance on all generic types defined in the registry"""
for name, type in self.types._types.items():
if isinstance(type, GenericType):
self.infer(name, type)
def infer(self, name: str, type: GenericType) -> GenericType:
"""Infer variance of parameters of the given type
Args:
name (str): the type's name
type (GenericType): the type
Returns:
GenericType: a new generic type with its parameters updated with
their inferred variance
"""
if self.is_inferred(name):
return type
self._queue.append(name)
inferrer: VarianceInferrer = VarianceInferrer(self)
inferred: GenericType = inferrer.infer(type)
self.types._types[name] = inferred
self._queue.pop()
self._inferred.add(name)
return inferred
def is_in_queue(self, name: str) -> bool:
"""Whether the given type's variance is currently being inferred
Args:
name (str): the type's name
Returns:
bool: whether the type is in the queue
"""
return name in self._queue
def is_inferred(self, name: str) -> bool:
"""Whether the given type's variance has already been inferred
Args:
name (str): the type's name
Returns:
bool: whether the type has been processed
"""
return name in self._inferred

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generic, Optional, Protocol, TextIO, TypeVar from typing import Generic, Optional, Protocol, TextIO, TypeVar, final
import midas.ast.midas as m import midas.ast.midas as m
import midas.ast.python as p import midas.ast.python as p
@@ -121,6 +121,7 @@ class Highlighter(ABC):
self.openings.setdefault((l + 1, 0), []).append(opening) self.openings.setdefault((l + 1, 0), []).append(opening)
@final
class PythonHighlighter( class PythonHighlighter(
Highlighter, Highlighter,
p.MidasType.Visitor[None], p.MidasType.Visitor[None],
@@ -138,10 +139,6 @@ class PythonHighlighter(
self.wrap(arg, "arg") self.wrap(arg, "arg")
arg.accept(self) arg.accept(self)
def visit_constraint_type(self, node: p.ConstraintType) -> None:
self.wrap(node, "constraint-type")
node.type.accept(self)
def visit_frame_column(self, node: p.FrameColumn) -> None: def visit_frame_column(self, node: p.FrameColumn) -> None:
self.wrap(node, "frame-column") self.wrap(node, "frame-column")
if node.type is not None: if node.type is not None:
@@ -201,6 +198,10 @@ class PythonHighlighter(
for body_stmt in stmt.body: for body_stmt in stmt.body:
body_stmt.accept(self) body_stmt.accept(self)
def visit_import_stmt(self, stmt: p.ImportStmt) -> None: ...
def visit_from_import_stmt(self, stmt: p.FromImportStmt) -> None: ...
def visit_binary_expr(self, expr: p.BinaryExpr) -> None: ... def visit_binary_expr(self, expr: p.BinaryExpr) -> None: ...
def visit_compare_expr(self, expr: p.CompareExpr) -> None: ... def visit_compare_expr(self, expr: p.CompareExpr) -> None: ...
@@ -259,6 +260,7 @@ class PythonHighlighter(
def visit_raw_stmt(self, stmt: p.RawStmt) -> None: ... def visit_raw_stmt(self, stmt: p.RawStmt) -> None: ...
@final
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]
): ):
@@ -267,6 +269,11 @@ class MidasHighlighter(
def highlight(self, node: Highlightable[MidasHighlighter]): def highlight(self, node: Highlightable[MidasHighlighter]):
node.accept(self) node.accept(self)
def visit_alias_stmt(self, stmt: m.AliasStmt) -> None:
self.wrap(stmt, "alias-stmt")
self.wrap(LocatableToken(stmt.name), "type-name")
stmt.type.accept(self)
def visit_type_stmt(self, stmt: m.TypeStmt) -> None: def visit_type_stmt(self, stmt: m.TypeStmt) -> None:
self.wrap(stmt, "type-stmt") self.wrap(stmt, "type-stmt")
self.wrap(LocatableToken(stmt.name), "type-name") self.wrap(LocatableToken(stmt.name), "type-name")
@@ -338,21 +345,11 @@ 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")
self._visit_param_spec(type.params) self._visit_param_spec(type.params)
type.returns.accept(self) type.returns.accept(self)
def visit_extension_type(self, type: m.ExtensionType) -> None:
self.wrap(type, "extension")
type.base.accept(self)
type.extension.accept(self)
def _visit_param_spec(self, spec: m.ParamSpec) -> None: def _visit_param_spec(self, spec: m.ParamSpec) -> None:
for param in spec.pos + spec.mixed + spec.kw: for param in spec.pos + spec.mixed + spec.kw:
param.type.accept(self) param.type.accept(self)
@@ -366,6 +363,7 @@ class MidasHighlighter(
self.wrap(column, "column") self.wrap(column, "column")
@final
class DiagnosticsHighlighter(Highlighter): class DiagnosticsHighlighter(Highlighter):
EXTRA_CSS_PATH: Optional[Path] = Path(__file__).parent / "hl_diagnostic.css" EXTRA_CSS_PATH: Optional[Path] = Path(__file__).parent / "hl_diagnostic.css"

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

@@ -7,10 +7,6 @@ span {
--col: 103, 192, 224; --col: 103, 192, 224;
} }
&.constraint-type {
--col: 174, 200, 195;
}
&.frame-column { &.frame-column {
--col: 216, 231, 81; --col: 216, 231, 81;
} }

View File

@@ -5,20 +5,45 @@ from typing import Callable
import midas.ast.python as p import midas.ast.python as p
AssertionBuilder = Callable[..., ast.expr] AssertionBuilder = Callable[..., ast.expr]
"""A callback function which builds an assertion test given some input expressions"""
@dataclass @dataclass
class Assertion: class Assertion:
"""Runtime assertion to generate, bound to an expression"""
bound_expr: p.Expr bound_expr: p.Expr
"""The expression the assertion is bound to"""
inputs: list[p.Expr] 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 builder: AssertionBuilder
"""The callback to build the assertion test given converted expression from `inputs`"""
message: str message: str
"""The assertion message"""
def is_bound_to(self, expr: p.Expr) -> bool: 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 return expr == self.bound_expr
class AssertionCollector: class AssertionCollector:
"""Helper class to collect assertions from outside the generator"""
def __init__(self): def __init__(self):
self.assertions: list[Assertion] = [] self.assertions: list[Assertion] = []
self.definitions: dict[str, ast.stmt] = {} self.definitions: dict[str, ast.stmt] = {}
@@ -30,6 +55,15 @@ class AssertionCollector:
builder: AssertionBuilder, builder: AssertionBuilder,
message: str, 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( self.assertions.append(
Assertion( Assertion(
bound_expr=bound_expr, bound_expr=bound_expr,
@@ -40,20 +74,51 @@ class AssertionCollector:
) )
def remove(self, assertion: Assertion): def remove(self, assertion: Assertion):
"""Remove the given assertion from the collection
Args:
assertion (Assertion): the assertion to remove
"""
try: try:
self.assertions.remove(assertion) self.assertions.remove(assertion)
except ValueError: except ValueError:
pass pass
def define(self, name: str, stmt: ast.stmt): 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: if name not in self.definitions:
self.definitions[name] = stmt self.definitions[name] = stmt
def get_definitions(self) -> list[ast.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()) return list(self.definitions.values())
def get_assertions(self) -> list[Assertion]: def get_assertions(self) -> list[Assertion]:
"""Get the list of assertions
Returns:
list[Assertion]: the list of assertions
"""
return self.assertions return self.assertions
def get_assertions_for(self, expr: p.Expr) -> list[Assertion]: 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)) return list(filter(lambda a: a.is_bound_to(expr), self.assertions))

View File

@@ -1,5 +1,5 @@
import ast import ast
from typing import Optional from typing import Optional, final
import midas.ast.midas as m import midas.ast.midas as m
from midas.checker.registry import TypesRegistry from midas.checker.registry import TypesRegistry
@@ -18,14 +18,14 @@ LOGICAL_OPERATORS: dict[TokenType, type[ast.boolop]] = {
} }
BINARY_OPERATORS: dict[TokenType, type[ast.operator]] = { BINARY_OPERATORS: dict[TokenType, type[ast.operator]] = {
# TokenType.PLUS: ast.Add, TokenType.PLUS: ast.Add,
TokenType.MINUS: ast.Sub, TokenType.MINUS: ast.Sub,
TokenType.STAR: ast.Mult, TokenType.STAR: ast.Mult,
TokenType.SLASH: ast.Div, TokenType.SLASH: ast.Div,
} }
UNARY_OPERATORS: dict[TokenType, type[ast.unaryop]] = { UNARY_OPERATORS: dict[TokenType, type[ast.unaryop]] = {
# TokenType.PLUS: ast.UAdd, TokenType.PLUS: ast.UAdd,
TokenType.MINUS: ast.USub, TokenType.MINUS: ast.USub,
} }
@@ -39,7 +39,10 @@ COMPARISON_OPERATORS: dict[TokenType, type[ast.cmpop]] = {
} }
@final
class ConstraintGenerator(m.Expr.Visitor[ast.expr]): class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
"""Class to generate Python code for constraint expressions"""
def __init__(self, types: TypesRegistry): def __init__(self, types: TypesRegistry):
self.types: TypesRegistry = types self.types: TypesRegistry = types
self._id: int = 0 self._id: int = 0
@@ -47,9 +50,22 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
self._aliases: dict[str, str] = {} self._aliases: dict[str, str] = {}
def get_definitions(self) -> list[ast.stmt]: def get_definitions(self) -> list[ast.stmt]:
"""Get the list of definitions
Returns:
list[ast.stmt]: the list of definitions
"""
return self._definitions return self._definitions
def generate(self, expr: m.Expr) -> ast.expr: 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: match expr:
case m.VariableExpr(): case m.VariableExpr():
return expr.accept(self) return expr.accept(self)
@@ -75,6 +91,14 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
return ast.Name(id=alias) return ast.Name(id=alias)
def make_alias(self, name: Optional[str]) -> str: 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 suffix: str
if name is None: if name is None:
suffix = f"p{self._id}" suffix = f"p{self._id}"
@@ -85,6 +109,15 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
return alias return alias
def make_definition(self, name: str, predicate: Predicate) -> ast.stmt: 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) body: ast.expr = predicate.body.accept(self)
if predicate.alias: if predicate.alias:
return ast.Assign( return ast.Assign(
@@ -96,6 +129,14 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
return self.make_func(name, [ast.Return(value=body)], predicate.type) return self.make_func(name, [ast.Return(value=body)], predicate.type)
def make_args(self, params: ParamSpec) -> ast.arguments: 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( return ast.arguments(
posonlyargs=[ posonlyargs=[
ast.arg( ast.arg(
@@ -125,6 +166,33 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
def make_func( def make_func(
self, name: str, inner_body: list[ast.stmt], type: Type, level: int = 0 self, name: str, inner_body: list[ast.stmt], type: Type, level: int = 0
) -> ast.stmt: ) -> 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: match type:
case Function(params=params, returns=Function()): case Function(params=params, returns=Function()):
inner_name: str = f"inner{level}" inner_name: str = f"inner{level}"
@@ -152,6 +220,18 @@ class ConstraintGenerator(m.Expr.Visitor[ast.expr]):
raise ValueError(f"Expected function, got {type!r}") raise ValueError(f"Expected function, got {type!r}")
def get_predicate(self, name: str) -> Optional[ast.expr]: 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: if name not in self._aliases:
predicate: Optional[Predicate] = self.types.lookup_predicate(name) predicate: Optional[Predicate] = self.types.lookup_predicate(name)
if predicate is None: if predicate is None:

View File

@@ -3,7 +3,7 @@ import logging
import shutil import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional, assert_never from typing import Optional, assert_never, final
import midas.ast.midas as m import midas.ast.midas as m
import midas.ast.python as p import midas.ast.python as p
@@ -16,11 +16,9 @@ from midas.checker.types import (
BaseType, BaseType,
ColumnGroupBy, ColumnGroupBy,
ColumnType, ColumnType,
ComplexType,
ConstraintType, ConstraintType,
DataFrameType, DataFrameType,
DerivedType, DerivedType,
ExtensionType,
FrameGroupBy, FrameGroupBy,
Function, Function,
GenericType, GenericType,
@@ -40,11 +38,24 @@ from midas.utils import TypedAST
@dataclass @dataclass
class Scope: class Scope:
"""A simple structure to store assertions an aliases defined in a scope"""
pre_assertions: list[ast.stmt] = field(default_factory=list[ast.stmt]) pre_assertions: list[ast.stmt] = field(default_factory=list[ast.stmt])
"""A list of assertions that must be generated before the scope"""
aliases: list[str] = field(default_factory=list[str]) aliases: list[str] = field(default_factory=list[str])
"""A list of aliases defined in the scope, that can be discard afterwards"""
@final
class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]): class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
"""
A class to translate the custom Python AST back into raw `ast` nodes
This class is also responsible for generating assertions, functions for
predicates and other code necessary to ensure runtime safety.
"""
IS_DATAFRAME_FUNC = "__midas_is_dataframe__" IS_DATAFRAME_FUNC = "__midas_is_dataframe__"
IS_COLUMN_FUNC = "__midas_is_column__" IS_COLUMN_FUNC = "__midas_is_column__"
@@ -72,12 +83,28 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
self.define_is_column: bool = False self.define_is_column: bool = False
def set_src_path(self, path: Path): def set_src_path(self, path: Path):
"""Set the current source file path
Args:
path (Path): the new source file path
"""
self.rel_src_path = path.resolve().relative_to(self.workdir) self.rel_src_path = path.resolve().relative_to(self.workdir)
def generate_ast(self, typed_ast: TypedAST) -> ast.AST: def generate_ast(self, typed_ast: TypedAST) -> ast.AST:
"""Translate the given type checked AST into a Python `ast.AST`
Args:
typed_ast (TypedAST): the type checked Python AST
Returns:
ast.AST: the generated raw AST
"""
self._typed_ast = typed_ast self._typed_ast = typed_ast
body: list[ast.stmt] = self._visit_body(typed_ast.stmts, can_be_empty=True) body: list[ast.stmt] = self._visit_body(typed_ast.stmts, can_be_empty=True)
predicates: list[ast.stmt] = self._constraint_generator.get_definitions() predicates: list[ast.stmt] = self._constraint_generator.get_definitions()
assertion_definitions: list[ast.stmt] = list(
typed_ast.assertions.definitions.values()
)
body = predicates + body body = predicates + body
@@ -87,6 +114,8 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
if self.define_is_column: if self.define_is_column:
body = [self._is_column_definition()] + body body = [self._is_column_definition()] + body
body = assertion_definitions + body
module = ast.Module(body=body, type_ignores=[]) module = ast.Module(body=body, type_ignores=[])
module = ast.fix_missing_locations(module) module = ast.fix_missing_locations(module)
return module return module
@@ -98,6 +127,29 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
out_path: Optional[Path] = None, out_path: Optional[Path] = None,
type_files: Optional[list[tuple[Path, Optional[str]]]] = None, type_files: Optional[list[tuple[Path, Optional[str]]]] = None,
) -> Path: ) -> Path:
"""Generate all project files for the given source file and AST
This function calls :func:`generate_ast` to generate the output AST,
unparses it to runnable Python code, and also generates stubs for
user-defined Midas types in the same output directory
Args:
typed_ast (TypedAST): the type-checked AST
src_path (Path): the source file path
out_path (Optional[Path], optional): the output file path. If `None`,
the relative path of the source file to the working directory is
used to compute an equivalent path in the build directory.
Defaults to None.
type_files (Optional[list[tuple[Path, Optional[str]]]], optional):
the list of Midas files used to type check the AST. Defaults to None.
Raises:
ValueError: if `out_path` is `None` and the computed path is outside
the build directory
Returns:
Path: the actual `out_path` used
"""
self.set_src_path(src_path) self.set_src_path(src_path)
if out_path is None: if out_path is None:
if self.build_dir.exists(): if self.build_dir.exists():
@@ -126,6 +178,12 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
return out_path return out_path
def generate_stubs(self, in_path: Path, out_path: Path): def generate_stubs(self, in_path: Path, out_path: Path):
"""Generate stubs from the given Midas file
Args:
in_path (Path): the Midas file path
out_path (Path): the stubs output file path
"""
checker = TypeChecker() checker = TypeChecker()
checker.import_midas(in_path) checker.import_midas(in_path)
generator = StubsGenerator(checker.types) generator = StubsGenerator(checker.types)
@@ -135,6 +193,18 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
out_path.write_text(output) out_path.write_text(output)
def convert(self, expr: p.Expr) -> ast.expr: def convert(self, expr: p.Expr) -> ast.expr:
"""Translate an expression
If the expression already has an alias, it is returned.
If assertions are defined for the given expression (in :attr:`TypedAST.assertions`),
they are materialized and added to the current scope.
Args:
expr (p.Expr): the expression to translate
Returns:
ast.expr: the translated expression
"""
for expr2, alias in self._aliases: for expr2, alias in self._aliases:
if expr2 == expr: if expr2 == expr:
return alias return alias
@@ -200,7 +270,9 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
alias: ast.expr = self._make_alias(expr.expr, expr2) alias: ast.expr = self._make_alias(expr.expr, expr2)
type: Type = self._get_expr_type(expr) type: Type = self._get_expr_type(expr)
asserts: list[ast.stmt] = self._make_cast_asserts(expr.location, alias, type) asserts: list[ast.stmt] = self._make_cast_asserts(
expr.location, alias, type, context=[]
)
for assert_ in asserts: for assert_ in asserts:
self._add_assert(assert_) self._add_assert(assert_)
@@ -251,6 +323,14 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def make_args(self, params: p.ParamSpec) -> ast.arguments: def make_args(self, params: p.ParamSpec) -> ast.arguments:
"""Translate a parameter spec into an `ast.arguments` node
Args:
params (p.ParamSpec): the parameter spec
Returns:
ast.arguments: the equivalent `ast.arguments`
"""
return ast.arguments( return ast.arguments(
posonlyargs=[ast.arg(arg=param.name) for param in params.pos], posonlyargs=[ast.arg(arg=param.name) for param in params.pos],
args=[ast.arg(arg=param.name) for param in params.mixed], args=[ast.arg(arg=param.name) for param in params.mixed],
@@ -275,7 +355,6 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def visit_type_assign(self, stmt: p.TypeAssign) -> ast.stmt: def visit_type_assign(self, stmt: p.TypeAssign) -> ast.stmt:
# TODO: is that ok?
return ast.Pass() return ast.Pass()
def visit_assign_stmt(self, stmt: p.AssignStmt) -> ast.stmt: def visit_assign_stmt(self, stmt: p.AssignStmt) -> ast.stmt:
@@ -307,12 +386,56 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
orelse=[], orelse=[],
) )
def visit_import_stmt(self, stmt: p.ImportStmt) -> ast.stmt:
return ast.Import(
names=self._convert_imports(stmt.imports),
)
def visit_from_import_stmt(self, stmt: p.FromImportStmt) -> ast.stmt:
return ast.ImportFrom(
module=stmt.module,
names=self._convert_imports(stmt.imports),
level=stmt.level,
)
def _convert_imports(self, imports: list[p.ImportAlias]) -> list[ast.alias]:
"""Translate a list of import aliases
Args:
imports (list[p.ImportAlias]): the import aliases to translate
Returns:
list[ast.alias]: the translated aliases
"""
return [
ast.alias(
name=import_.name,
asname=import_.alias,
)
for import_ in imports
]
def visit_raw_stmt(self, stmt: p.RawStmt) -> ast.stmt: def visit_raw_stmt(self, stmt: p.RawStmt) -> ast.stmt:
return stmt.stmt return stmt.stmt
def _visit_body( def _visit_body(
self, stmts: list[p.Stmt], can_be_empty: bool = False self, stmts: list[p.Stmt], can_be_empty: bool = False
) -> list[ast.stmt]: ) -> list[ast.stmt]:
"""Translate a list of statements
Assertions generated while translating a statement are inserted before it,
and aliases are deleted after the statement they're used in.
Extraneous `pass` statements are automatically removed
Args:
stmts (list[p.Stmt]): the statements to translate
can_be_empty (bool, optional): if `False` and no statement is
generated, an `ast.Pass` statement is returned. Defaults to False.
Returns:
list[ast.stmt]: the generated statements
"""
generated: list[ast.stmt] = [] generated: list[ast.stmt] = []
for stmt in stmts: for stmt in stmts:
scope = Scope() scope = Scope()
@@ -335,6 +458,20 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
return generated return generated
def _make_alias(self, node: p.Expr, expr: ast.expr) -> ast.expr: def _make_alias(self, node: p.Expr, expr: ast.expr) -> ast.expr:
"""Generate a unique alias for the given expression
This function creates a unique name, generates an assignment statement
to define the alias before the current statement, adds the alias to the
list of aliases defined in the current statement, and returns an
expression that can be used in place of `expr`
Args:
node (p.Expr): the AST node that generated `expr`
expr (ast.expr): the expression to alias
Returns:
ast.expr: the generated alias reference
"""
name: str = f"__midas_a{self._alias_count}__" name: str = f"__midas_a{self._alias_count}__"
alias = ast.Name(id=name) alias = ast.Name(id=name)
self._alias_count += 1 self._alias_count += 1
@@ -349,6 +486,15 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
return alias return alias
def _build_assert(self, expr: ast.expr, message: str | ast.expr) -> ast.stmt: def _build_assert(self, expr: ast.expr, message: str | ast.expr) -> ast.stmt:
"""Build an assert statement from the given test expression and message
Args:
expr (ast.expr): the test expression
message (str | ast.expr): the assert message
Returns:
ast.stmt: the assert statement
"""
if isinstance(message, str): if isinstance(message, str):
message = ast.Constant(value=message) message = ast.Constant(value=message)
return ast.Assert( return ast.Assert(
@@ -357,17 +503,50 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def _add_assert(self, assertion: ast.stmt): def _add_assert(self, assertion: ast.stmt):
"""Append the given assertion to the current scope
Args:
assertion (ast.stmt): the assertion to add
"""
self._scopes[-1].pre_assertions.append(assertion) self._scopes[-1].pre_assertions.append(assertion)
def _get_expr_type(self, query: p.Expr) -> Type: def _get_expr_type(self, query: p.Expr) -> Type:
"""Get the type of the given expression as computed by the type checker
Args:
query (p.Expr): the expression
Raises:
RuntimeError: if no type judgment can be found for `query`
Returns:
Type: the type of `expr`
"""
for expr, type in self._typed_ast.judgements: for expr, type in self._typed_ast.judgements:
if expr == query: if expr == query:
return type return type
raise RuntimeError(f"Cannot get type judgement for {query}") raise RuntimeError(f"Cannot get type judgement for {query}")
def _make_cast_asserts( def _make_cast_asserts(
self, src_location: Location, expr: ast.expr, type: Type self,
src_location: Location,
expr: ast.expr,
type: Type,
*,
context: list[str],
) -> list[ast.stmt]: ) -> list[ast.stmt]:
"""Generate assertions for the given cast expression
Args:
src_location (Location): the location of the cast expression in
the source file
expr (ast.expr): the expression being cast
type (Type): the target type
context (list[str]): the current context
Returns:
list[ast.stmt]: the generated assertion statements
"""
match type: match type:
case UnknownType() | TopType(): case UnknownType() | TopType():
return [] return []
@@ -380,12 +559,16 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
args=[expr, ast.Name(id=name)], args=[expr, ast.Name(id=name)],
keywords=[], keywords=[],
), ),
self._make_cast_assert_message(src_location, expr, type), self._make_cast_assert_message(
src_location, expr, type, context=context
),
) )
] ]
case DerivedType(type=base): case DerivedType(type=base):
return self._make_cast_asserts(src_location, expr, base) return self._make_cast_asserts(
src_location, expr, base, context=context
)
case UnitType(): case UnitType():
return [ return [
@@ -397,19 +580,25 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
ast.Constant(value=None), ast.Constant(value=None),
], ],
), ),
self._make_cast_assert_message(src_location, expr, type), self._make_cast_assert_message(
src_location, expr, type, context=context
),
), ),
] ]
case AppliedType(body=body): case AppliedType(body=body):
return self._make_cast_asserts(src_location, expr, body) return self._make_cast_asserts(
src_location, expr, body, context=context
)
case ConstraintType(type=base, constraint=constraint): case ConstraintType(type=base, constraint=constraint):
asserts: list[ast.stmt] = self._make_cast_asserts( asserts: list[ast.stmt] = self._make_cast_asserts(
src_location, expr, base src_location, expr, base, context=context
) )
asserts.append( asserts.append(
self._make_constraint_assert(src_location, expr, constraint) self._make_constraint_assert(
src_location, expr, constraint, context=context
)
) )
return asserts return asserts
@@ -417,7 +606,9 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
# TODO: check with type from arguments / use call-site context # TODO: check with type from arguments / use call-site context
if bound is None: if bound is None:
return [] return []
return self._make_cast_asserts(src_location, expr, bound) return self._make_cast_asserts(
src_location, expr, bound, context=context
)
case TupleType(items=items): case TupleType(items=items):
asserts: list[ast.stmt] = [ asserts: list[ast.stmt] = [
@@ -427,13 +618,17 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
args=[expr, ast.Name(id="tuple")], args=[expr, ast.Name(id="tuple")],
keywords=[], keywords=[],
), ),
self._make_cast_assert_message(src_location, expr, type), self._make_cast_assert_message(
src_location, expr, type, context=context
),
), ),
] ]
assert isinstance(expr, ast.Tuple) assert isinstance(expr, ast.Tuple)
for item, item_type in zip(expr.elts, items): for item, item_type in zip(expr.elts, items):
asserts.extend( asserts.extend(
self._make_cast_asserts(src_location, item, item_type) self._make_cast_asserts(
src_location, item, item_type, context=context
)
) )
return asserts return asserts
@@ -447,7 +642,11 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
keywords=[], keywords=[],
), ),
self._make_cast_assert_message( self._make_cast_assert_message(
src_location, expr, type, ": Not a dataframe" src_location,
expr,
type,
context=context,
extra=": Not a dataframe",
), ),
), ),
] ]
@@ -463,7 +662,8 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
src_location, src_location,
expr, expr,
type, type,
f": Missing column {column.name}", context=context,
extra=f": Missing column '{column.name}'",
), ),
) )
) )
@@ -474,6 +674,7 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
value=expr, slice=ast.Constant(value=column.name) value=expr, slice=ast.Constant(value=column.name)
), ),
column.type, column.type,
context=context + [f"in column '{column.name}'"],
) )
) )
return asserts return asserts
@@ -488,12 +689,19 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
keywords=[], keywords=[],
), ),
self._make_cast_assert_message( self._make_cast_assert_message(
src_location, expr, type, ": Not a column" src_location,
expr,
type,
context=context,
extra=": Not a column",
), ),
), ),
] ]
inner_assert: Optional[ast.stmt] = self._make_column_inner_assert( inner_assert: Optional[ast.stmt] = self._make_column_inner_assert(
src_location, expr, type src_location,
expr,
type,
context,
) )
if inner_assert is not None: if inner_assert is not None:
asserts.append(inner_assert) asserts.append(inner_assert)
@@ -502,8 +710,6 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
case ( case (
Function() Function()
| OverloadedFunction() | OverloadedFunction()
| ComplexType()
| ExtensionType()
| GenericType() | GenericType()
| FrameGroupBy() | FrameGroupBy()
| ColumnGroupBy() | ColumnGroupBy()
@@ -520,8 +726,30 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
location: Location, location: Location,
expr: ast.expr, expr: ast.expr,
type: Type, type: Type,
extra: Optional[str] = None, *,
context: list[str],
extra: str = "",
) -> ast.expr: ) -> ast.expr:
"""Build an AST node for a cast assertion message
The generated Python code looks like:
```python
f"file.py:L1:1: CastError: Cannot cast {type(expr).__name__} to Type"
```
Args:
location (Location): the location of the cast expression in the
source file
expr (ast.expr): the expression being cast
type (Type): the target type
context (list[str]): the current context
extra (str, optional): extra text to append at the end of
the message. Defaults to "".
Returns:
ast.expr: the generated message (as an f-string)
"""
context_str: str = "".join(map(lambda c: f", {c}", context))
loc_str: str = f"{self.rel_src_path}:L{location.lineno}:{location.col_offset+1}" loc_str: str = f"{self.rel_src_path}:L{location.lineno}:{location.col_offset+1}"
# f"file.py:L1:1: CastError: Cannot cast {type(expr).__name__} to Type" # f"file.py:L1:1: CastError: Cannot cast {type(expr).__name__} to Type"
return ast.JoinedStr( return ast.JoinedStr(
@@ -538,13 +766,30 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
), ),
conversion=-1, conversion=-1,
), ),
ast.Constant(f" to {type}{extra or ''}"), ast.Constant(f" to {type}{context_str}{extra}"),
] ]
) )
def _make_constraint_assert( def _make_constraint_assert(
self, src_location: Location, expr: ast.expr, constraint: m.Expr self,
src_location: Location,
expr: ast.expr,
constraint: m.Expr,
*,
context: list[str],
) -> ast.stmt: ) -> ast.stmt:
"""Build an assertion for the given constraint on the given expression
Args:
src_location (Location): the location of the cast expression in the
source file
expr (ast.expr): the expression subject to `constraint`
constraint (m.Expr): the constraint applied on `expr`
context (list[str]): the current context
Returns:
ast.stmt: the assert statement checking the constraint
"""
test_func: ast.expr = self._get_constraint(constraint) test_func: ast.expr = self._get_constraint(constraint)
return self._build_assert( return self._build_assert(
ast.Call( ast.Call(
@@ -552,21 +797,43 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
args=[expr], args=[expr],
keywords=[], keywords=[],
), ),
self._make_constraint_assert_message(src_location, expr, constraint), self._make_constraint_assert_message(
src_location, constraint, context=context
),
) )
def _make_constraint_assert_message( def _make_constraint_assert_message(
self, location: Location, expr: ast.expr, constraint: m.Expr self, location: Location, constraint: m.Expr, *, context: list[str]
) -> ast.expr: ) -> ast.expr:
"""Build an assert message for the given constraint
Args:
location (Location): the location of the cast expression in the
source file
constraint (m.Expr): the constraint
context (list[str]): the current context
Returns:
ast.expr: the assert message
"""
printer = MidasPrinter() printer = MidasPrinter()
constraint_str: str = printer.print(constraint) constraint_str: str = printer.print(constraint)
context_str: str = "".join(map(lambda c: f", {c}", context))
loc_str: str = f"{self.rel_src_path}:L{location.lineno}:{location.col_offset+1}" loc_str: str = f"{self.rel_src_path}:L{location.lineno}:{location.col_offset+1}"
# f"file.py:L1:1: ConstraintError: Value does not fit constraint 'v > 0'" # f"file.py:L1:1: ConstraintError: Value does not fit constraint 'v > 0'"
return ast.Constant( return ast.Constant(
f"{loc_str}: ConstraintError: Value does not fit constraint '{constraint_str}'" f"{loc_str}: ConstraintError: Value does not fit constraint '{constraint_str}'{context_str}"
) )
def _get_constraint(self, expr: m.Expr) -> ast.expr: def _get_constraint(self, expr: m.Expr) -> ast.expr:
"""Get or generate a Python expression for the given constraint
Args:
expr (m.Expr): the constraint
Returns:
ast.expr: an equivalent Python expression
"""
for expr2, constraint in self._constraints: for expr2, constraint in self._constraints:
if expr2 == expr: if expr2 == expr:
return constraint return constraint
@@ -576,10 +843,18 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
return constraint return constraint
def _is_dataframe_definition(self) -> ast.stmt: def _is_dataframe_definition(self) -> ast.stmt:
""" """Build a function def to check if a value is a dataframe
The function is defined as:
```python
def IS_DATAFRAME_FUNC(obj) -> bool: def IS_DATAFRAME_FUNC(obj) -> bool:
import pandas as pd import pandas as pd
return isinstance(obj, pd.DataFrame) return isinstance(obj, pd.DataFrame)
```
where `IS_DATAFRAME_FUNC` is replaced by :attr:`IS_DATAFRAME_FUNC`
Returns:
ast.stmt: the function def
""" """
return ast.FunctionDef( return ast.FunctionDef(
@@ -612,10 +887,18 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def _is_column_definition(self) -> ast.stmt: def _is_column_definition(self) -> ast.stmt:
""" """Build a function def to check if a value is a column
The function is defined as:
```python
def IS_COLUMN_FUNC(obj) -> bool: def IS_COLUMN_FUNC(obj) -> bool:
import pandas as pd import pandas as pd
return isinstance(obj, pd.Series) return isinstance(obj, pd.Series)
```
where `IS_COLUMN_FUNC` is replaced by :attr:`IS_COLUMN_FUNC`
Returns:
ast.stmt: the function def
""" """
return ast.FunctionDef( return ast.FunctionDef(
@@ -648,21 +931,48 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def _make_column_inner_assert( def _make_column_inner_assert(
self, src_location: Location, column: ast.expr, type: ColumnType self,
src_location: Location,
column: ast.expr,
type: ColumnType,
context: list[str],
) -> Optional[ast.stmt]: ) -> Optional[ast.stmt]:
# TODO: improve message, maybe chain contexts """Build a for-loop checking the type of values inside a column
col: ast.expr = ast.Name(id="col")
body: list[ast.stmt] = self._make_cast_asserts(src_location, col, type.type) Args:
src_location (Location): the location of the cast expression in the
source file
column (ast.expr): the column being cast
type (ColumnType): the type of the column
context (list[str]): the current context
Returns:
Optional[ast.stmt]: a for-loop checking the values, or `None` if no
assertions are necessary
"""
value: ast.expr = ast.Name(id="value")
body: list[ast.stmt] = self._make_cast_asserts(
src_location, value, type.type, context=context
)
if len(body) == 0: if len(body) == 0:
return None return None
return ast.For( return ast.For(
target=col, target=value,
iter=column, iter=column,
body=body, body=body,
orelse=[], orelse=[],
) )
def _convert_assertion(self, assertion: Assertion) -> ast.stmt: def _convert_assertion(self, assertion: Assertion) -> ast.stmt:
"""Generate a Python assert statement for the given assertion
Args:
assertion (Assertion): the assertion to translate
Returns:
ast.stmt: the generated assert statement
"""
inputs: list[ast.expr] = [] inputs: list[ast.expr] = []
for input in assertion.inputs: for input in assertion.inputs:
@@ -678,6 +988,15 @@ class Generator(p.Stmt.Visitor[ast.stmt], p.Expr.Visitor[ast.expr]):
) )
def _apply_assertions(self, expr: p.Expr, assertions: list[Assertion]) -> ast.expr: def _apply_assertions(self, expr: p.Expr, assertions: list[Assertion]) -> ast.expr:
"""Translate the given expression, adding linked assertions to the scope
Args:
expr (p.Expr): the expression to translate
assertions (list[Assertion]): the list of assertions linked to `expr`
Returns:
ast.expr: the translated expression
"""
for assertion in assertions: for assertion in assertions:
assert_stmt: ast.stmt assert_stmt: ast.stmt
assert_stmt = self._convert_assertion(assertion) assert_stmt = self._convert_assertion(assertion)

View File

@@ -8,11 +8,9 @@ from midas.checker.types import (
BaseType, BaseType,
ColumnGroupBy, ColumnGroupBy,
ColumnType, ColumnType,
ComplexType,
ConstraintType, ConstraintType,
DataFrameType, DataFrameType,
DerivedType, DerivedType,
ExtensionType,
FrameGroupBy, FrameGroupBy,
Function, Function,
GenericType, GenericType,
@@ -32,6 +30,8 @@ Empty = ast.Constant(value=...)
class StubsGenerator: class StubsGenerator:
"""A class to generate Python stubs for user-defined Midas types"""
def __init__(self, types: TypesRegistry) -> None: def __init__(self, types: TypesRegistry) -> None:
self.types: TypesRegistry = types self.types: TypesRegistry = types
self.stubs: list[ast.stmt] = [] self.stubs: list[ast.stmt] = []
@@ -43,12 +43,17 @@ class StubsGenerator:
self.substitutions: dict[str, dict[str, Type]] = {} self.substitutions: dict[str, dict[str, Type]] = {}
def generate_stubs(self) -> ast.Module: 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.stubs = []
self.typing_imports = set() self.typing_imports = set()
self.import_pandas = False self.import_pandas = False
for name, type in self.types._types.items(): for name, type in self.types._types.items():
# Skip builtin types, not just based on name so the user can override # Skip builtin types, not just based on name so the user can override
# TODO: check if added members on builtin type # TODO: check if added members on builtin type, or prevent it
match type: match type:
case BaseType(name=name_) if name == name_: case BaseType(name=name_) if name == name_:
continue continue
@@ -92,9 +97,17 @@ class StubsGenerator:
return ast.Module(body=imports + self.stubs, type_ignores=[]) return ast.Module(body=imports + self.stubs, type_ignores=[])
def generate_stub(self, name: str, type: Type): 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 base_type: Type = type
# TODO: improve # Generate simple assignment for type aliases
# A type alias will have a name that is different from the type represents
# or will neither be a `DeriveType` nor a `GenericType`
match type: match type:
case DerivedType(name=name_) | GenericType(name=name_) if name_ == name: case DerivedType(name=name_) | GenericType(name=name_) if name_ == name:
pass pass
@@ -129,6 +142,17 @@ class StubsGenerator:
self.add_stub(stub) self.add_stub(stub)
def get_bases(self, type: Type) -> tuple[list[ast.expr], dict[str, Type]]: 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: match type:
case DerivedType(type=base): case DerivedType(type=base):
return [self.dump_type(base)], {} return [self.dump_type(base)], {}
@@ -173,6 +197,16 @@ class StubsGenerator:
def generate_body( def generate_body(
self, members: dict[str, Member], substitutions: dict[str, Type] self, members: dict[str, Member], substitutions: dict[str, Type]
) -> list[ast.stmt]: ) -> 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: if len(members) == 0:
return [ast.Expr(value=Empty)] return [ast.Expr(value=Empty)]
@@ -194,6 +228,14 @@ class StubsGenerator:
return body return body
def dump_type(self, type: Type) -> ast.expr: 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: match type:
case DerivedType(name=name) | GenericType(name=name) if ( case DerivedType(name=name) | GenericType(name=name) if (
name in self.substitutions name in self.substitutions
@@ -227,14 +269,6 @@ class StubsGenerator:
right=self.dump_type(overloads[-1]), right=self.dump_type(overloads[-1]),
) )
case ComplexType():
name: str = self.new_stub_name()
self.generate_stub(name, type)
return ast.Name(id=name)
case ExtensionType():
raise NotImplementedError
case TypeVar(): case TypeVar():
return ast.Name(id=type.name) return ast.Name(id=type.name)
@@ -273,14 +307,11 @@ class StubsGenerator:
), ),
) )
case ColumnType(type=inner): case ColumnType():
self.import_pandas = True self.import_pandas = True
return ast.Subscript( return ast.Attribute(
value=ast.Attribute( value=ast.Name(id="pd"),
value=ast.Name(id="pd"), attr="Series",
attr="Series",
),
slice=self.dump_type(inner),
) )
case DataFrameType(): case DataFrameType():
@@ -322,6 +353,17 @@ class StubsGenerator:
def dump_method( def dump_method(
self, name: str, method: Type, overloaded: bool = False self, name: str, method: Type, overloaded: bool = False
) -> list[ast.stmt]: ) -> 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: match method:
case Function(): case Function():
if overloaded: if overloaded:
@@ -350,6 +392,16 @@ class StubsGenerator:
] ]
def dump_params(self, params: ParamSpec, with_self: bool = False) -> ast.arguments: 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] = [ pos: list[ast.arg] = [
ast.arg( ast.arg(
arg=f"_{param.pos}", arg=f"_{param.pos}",
@@ -392,6 +444,14 @@ class StubsGenerator:
) )
def define_protocol(self, func: Function) -> str: 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") self.add_typing_import("Protocol")
name: str = self.new_protocol_name() name: str = self.new_protocol_name()
protocol = ast.ClassDef( protocol = ast.ClassDef(
@@ -413,33 +473,74 @@ class StubsGenerator:
return name return name
def new_protocol_name(self) -> str: def new_protocol_name(self) -> str:
"""Get a unique protocol name
Returns:
str: the unique protocol name
"""
name: str = f"_Protocol{self.protocol_idx}" name: str = f"_Protocol{self.protocol_idx}"
self.protocol_idx += 1 self.protocol_idx += 1
return name return name
def new_stub_name(self) -> str: def new_stub_name(self) -> str:
"""Get a unique stub name
Returns:
str: the unique stub name
"""
name: str = f"_Stub_{self.stub_idx}" name: str = f"_Stub_{self.stub_idx}"
self.stub_idx += 1 self.stub_idx += 1
return name return name
def new_type_var_name(self) -> str: 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}" name: str = f"_T{self.type_var_idx}"
self.type_var_idx += 1 self.type_var_idx += 1
return name return name
def add_stub(self, stub: ast.stmt): 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) self.stubs.append(stub)
def add_typing_import(self, name: str): 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) self.typing_imports.add(name)
def define_type_vars(self, vars: list[TypeVar]) -> list[TypeVar]: 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] = [] vars2: list[TypeVar] = []
for var in vars: for var in vars:
vars2.append(self.define_type_var(var)) vars2.append(self.define_type_var(var))
return vars2 return vars2
def define_type_var(self, var: TypeVar) -> TypeVar: 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() name: str = self.new_type_var_name()
self.add_typing_import("TypeVar") self.add_typing_import("TypeVar")

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 ".":
@@ -81,6 +83,12 @@ class MidasLexer(Lexer):
return None return None
def scan_string(self, opening: str): 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(): while self.peek() != opening and not self.is_at_end():
self.advance() self.advance()
@@ -100,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):
@@ -147,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

@@ -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()
@@ -104,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

@@ -5,11 +5,9 @@ from midas.ast.midas import (
AliasStmt, AliasStmt,
BinaryExpr, BinaryExpr,
CallExpr, CallExpr,
ComplexType,
ConstraintType, ConstraintType,
Expr, Expr,
ExtendStmt, ExtendStmt,
ExtensionType,
FrameType, FrameType,
FunctionType, FunctionType,
GenericType, GenericType,
@@ -35,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,
@@ -73,10 +72,10 @@ 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):
@@ -95,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
@@ -165,11 +155,16 @@ class MidasParser(Parser):
def alias_declaration(self) -> AliasStmt: def alias_declaration(self) -> AliasStmt:
"""Parse an alias declaration """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: Returns:
AliasStmt: the parsed alias declaration statement AliasStmt: the parsed alias declaration statement
""" """
keyword: Token = self.previous() keyword: Token = self.previous()
name: Token = self.consume_identifier("Expected type name") name: Token = self.consume_identifier("Expected alias name")
self.consume(TokenType.EQUAL, "Expected '=' before alias definition") self.consume(TokenType.EQUAL, "Expected '=' before alias definition")
@@ -184,27 +179,26 @@ class MidasParser(Parser):
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()
@@ -216,17 +210,33 @@ 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:
"""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() type: NamedType = self.named_type()
if self.check(TokenType.LEFT_BRACKET): if self.check(TokenType.LEFT_BRACKET):
if type.name.lexeme == "Frame": if type.name.lexeme == "Frame":
@@ -240,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):
@@ -250,41 +267,34 @@ class MidasParser(Parser):
return args return args
def named_type(self) -> NamedType: 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(
TokenType.LEFT_BRACE, "Expected '{' to start type body"
)
members: list[MemberStmt] = []
# TODO: add keyword to differentiate properties and methods,
# and allow multiple methods with the same name but not properties
names: set[str] = set()
while not self.check(TokenType.RIGHT_BRACE) and not self.is_at_end():
member: MemberStmt = self.member_stmt()
# if member.name.lexeme in names:
# raise self.error(member.name, "Duplicate property")
# names.add(member.name.lexeme)
members.append(member)
right: Token = self.consume(TokenType.RIGHT_BRACE, "Unclosed type body")
return ComplexType(
location=left.location_to(right),
members=members,
)
def frame_type(self) -> FrameType:
keyword: Token = self.previous() keyword: Token = self.previous()
self.consume(TokenType.LEFT_BRACKET, "Expected '[' to start frame schema") self.consume(TokenType.LEFT_BRACKET, "Expected '[' to start frame schema")
@@ -311,9 +321,9 @@ class MidasParser(Parser):
) )
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
@@ -321,10 +331,20 @@ class MidasParser(Parser):
return self.expression() return self.expression()
def expression(self) -> Expr: 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
@@ -340,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
@@ -356,7 +379,10 @@ 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
@@ -377,6 +403,14 @@ class MidasParser(Parser):
return expr return expr
def term(self) -> 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() expr: Expr = self.factor()
while self.match(TokenType.PLUS, TokenType.MINUS): while self.match(TokenType.PLUS, TokenType.MINUS):
operator: Token = self.previous() operator: Token = self.previous()
@@ -388,6 +422,14 @@ class MidasParser(Parser):
return expr return expr
def factor(self) -> 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() expr: Expr = self.unary()
while self.match(TokenType.STAR, TokenType.SLASH): while self.match(TokenType.STAR, TokenType.SLASH):
operator: Token = self.previous() operator: Token = self.previous()
@@ -399,12 +441,15 @@ 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)
@@ -412,12 +457,44 @@ class MidasParser(Parser):
return self.call() return self.call()
def call(self) -> Expr: 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() expr: Expr = self.reference()
while self.match(TokenType.LEFT_PAREN): while self.match(TokenType.LEFT_PAREN):
expr = self.finish_call(expr) expr = self.finish_call(expr)
return expr return expr
def finish_call(self, callee: Expr) -> 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] = [] pos_args: list[Expr] = []
kw_args: dict[str, Expr] = {} kw_args: dict[str, Expr] = {}
keywords: bool = False keywords: bool = False
@@ -437,13 +514,14 @@ class MidasParser(Parser):
else: else:
value = self.expression() value = self.expression()
if self.check(TokenType.EQUAL): if self.check(TokenType.EQUAL):
error_msg: str
if keywords: if keywords:
raise self.error(self.peek(), "Invalid keyword argument name") error_msg = "Invalid keyword argument name"
else: else:
raise self.error( error_msg = (
self.peek(), "Cannot pass positional arguments after a keyword argument"
"Cannot pass positional arguments after a keyword argument",
) )
raise self.error(self.peek(), error_msg)
pos_args.append(value) pos_args.append(value)
if not self.match(TokenType.COMMA): if not self.match(TokenType.COMMA):
@@ -460,7 +538,12 @@ class MidasParser(Parser):
) )
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
@@ -475,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
@@ -501,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
@@ -524,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
@@ -551,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
@@ -576,7 +704,12 @@ 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
@@ -599,6 +732,17 @@ class MidasParser(Parser):
) )
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() params: ParamSpec = self.function_params()
self.consume(TokenType.ARROW, "Expected '->' before result type") self.consume(TokenType.ARROW, "Expected '->' before result type")
@@ -611,6 +755,21 @@ class MidasParser(Parser):
) )
def function_params(self) -> ParamSpec: 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"
) )

View File

@@ -9,16 +9,18 @@ from midas.ast.python import (
CallExpr, CallExpr,
CastExpr, CastExpr,
CompareExpr, CompareExpr,
ConstraintType,
DictExpr, DictExpr,
Expr, Expr,
ExpressionStmt, ExpressionStmt,
ForStmt, ForStmt,
FrameColumn, FrameColumn,
FrameType, FrameType,
FromImportStmt,
Function, Function,
GetExpr, GetExpr,
IfStmt, IfStmt,
ImportAlias,
ImportStmt,
ListExpr, ListExpr,
LiteralExpr, LiteralExpr,
LogicalExpr, LogicalExpr,
@@ -50,6 +52,8 @@ 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" UNSAFE_CAST_FUNCTION = "unsafe_cast"
@@ -103,10 +107,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)
@@ -308,30 +336,6 @@ class PythonParser:
args=(), args=(),
) )
case ast.BinOp(left=left_expr, op=ast.Add(), right=right_expr):
left = self._parse_type(left_expr)
match left:
# If chained constraints, separate base type and rebuild constraint
case ConstraintType(type=left_type, constraint=left_constraint):
constraint = ast.BinOp(
left=left_constraint,
op=ast.Add(),
right=right_expr,
)
ast.copy_location(constraint, type_expr)
return ConstraintType(
location=loc,
type=left_type,
constraint=constraint,
)
case _:
return ConstraintType(
location=loc,
type=left,
constraint=right_expr,
)
case ast.Constant(value=None): case ast.Constant(value=None):
return BaseType( return BaseType(
location=loc, location=loc,
@@ -351,7 +355,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 _:
@@ -362,7 +366,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,

View File

@@ -1,6 +1,8 @@
from typing import Generic, TypeVar from typing import Generic, TypeVar
from typing import cast as typing_cast from typing import cast as typing_cast
from pandas import DataFrame, Series
cast = typing_cast cast = typing_cast
"""### Midas documentation """### Midas documentation
Cast a value to a type. Cast a value to a type.
@@ -38,14 +40,14 @@ _**Internal Python documentation**_
T = TypeVar("T") T = TypeVar("T")
class Frame(Generic[T]): class Frame(DataFrame, Generic[T]):
"""A `Frame` is the abstract type implemented by `DataFrame` """A `Frame` is the abstract type implemented by `DataFrame`
A frame contains any number of named columns (see :class:`Column`) A frame contains any number of named columns (see :class:`Column`)
""" """
class Column(Generic[T]): class Column(Series, Generic[T]):
"""A `Column` is the abstract type implemented by `Series` """A `Column` is the abstract type implemented by `Series`
A column contains a any number of values of the same type A column contains a any number of values of the same type

View File

@@ -0,0 +1,174 @@
import ast
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@dataclass
class ArgDoc:
name: str
type: str
optional: bool
@dataclass
class Param:
name: str
annotation: Optional[str]
optional: bool
class Checker(ast.NodeVisitor):
def _get_args(self, docstring: str) -> list[ArgDoc]:
args: list[ArgDoc] = []
in_args: bool = False
for line in docstring.splitlines():
if not in_args:
if line == "Args:":
in_args = True
continue
# End of args
if not line.startswith(" "):
break
# Continuation line
if line.startswith(" "):
continue
line = line.strip()
m = re.match(r"(?P<name>\w+) \((?P<type>.*?)(?P<opt>, optional)?\):", line)
if m is None:
continue
args.append(
ArgDoc(
name=m.group("name"),
type=m.group("type"),
optional=m.group("opt") is not None,
)
)
return args
def log(self, node: ast.FunctionDef, msg: str):
loc: str = f"{node.name} L{node.lineno}:{node.col_offset+1}"
print(f" ({loc}) {msg}")
def _is_ignored(self, node: ast.FunctionDef) -> bool:
name: str = node.name
if name.startswith("visit_") or name.startswith("_visit_"):
return True
if name.startswith("parse_") or name.startswith("_parse_"):
return True
if name.startswith("_print"):
return True
if name.startswith("_write"):
return True
if name.startswith("__") and name.endswith("__"):
return True
if name == "accept":
return True
node.decorator_list
match node:
case ast.FunctionDef(
decorator_list=[
ast.Call(
func=ast.Name(id="method"),
),
],
):
return True
return False
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
docstring: Optional[str] = ast.get_docstring(node)
func_name: str = node.name
if docstring is None:
if not self._is_ignored(node):
self.log(node, f"Missing docstring for function {func_name}")
return
args_doc: list[ArgDoc] = self._get_args(docstring)
by_name: dict[str, ArgDoc] = {}
for doc in args_doc:
if doc.name in by_name:
self.log(node, f"Multiple documentation lines for argument {doc.name}")
by_name[doc.name] = doc
all_params: list[Param] = []
pos_args: list[ast.arg] = node.args.posonlyargs
mixed_args: list[ast.arg] = node.args.args
kw_args: list[ast.arg] = node.args.kwonlyargs
def add_param(arg: ast.arg, optional: bool):
all_params.append(
Param(
name=arg.arg,
annotation=(
ast.unparse(arg.annotation)
if arg.annotation is not None
else None
),
optional=optional,
)
)
n_pos: int = len(pos_args) + len(mixed_args)
for i, arg in enumerate(pos_args):
j: int = n_pos - i - 1
optional: bool = j < len(node.args.defaults)
add_param(arg, optional)
for i, arg in enumerate(mixed_args):
j: int = len(mixed_args) - i - 1
optional: bool = j < len(node.args.defaults)
add_param(arg, optional)
for arg, default in zip(kw_args, node.args.kw_defaults):
optional: bool = default is not None
add_param(arg, optional)
for param in all_params:
doc: Optional[ArgDoc] = by_name.get(param.name, None)
if doc is None:
if param.name not in {"self", "cls"}:
self.log(
node, f"Missing documentation for parameter '{param.name}'"
)
continue
if doc.name != param.name:
self.log(node, f"Documentation mismatch for '{param.name}': wrong name")
if doc.type != param.annotation:
self.log(node, f"Documentation mismatch for '{param.name}': wrong type")
if doc.optional != param.optional:
self.log(
node,
f"Documentation mismatch for '{param.name}': wrong optionality",
)
def check_file(path: Path):
source: str = path.read_text()
tree = ast.parse(source)
checker = Checker()
checker.visit(tree)
def main():
folder: Path = (Path(__file__).parent.parent / "midas").resolve()
all_files = folder.rglob("*.py")
for f in all_files:
print(f.relative_to(folder))
check_file(f)
print()
if __name__ == "__main__":
main()

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,
) )

33
test.py
View File

@@ -1,33 +0,0 @@
import json
from pathlib import Path
from midas.ast.printer import MidasAstPrinter
from midas.lexer.midas import MidasLexer
from midas.lexer.token import Token
from midas.parser.midas import MidasParser
def test_midas():
# Midas type definitions
path: Path = Path("examples") / "00_syntax_prototype" / "03_custom_types_v2.midas"
definitions: str = path.read_text()
midas_lexer: MidasLexer = MidasLexer(definitions, path.name)
tokens: list[Token] = midas_lexer.process()
# print([f"{t.type.name}('{t.lexeme}')" for t in tokens])
with open("tokens.json", "w") as f:
json.dump([f"{t.type.name}('{t.lexeme}')" for t in tokens], f, indent=4)
parser = MidasParser(tokens)
parsed = parser.parse()
print(parsed)
for err in parser.errors:
print(err.get_report())
printer = MidasAstPrinter()
for stmt in parsed:
if stmt is None:
print("None")
continue
print(printer.print(stmt))
test_midas()

View File

@@ -1,7 +1,8 @@
import sys
from typing import Type from typing import Type
from midas.cli.ansi import Ansi from midas.cli.ansi import Ansi
from tests.base import Tester from tests.base import Tester, TestsSummary
from tests.checker import CheckerTester from tests.checker import CheckerTester
from tests.generator import GeneratorTester from tests.generator import GeneratorTester
from tests.midas import MidasTester from tests.midas import MidasTester
@@ -15,12 +16,12 @@ def print_banner(name: str):
print(horizontal) print(horizontal)
def run_tests(tester_cls: Type[Tester]) -> bool: def run_tests(tester_cls: Type[Tester]) -> TestsSummary:
print_banner(tester_cls.__name__) print_banner(tester_cls.__name__)
tester: Tester = tester_cls() tester: Tester = tester_cls()
success: bool = tester.run_all_tests() summary: TestsSummary = tester.run_all_tests()
print() print()
return success return summary
def main(): def main():
@@ -31,12 +32,17 @@ def main():
GeneratorTester, GeneratorTester,
] ]
success: bool = all(map(run_tests, testers)) summaries: list[TestsSummary] = list(
map(run_tests, testers)
) # list to avoid early stop
summary: TestsSummary = TestsSummary.concat(*summaries)
if success: if summary.success:
print(Ansi.FG(Ansi.BRIGHT_GREEN) + "All tests passed!" + Ansi.RESET) print(Ansi.FG(Ansi.BRIGHT_GREEN) + "All tests passed!" + Ansi.RESET)
else: else:
print(Ansi.FG(Ansi.BRIGHT_RED) + "Some tests failed!" + Ansi.RESET) print(Ansi.FG(Ansi.BRIGHT_RED) + "Some tests failed!" + Ansi.RESET)
summary.print()
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,6 +4,7 @@ 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
@@ -14,6 +15,43 @@ 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"""
@@ -34,40 +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):
path: Path = 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}") print(f"{Ansi.FG(Ansi.BRIGHT_CYAN)}Case {i+1}/{n}: {path}{Ansi.RESET}")
print(Ansi.DIM, end="")
success: bool = self._run_test(test) success: bool = self._run_test(test)
print(Ansi.RESET, end="") summary.add(str(path), success)
if 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()
@@ -143,16 +178,18 @@ 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:
success: bool = tester.run_all_tests() summary: TestsSummary = tester.run_all_tests()
if not success: if not summary.success:
summary.print()
sys.exit(1) sys.exit(1)
case _: case _:
print(f"Unknown subcommand '{args.subcommand}'") print(f"Unknown subcommand '{args.subcommand}'")

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
df: Frame[ df: Frame[
verified: bool, verified: bool,
birth_year: int, birth_year: int,
height: float + ( _ > 0 ) + ( _ < 250 ), height: float,
name: str, name: str,
date: datetime, date: datetime,
float, float,

View File

@@ -1,19 +1,5 @@
{ {
"diagnostics": [ "diagnostics": [
{
"type": "Warning",
"location": {
"start": [
8,
12
],
"end": [
8,
43
]
},
"message": "ConstraintType not yet supported"
},
{ {
"type": "Warning", "type": "Warning",
"location": { "location": {

View File

@@ -285,7 +285,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -295,7 +296,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -305,7 +307,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -361,7 +364,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -371,7 +375,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -381,7 +386,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -455,7 +461,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -465,7 +472,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -475,7 +483,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -553,7 +562,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -563,7 +573,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -573,7 +584,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -665,7 +677,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -675,7 +688,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -685,7 +699,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -780,7 +795,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -790,7 +806,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -800,7 +817,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -870,7 +888,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -880,7 +899,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -890,7 +910,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -951,7 +972,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -961,7 +983,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -971,7 +994,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -1058,7 +1082,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -1068,7 +1093,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -1078,7 +1104,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -1176,7 +1203,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -1186,7 +1214,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -1196,7 +1225,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -1294,7 +1324,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -1304,7 +1335,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -1314,7 +1346,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },
@@ -1412,7 +1445,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [ "mixed": [
@@ -1422,7 +1456,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [ "kw": [
@@ -1432,7 +1467,8 @@
"type": { "type": {
"name": "str" "name": "str"
}, },
"required": true "required": true,
"unsupported": false
} }
] ]
}, },

View File

@@ -145,7 +145,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
}, },
{ {
"pos": 1, "pos": 1,
@@ -153,7 +154,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [] "kw": []

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -126,7 +126,8 @@
"pos": 0, "pos": 0,
"name": "object", "name": "object",
"type": {}, "type": {},
"required": false "required": false,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -388,7 +389,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [] "kw": []
@@ -459,7 +461,8 @@
"bound": null, "bound": null,
"variance": "INVARIANT" "variance": "INVARIANT"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -471,7 +474,8 @@
"variance": "INVARIANT" "variance": "INVARIANT"
} }
}, },
"required": true "required": true,
"unsupported": false
}, },
{ {
"pos": 1, "pos": 1,
@@ -489,7 +493,8 @@
"name": "list" "name": "list"
} }
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -565,7 +570,8 @@
"type": { "type": {
"name": "float" "name": "float"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [] "kw": []
@@ -636,7 +642,8 @@
"bound": null, "bound": null,
"variance": "INVARIANT" "variance": "INVARIANT"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -648,7 +655,8 @@
"variance": "INVARIANT" "variance": "INVARIANT"
} }
}, },
"required": true "required": true,
"unsupported": false
}, },
{ {
"pos": 1, "pos": 1,
@@ -666,7 +674,8 @@
"name": "list" "name": "list"
} }
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -732,7 +741,8 @@
"type": { "type": {
"name": "int" "name": "int"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"kw": [] "kw": []
@@ -803,7 +813,8 @@
"bound": null, "bound": null,
"variance": "INVARIANT" "variance": "INVARIANT"
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],
@@ -815,7 +826,8 @@
"variance": "INVARIANT" "variance": "INVARIANT"
} }
}, },
"required": true "required": true,
"unsupported": false
}, },
{ {
"pos": 1, "pos": 1,
@@ -833,7 +845,8 @@
"name": "list" "name": "list"
} }
}, },
"required": true "required": true,
"unsupported": false
} }
], ],
"mixed": [], "mixed": [],

View File

@@ -1,8 +1,9 @@
# type: ignore # type: ignore
# ruff: disable [F821] # ruff: disable [F821]
import pandas as pd
df1: Frame[a:int, b:float] df1 = cast(Frame[i:int, a:int, b:float], pd.DataFrame())
df2: Frame[a:int, b:float] df2 = cast(Frame[i:int, a:int, b:float], pd.DataFrame())
_: Any _: Any
@@ -38,7 +39,7 @@ _ = df1.sum()
_ = df1.var() _ = df1.var()
# Groupby # Groupby
df_gb = df1.groupby(by="a") df_gb = df1.groupby(by="i")
_ = df_gb.kurt() _ = df_gb.kurt()
_ = df_gb.max() _ = df_gb.max()

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

@@ -0,0 +1,44 @@
def a1(param: int) -> float: ...
def a2(param: float) -> int: ...
a = a1
a = a2
def b1(a: int, /) -> float: ...
def b2(b: float, /) -> int: ...
b = b1
b = b2
def c1(a: int) -> None: ...
def c2(p: float = 0, /, *, a: float = 0) -> None: ...
c = c1
c = c2
# Invalid subtypes
def d1(a: int) -> float: ...
def d2(a: str) -> float: ...
def d3(a: int) -> str: ...
d = d1
d = d2
d = d3
def e1(*, a: int = 0) -> None: ...
def e2(*, a: int) -> None: ...
def e3(*, a: int = 0, b: int) -> None: ...
e = e1
e = e2
e = e3

View File

@@ -0,0 +1,881 @@
{
"diagnostics": [
{
"type": "Warning",
"location": {
"start": [
1,
29
],
"end": [
1,
32
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=1, col_offset=29, end_lineno=1, end_col_offset=32), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
1,
22
],
"end": [
1,
27
]
},
"message": "Return type mismatch, annotated float but returns None"
},
{
"type": "Warning",
"location": {
"start": [
2,
29
],
"end": [
2,
32
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=2, col_offset=29, end_lineno=2, end_col_offset=32), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
2,
24
],
"end": [
2,
27
]
},
"message": "Return type mismatch, annotated int but returns None"
},
{
"type": "Warning",
"location": {
"start": [
9,
28
],
"end": [
9,
31
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=9, col_offset=28, end_lineno=9, end_col_offset=31), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
9,
21
],
"end": [
9,
26
]
},
"message": "Return type mismatch, annotated float but returns None"
},
{
"type": "Warning",
"location": {
"start": [
10,
28
],
"end": [
10,
31
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=10, col_offset=28, end_lineno=10, end_col_offset=31), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
10,
23
],
"end": [
10,
26
]
},
"message": "Return type mismatch, annotated int but returns None"
},
{
"type": "Warning",
"location": {
"start": [
17,
24
],
"end": [
17,
27
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=17, col_offset=24, end_lineno=17, end_col_offset=27), value=Ellipsis)"
},
{
"type": "Warning",
"location": {
"start": [
18,
50
],
"end": [
18,
53
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=18, col_offset=50, end_lineno=18, end_col_offset=53), value=Ellipsis)"
},
{
"type": "Warning",
"location": {
"start": [
27,
25
],
"end": [
27,
28
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=27, col_offset=25, end_lineno=27, end_col_offset=28), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
27,
18
],
"end": [
27,
23
]
},
"message": "Return type mismatch, annotated float but returns None"
},
{
"type": "Warning",
"location": {
"start": [
28,
25
],
"end": [
28,
28
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=28, col_offset=25, end_lineno=28, end_col_offset=28), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
28,
18
],
"end": [
28,
23
]
},
"message": "Return type mismatch, annotated float but returns None"
},
{
"type": "Warning",
"location": {
"start": [
29,
23
],
"end": [
29,
26
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=29, col_offset=23, end_lineno=29, end_col_offset=26), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
29,
18
],
"end": [
29,
21
]
},
"message": "Return type mismatch, annotated str but returns None"
},
{
"type": "Error",
"location": {
"start": [
33,
0
],
"end": [
33,
6
]
},
"message": "Cannot assign (a: str) -> float to variable 'd' of type (a: int) -> float"
},
{
"type": "Error",
"location": {
"start": [
34,
0
],
"end": [
34,
6
]
},
"message": "Cannot assign (a: int) -> str to variable 'd' of type (a: int) -> float"
},
{
"type": "Warning",
"location": {
"start": [
37,
31
],
"end": [
37,
34
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=37, col_offset=31, end_lineno=37, end_col_offset=34), value=Ellipsis)"
},
{
"type": "Warning",
"location": {
"start": [
38,
27
],
"end": [
38,
30
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=38, col_offset=27, end_lineno=38, end_col_offset=30), value=Ellipsis)"
},
{
"type": "Warning",
"location": {
"start": [
39,
39
],
"end": [
39,
42
]
},
"message": "Unknown literal LiteralExpr(location=Location(lineno=39, col_offset=39, end_lineno=39, end_col_offset=42), value=Ellipsis)"
},
{
"type": "Error",
"location": {
"start": [
43,
0
],
"end": [
43,
6
]
},
"message": "Cannot assign (*, a: int) -> None to variable 'e' of type (*, a: int?) -> None"
},
{
"type": "Error",
"location": {
"start": [
44,
0
],
"end": [
44,
6
]
},
"message": "Cannot assign (*, a: int?, b: int) -> None to variable 'e' of type (*, a: int?) -> None"
}
],
"judgments": [
{
"location": {
"from": "L1:29",
"to": "L1:32"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L2:29",
"to": "L2:32"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L5:4",
"to": "L5:6"
},
"expr": {
"_type": "VariableExpr",
"name": "a1"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "param",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L6:4",
"to": "L6:6"
},
"expr": {
"_type": "VariableExpr",
"name": "a2"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "param",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "int"
}
}
},
{
"location": {
"from": "L9:28",
"to": "L9:31"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L10:28",
"to": "L10:31"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L13:4",
"to": "L13:6"
},
"expr": {
"_type": "VariableExpr",
"name": "b1"
},
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L14:4",
"to": "L14:6"
},
"expr": {
"_type": "VariableExpr",
"name": "b2"
},
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "b",
"type": {
"name": "float"
},
"required": true,
"unsupported": false
}
],
"mixed": [],
"kw": []
},
"returns": {
"name": "int"
}
}
},
{
"location": {
"from": "L17:24",
"to": "L17:27"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L18:18",
"to": "L18:19"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L18:38",
"to": "L18:39"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L18:50",
"to": "L18:53"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L21:4",
"to": "L21:6"
},
"expr": {
"_type": "VariableExpr",
"name": "c1"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {}
}
},
{
"location": {
"from": "L22:4",
"to": "L22:6"
},
"expr": {
"_type": "VariableExpr",
"name": "c2"
},
"type": {
"params": {
"pos": [
{
"pos": 0,
"name": "p",
"type": {
"name": "float"
},
"required": false,
"unsupported": false
}
],
"mixed": [],
"kw": [
{
"pos": 1,
"name": "a",
"type": {
"name": "float"
},
"required": false,
"unsupported": false
}
]
},
"returns": {}
}
},
{
"location": {
"from": "L27:25",
"to": "L27:28"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L28:25",
"to": "L28:28"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L29:23",
"to": "L29:26"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L32:4",
"to": "L32:6"
},
"expr": {
"_type": "VariableExpr",
"name": "d1"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L33:4",
"to": "L33:6"
},
"expr": {
"_type": "VariableExpr",
"name": "d2"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "a",
"type": {
"name": "str"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "float"
}
}
},
{
"location": {
"from": "L34:4",
"to": "L34:6"
},
"expr": {
"_type": "VariableExpr",
"name": "d3"
},
"type": {
"params": {
"pos": [],
"mixed": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
],
"kw": []
},
"returns": {
"name": "str"
}
}
},
{
"location": {
"from": "L37:19",
"to": "L37:20"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L37:31",
"to": "L37:34"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L38:27",
"to": "L38:30"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L39:19",
"to": "L39:20"
},
"expr": {
"_type": "LiteralExpr",
"value": 0
},
"type": {
"name": "int"
}
},
{
"location": {
"from": "L39:39",
"to": "L39:42"
},
"expr": {
"_type": "LiteralExpr",
"value": "..."
},
"type": {}
},
{
"location": {
"from": "L42:4",
"to": "L42:6"
},
"expr": {
"_type": "VariableExpr",
"name": "e1"
},
"type": {
"params": {
"pos": [],
"mixed": [],
"kw": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": false,
"unsupported": false
}
]
},
"returns": {}
}
},
{
"location": {
"from": "L43:4",
"to": "L43:6"
},
"expr": {
"_type": "VariableExpr",
"name": "e2"
},
"type": {
"params": {
"pos": [],
"mixed": [],
"kw": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
]
},
"returns": {}
}
},
{
"location": {
"from": "L44:4",
"to": "L44:6"
},
"expr": {
"_type": "VariableExpr",
"name": "e3"
},
"type": {
"params": {
"pos": [],
"mixed": [],
"kw": [
{
"pos": 0,
"name": "a",
"type": {
"name": "int"
},
"required": false,
"unsupported": false
},
{
"pos": 1,
"name": "b",
"type": {
"name": "int"
},
"required": true,
"unsupported": false
}
]
},
"returns": {}
}
}
]
}

View File

@@ -18,7 +18,7 @@ Module(
ops=[ ops=[
Gt()], Gt()],
comparators=[ comparators=[
Constant(value=0.0)]))], Constant(value=0)]))],
decorator_list=[], decorator_list=[],
returns=Constant(value='bool')), returns=Constant(value='bool')),
FunctionDef( FunctionDef(
@@ -39,7 +39,7 @@ Module(
ops=[ ops=[
Gt()], Gt()],
comparators=[ comparators=[
Constant(value=0.0)]))], Constant(value=0)]))],
decorator_list=[], decorator_list=[],
returns=Constant(value='bool')), returns=Constant(value='bool')),
FunctionDef( FunctionDef(
@@ -128,8 +128,8 @@ Module(
func=Call( func=Call(
func=Name(id='__midas_in_range__'), func=Name(id='__midas_in_range__'),
args=[ args=[
Constant(value=100.0), Constant(value=100),
Constant(value=200.0)], Constant(value=200)],
keywords=[]), keywords=[]),
args=[ args=[
Name(id='_')], Name(id='_')],
@@ -142,8 +142,8 @@ Module(
value=Call( value=Call(
func=Name(id='__midas_in_range__'), func=Name(id='__midas_in_range__'),
args=[ args=[
Constant(value=0.0), Constant(value=0),
Constant(value=18.0)], Constant(value=18)],
keywords=[])), keywords=[])),
FunctionDef( FunctionDef(
name='__midas_p3__', name='__midas_p3__',
@@ -208,7 +208,7 @@ Module(
args=[ args=[
Name(id='__midas_a0__')], Name(id='__midas_a0__')],
keywords=[]), keywords=[]),
msg=Constant(value="02_constraints.py:L5:10: ConstraintError: Value does not fit constraint '_ > 0.0'")), msg=Constant(value="02_constraints.py:L5:10: ConstraintError: Value does not fit constraint '_ > 0'")),
Assign( Assign(
targets=[ targets=[
Name(id='t1')], Name(id='t1')],
@@ -284,7 +284,7 @@ Module(
args=[ args=[
Name(id='__midas_a2__')], Name(id='__midas_a2__')],
keywords=[]), keywords=[]),
msg=Constant(value="02_constraints.py:L7:10: ConstraintError: Value does not fit constraint 'in_range(100.0, 200.0)(_)'")), msg=Constant(value="02_constraints.py:L7:10: ConstraintError: Value does not fit constraint 'in_range(100, 200)(_)'")),
Assign( Assign(
targets=[ targets=[
Name(id='t3')], Name(id='t3')],

View File

@@ -0,0 +1,49 @@
# type: ignore
# ruff: disable[F821]
import module1
import module2 as alias2
from module3 import submodule3
from module4 import submodule4 as alias4
a: int
b: Generic[int]
c: Generic2[int, float]
d: Frame[a:int, b:float]
e = 3
f: int = 4
g = []
h = [1, 0.1, " ", None, False, True]
i = {}
j = {"a": 1, "b": 2}
k = {"c": 3, **j}
l = cast(int, a)
m = unsafe_cast(int, a)
def n(a: int, /, b: float, *, c: str) -> Any:
return
def o(a: int = 1, /, b: float = 2.0, *, c: str = "") -> Any:
return 1
for i in h:
pass
if e == f:
pass
elif f == g:
pass
else:
pass
p = +a + -b - ~c * d / e**f
q = not (a and b) or c
r = a & b | c ^ d
s = a.b.c
t = a[b][c, d][e:f]
u = a(b)(c=d)

View File

@@ -0,0 +1,249 @@
Module(
body=[
Import(
names=[
alias(name='module1')]),
Import(
names=[
alias(name='module2', asname='alias2')]),
ImportFrom(
module='module3',
names=[
alias(name='submodule3')],
level=0),
ImportFrom(
module='module4',
names=[
alias(name='submodule4', asname='alias4')],
level=0),
Assign(
targets=[
Name(id='e')],
value=Constant(value=3)),
Assign(
targets=[
Name(id='f')],
value=Constant(value=4)),
Assign(
targets=[
Name(id='g')],
value=List(elts=[])),
Assign(
targets=[
Name(id='h')],
value=List(
elts=[
Constant(value=1),
Constant(value=0.1),
Constant(value=' '),
Constant(value=None),
Constant(value=False),
Constant(value=True)])),
Assign(
targets=[
Name(id='i')],
value=Dict(keys=[], values=[])),
Assign(
targets=[
Name(id='j')],
value=Dict(
keys=[
Constant(value='a'),
Constant(value='b')],
values=[
Constant(value=1),
Constant(value=2)])),
Assign(
targets=[
Name(id='k')],
value=Dict(
keys=[
Constant(value='c'),
None],
values=[
Constant(value=3),
Name(id='j')])),
Assign(
targets=[
Name(id='__midas_a0__')],
value=Name(id='a')),
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='__midas_a0__'),
Name(id='int')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='03_simple_syntax.py:L21:5: 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 int')])),
Assign(
targets=[
Name(id='l')],
value=Name(id='__midas_a0__')),
Delete(
targets=[
Name(id='__midas_a0__')]),
Assign(
targets=[
Name(id='m')],
value=Name(id='a')),
FunctionDef(
name='n',
args=arguments(
posonlyargs=[
arg(arg='a')],
args=[
arg(arg='b')],
kwonlyargs=[
arg(arg='c')],
kw_defaults=[
None],
defaults=[]),
body=[
Return()],
decorator_list=[]),
FunctionDef(
name='o',
args=arguments(
posonlyargs=[
arg(arg='a')],
args=[
arg(arg='b')],
kwonlyargs=[
arg(arg='c')],
kw_defaults=[
Constant(value='')],
defaults=[
Constant(value=1),
Constant(value=2.0)]),
body=[
Return(
value=Constant(value=1))],
decorator_list=[]),
For(
target=Name(id='i'),
iter=Name(id='h'),
body=[
Pass()],
orelse=[]),
If(
test=Compare(
left=Name(id='e'),
ops=[
Eq()],
comparators=[
Name(id='f')]),
body=[
Pass()],
orelse=[
If(
test=Compare(
left=Name(id='f'),
ops=[
Eq()],
comparators=[
Name(id='g')]),
body=[
Pass()],
orelse=[])]),
Assign(
targets=[
Name(id='p')],
value=BinOp(
left=BinOp(
left=UnaryOp(
op=UAdd(),
operand=Name(id='a')),
op=Add(),
right=UnaryOp(
op=USub(),
operand=Name(id='b'))),
op=Sub(),
right=BinOp(
left=BinOp(
left=UnaryOp(
op=Invert(),
operand=Name(id='c')),
op=Mult(),
right=Name(id='d')),
op=Div(),
right=BinOp(
left=Name(id='e'),
op=Pow(),
right=Name(id='f'))))),
Assign(
targets=[
Name(id='q')],
value=BoolOp(
op=Or(),
values=[
UnaryOp(
op=Not(),
operand=BoolOp(
op=And(),
values=[
Name(id='a'),
Name(id='b')])),
Name(id='c')])),
Assign(
targets=[
Name(id='r')],
value=BinOp(
left=BinOp(
left=Name(id='a'),
op=BitAnd(),
right=Name(id='b')),
op=BitOr(),
right=BinOp(
left=Name(id='c'),
op=BitXor(),
right=Name(id='d')))),
Assign(
targets=[
Name(id='s')],
value=Attribute(
value=Attribute(
value=Name(id='a'),
attr='b'),
attr='c')),
Assign(
targets=[
Name(id='t')],
value=Subscript(
value=Subscript(
value=Subscript(
value=Name(id='a'),
slice=Name(id='b')),
slice=Tuple(
elts=[
Name(id='c'),
Name(id='d')])),
slice=Slice(
lower=Name(id='e'),
upper=Name(id='f')))),
Assign(
targets=[
Name(id='u')],
value=Call(
func=Call(
func=Name(id='a'),
args=[
Name(id='b')],
keywords=[]),
args=[],
keywords=[
keyword(
arg='c',
value=Name(id='d'))]))],
type_ignores=[])

View File

@@ -0,0 +1,15 @@
predicate is_positive(v: float) = v >= 0
type Positive = float where is_positive(_)
alias T1 = Frame[
a: int
]
alias T2 = Frame[
a: int,
b: str,
c: Positive,
d: float where is_positive(_)
]
alias Positives = Column[Positive]

View File

@@ -0,0 +1,13 @@
from typing import Any
from midas import T1, T2, Column, Positive, Positives, cast
o: Any = object()
df1 = cast(T1, o)
df2 = cast(T2, o)
df1 + df2
col1: Positives = df2["c"]
col2 = cast(Column[Positive], col1)

View File

@@ -0,0 +1,731 @@
Module(
body=[
FunctionDef(
name='__midas_column_same_length__',
args=arguments(
posonlyargs=[],
args=[
arg(arg='column1'),
arg(arg='column2')],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Compare(
left=Call(
func=Name(id='len'),
args=[
Attribute(
value=Name(id='column1'),
attr='index')],
keywords=[]),
ops=[
Eq()],
comparators=[
Call(
func=Name(id='len'),
args=[
Attribute(
value=Name(id='column2'),
attr='index')],
keywords=[])]))],
decorator_list=[]),
FunctionDef(
name='__midas_frame_same_length__',
args=arguments(
posonlyargs=[],
args=[
arg(arg='frame1'),
arg(arg='frame2')],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Return(
value=Compare(
left=Call(
func=Name(id='len'),
args=[
Attribute(
value=Name(id='frame1'),
attr='index')],
keywords=[]),
ops=[
Eq()],
comparators=[
Call(
func=Name(id='len'),
args=[
Attribute(
value=Name(id='frame2'),
attr='index')],
keywords=[])]))],
decorator_list=[]),
FunctionDef(
name='__midas_is_column__',
args=arguments(
posonlyargs=[
arg(arg='obj')],
args=[],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Import(
names=[
alias(name='pandas', asname='pd')]),
Return(
value=Call(
func=Name(id='isinstance'),
args=[
Name(id='obj'),
Attribute(
value=Name(id='pd'),
attr='Series')],
keywords=[]))],
decorator_list=[],
returns=Name(id='bool')),
FunctionDef(
name='__midas_is_dataframe__',
args=arguments(
posonlyargs=[
arg(arg='obj')],
args=[],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Import(
names=[
alias(name='pandas', asname='pd')]),
Return(
value=Call(
func=Name(id='isinstance'),
args=[
Name(id='obj'),
Attribute(
value=Name(id='pd'),
attr='DataFrame')],
keywords=[]))],
decorator_list=[],
returns=Name(id='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=[
GtE()],
comparators=[
Constant(value=0)]))],
decorator_list=[],
returns=Constant(value='bool')),
FunctionDef(
name='__midas_p0__',
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_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')),
ImportFrom(
module='typing',
names=[
alias(name='Any')],
level=0),
ImportFrom(
module='midas',
names=[
alias(name='T1'),
alias(name='T2'),
alias(name='Column'),
alias(name='Positive'),
alias(name='Positives'),
alias(name='cast')],
level=0),
Assign(
targets=[
Name(id='o')],
value=Call(
func=Name(id='object'),
args=[],
keywords=[])),
Assign(
targets=[
Name(id='__midas_a0__')],
value=Name(id='o')),
Assert(
test=Call(
func=Name(id='__midas_is_dataframe__'),
args=[
Name(id='__midas_a0__')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L7:7: 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 Frame[a: Column[int]]: Not a dataframe')])),
Assert(
test=Compare(
left=Constant(value='a'),
ops=[
In()],
comparators=[
Name(id='__midas_a0__')]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L7:7: 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 Frame[a: Column[int]]: Missing column 'a'")])),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Subscript(
value=Name(id='__midas_a0__'),
slice=Constant(value='a'))],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L7:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Subscript(
value=Name(id='__midas_a0__'),
slice=Constant(value='a'))],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to Column[int], in column 'a': Not a column")])),
For(
target=Name(id='value'),
iter=Subscript(
value=Name(id='__midas_a0__'),
slice=Constant(value='a')),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='int')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L7:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to int, in column 'a'")]))],
orelse=[]),
Assign(
targets=[
Name(id='df1')],
value=Name(id='__midas_a0__')),
Delete(
targets=[
Name(id='__midas_a0__')]),
Assign(
targets=[
Name(id='__midas_a1__')],
value=Name(id='o')),
Assert(
test=Call(
func=Name(id='__midas_is_dataframe__'),
args=[
Name(id='__midas_a1__')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: 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 Frame[a: Column[int], b: Column[str], c: Column[Positive], d: Column[float where is_positive(_)]]: Not a dataframe')])),
Assert(
test=Compare(
left=Constant(value='a'),
ops=[
In()],
comparators=[
Name(id='__midas_a1__')]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: 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 Frame[a: Column[int], b: Column[str], c: Column[Positive], d: Column[float where is_positive(_)]]: Missing column 'a'")])),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='a'))],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='a'))],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to Column[int], in column 'a': Not a column")])),
For(
target=Name(id='value'),
iter=Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='a')),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='int')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to int, in column 'a'")]))],
orelse=[]),
Assert(
test=Compare(
left=Constant(value='b'),
ops=[
In()],
comparators=[
Name(id='__midas_a1__')]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: 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 Frame[a: Column[int], b: Column[str], c: Column[Positive], d: Column[float where is_positive(_)]]: Missing column 'b'")])),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='b'))],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='b'))],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to Column[str], in column 'b': Not a column")])),
For(
target=Name(id='value'),
iter=Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='b')),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='str')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to str, in column 'b'")]))],
orelse=[]),
Assert(
test=Compare(
left=Constant(value='c'),
ops=[
In()],
comparators=[
Name(id='__midas_a1__')]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: 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 Frame[a: Column[int], b: Column[str], c: Column[Positive], d: Column[float where is_positive(_)]]: Missing column 'c'")])),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='c'))],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='c'))],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to Column[Positive], in column 'c': Not a column")])),
For(
target=Name(id='value'),
iter=Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='c')),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to float, in column 'c'")])),
Assert(
test=Call(
func=Name(id='__midas_p0__'),
args=[
Name(id='value')],
keywords=[]),
msg=Constant(value="04_frames.py:L8:7: ConstraintError: Value does not fit constraint 'is_positive(_)', in column 'c'"))],
orelse=[]),
Assert(
test=Compare(
left=Constant(value='d'),
ops=[
In()],
comparators=[
Name(id='__midas_a1__')]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: 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 Frame[a: Column[int], b: Column[str], c: Column[Positive], d: Column[float where is_positive(_)]]: Missing column 'd'")])),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='d'))],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='d'))],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to Column[float where is_positive(_)], in column 'd': Not a column")])),
For(
target=Name(id='value'),
iter=Subscript(
value=Name(id='__midas_a1__'),
slice=Constant(value='d')),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L8:7: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=" to float, in column 'd'")])),
Assert(
test=Call(
func=Name(id='__midas_p1__'),
args=[
Name(id='value')],
keywords=[]),
msg=Constant(value="04_frames.py:L8:7: ConstraintError: Value does not fit constraint 'is_positive(_)', in column 'd'"))],
orelse=[]),
Assign(
targets=[
Name(id='df2')],
value=Name(id='__midas_a1__')),
Delete(
targets=[
Name(id='__midas_a1__')]),
Assign(
targets=[
Name(id='__midas_a2__')],
value=Name(id='df1')),
Assign(
targets=[
Name(id='__midas_a3__')],
value=Name(id='df2')),
Assert(
test=Call(
func=Name(id='__midas_column_same_length__'),
args=[
Name(id='__midas_a2__'),
Name(id='__midas_a3__')],
keywords=[]),
msg=Constant(value='04_frames.py:L10:1: AssertionError: Columns must have the same length')),
Assign(
targets=[
Name(id='__midas_a4__')],
value=Name(id='__midas_a2__')),
Assign(
targets=[
Name(id='__midas_a5__')],
value=Name(id='__midas_a3__')),
Assert(
test=Call(
func=Name(id='__midas_frame_same_length__'),
args=[
Name(id='__midas_a4__'),
Name(id='__midas_a5__')],
keywords=[]),
msg=Constant(value='04_frames.py:L10:1: AssertionError: DataFrames must have the same length')),
Expr(
value=BinOp(
left=Name(id='__midas_a2__'),
op=Add(),
right=Name(id='__midas_a3__'))),
Delete(
targets=[
Name(id='__midas_a2__'),
Name(id='__midas_a3__'),
Name(id='__midas_a4__'),
Name(id='__midas_a5__')]),
Assign(
targets=[
Name(id='col1')],
value=Subscript(
value=Name(id='df2'),
slice=Constant(value='c'))),
Assign(
targets=[
Name(id='__midas_a6__')],
value=Name(id='col1')),
Assert(
test=Call(
func=Name(id='__midas_is_column__'),
args=[
Name(id='__midas_a6__')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L13:8: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='__midas_a6__')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to Column[Positive]: Not a column')])),
For(
target=Name(id='value'),
iter=Name(id='__midas_a6__'),
body=[
Assert(
test=Call(
func=Name(id='isinstance'),
args=[
Name(id='value'),
Name(id='float')],
keywords=[]),
msg=JoinedStr(
values=[
Constant(value='04_frames.py:L13:8: CastError: Cannot cast '),
FormattedValue(
value=Attribute(
value=Call(
func=Name(id='type'),
args=[
Name(id='value')],
keywords=[]),
attr='__name__'),
conversion=-1),
Constant(value=' to float')])),
Assert(
test=Call(
func=Name(id='__midas_p0__'),
args=[
Name(id='value')],
keywords=[]),
msg=Constant(value="04_frames.py:L13:8: ConstraintError: Value does not fit constraint 'is_positive(_)'"))],
orelse=[]),
Assign(
targets=[
Name(id='col2')],
value=Name(id='__midas_a6__')),
Delete(
targets=[
Name(id='__midas_a6__')])],
type_ignores=[])

View File

@@ -2,33 +2,29 @@
type Custom = float type Custom = float
// Simple custom types with constraints // Simple custom types with constraints
type Latitude = float where (-90 <= _ <= 90) type Latitude = float where (-90 <= _ & _ <= 90)
type Longitude = float where (-180 <= _ <= 180) type Longitude = float where (-180 <= _ & _ <= 180)
// Generic custom type (a Difference of T is derived from T, e.g. a difference of floats is a float // Generic custom type (a Difference of T is derived from T, e.g. a difference of floats is a float
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,20 +34,30 @@ 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
prop age: Optional[int where (0 <= _ < 150)] prop age: int where (0 <= _ & _ < 150)
// Property referencing a predicate // Property referencing a predicate
prop height: float where StrictlyPositive prop height: float where StrictlyPositive(_)
prop home: GeoLocation prop home: GeoLocation
} }

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