├── .github ├── dependabot.yml └── workflows │ ├── doc.yml │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── Makefile ├── annotated_types.md ├── changelog.md ├── conf.py ├── configuration.md ├── design.md ├── faq.md ├── glossary.rst ├── index.rst ├── make.bat ├── reference │ ├── annotations.rst │ ├── ast_annotator.rst │ ├── extensions.rst │ ├── name_check_visitor.rst │ ├── runtime.rst │ ├── signature.rst │ ├── stacked_scopes.rst │ └── value.rst ├── requirements.txt ├── type_evaluation.md └── typesystem.md ├── pycroscope ├── __init__.py ├── __main__.py ├── analysis_lib.py ├── annotated_types.py ├── annotations.py ├── arg_spec.py ├── ast_annotator.py ├── asynq_checker.py ├── asynq_test.toml ├── asynq_tests.py ├── attributes.py ├── boolability.py ├── checker.py ├── error_code.py ├── extensions.py ├── find_unused.py ├── format_strings.py ├── functions.py ├── implementation.py ├── importer.py ├── input_sig.py ├── maybe_asynq.py ├── name_check_visitor.pxd ├── name_check_visitor.py ├── node_visitor.py ├── options.py ├── patma.py ├── predicates.py ├── reexport.py ├── relations.py ├── runtime.py ├── safe.py ├── shared_options.py ├── signature.py ├── stacked_scopes.py ├── stubs │ ├── README │ ├── _pycroscope_tests-stubs │ │ ├── aliases.pyi │ │ ├── args.pyi │ │ ├── callable.pyi │ │ ├── cdata.pyi │ │ ├── contextmanager.pyi │ │ ├── defaults.pyi │ │ ├── deprecated.pyi │ │ ├── evaluated.pyi │ │ ├── initnew.pyi │ │ ├── nested.pyi │ │ ├── overloaded.pyi │ │ ├── paramspec.pyi │ │ ├── posonly.pyi │ │ ├── recursion.pyi │ │ ├── self.pyi │ │ ├── tsself.pyi │ │ ├── typeddict.pyi │ │ └── typevar.pyi │ └── pycroscope-stubs │ │ └── extensions.pyi ├── suggested_type.py ├── test.toml ├── test_analysis_lib.py ├── test_annotated_types.py ├── test_annotations.py ├── test_arg_spec.py ├── test_ast_annotator.py ├── test_async_await.py ├── test_asynq.py ├── test_asynq_checker.py ├── test_attributes.py ├── test_boolability.py ├── test_config.py ├── test_definite_value.py ├── test_deprecated.py ├── test_enum.py ├── test_error_code.py ├── test_extensions.py ├── test_format_strings.py ├── test_functions.py ├── test_generators.py ├── test_implementation.py ├── test_import.py ├── test_inference_helpers.py ├── test_literal_string.py ├── test_name_check_visitor.py ├── test_never.py ├── test_node_visitor.py ├── test_operations.py ├── test_override.py ├── test_patma.py ├── test_pep673.py ├── test_recursion.py ├── test_relations.py ├── test_runtime.py ├── test_self.py ├── test_signature.py ├── test_stacked_scopes.py ├── test_suggested_type.py ├── test_thrift_enum.py ├── test_try.py ├── test_type_aliases.py ├── test_type_evaluation.py ├── test_type_object.py ├── test_typeddict.py ├── test_typeis.py ├── test_typeshed.py ├── test_typevar.py ├── test_unsafe_comparison.py ├── test_value.py ├── test_yield_checker.py ├── tests.py ├── type_evaluation.py ├── type_object.py ├── typeshed.py ├── typevar.py ├── value.py └── yield_checker.py ├── pyproject.toml └── requirements.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | # We want to run on external PRs, but not on our own internal PRs as they'll be run 8 | # by the push to the branch. Without this if check, checks are duplicated since 9 | # internal PRs match both the push and pull_request events. 10 | if: 11 | github.event_name == 'push' || github.event.pull_request.head.repo.full_name != 12 | github.repository 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest] 18 | 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up latest Python 24 | uses: actions/setup-python@v5 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip setuptools wheel 29 | python -m pip install -e ".[d]" 30 | python -m pip install -r "docs/requirements.txt" 31 | - name: Build documentation 32 | run: sphinx-build -a -b html -W --keep-going docs/ docs/_build 33 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | precommit: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Set up latest Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.13" 16 | 17 | - name: Run pre-commit hooks 18 | uses: pre-commit/action@v3.0.1 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Based on 2 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: Publish Python distributions to PyPI 5 | 6 | on: 7 | release: 8 | types: [published] 9 | push: 10 | branches: [main] 11 | pull_request: 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | build: 22 | name: Build and publish Python distributions to PyPI 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.x" 30 | - name: Install pypa/build 31 | run: >- 32 | python -m pip install --upgrade build 33 | - name: Build a binary wheel and a source tarball 34 | run: >- 35 | python -m 36 | build 37 | --sdist 38 | --wheel 39 | --outdir dist/ 40 | . 41 | - name: Store the distribution packages 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: python-package-distributions 45 | path: dist/ 46 | 47 | publish: 48 | name: Publish distribution to PyPI 49 | if: github.event_name == 'release' 50 | needs: 51 | - build 52 | runs-on: ubuntu-latest 53 | environment: 54 | name: publish 55 | url: https://pypi.org/p/pycroscope 56 | permissions: 57 | id-token: write 58 | steps: 59 | - name: Download all the dists 60 | uses: actions/download-artifact@v4 61 | with: 62 | name: python-package-distributions 63 | path: dist/ 64 | - name: Publish distribution to PyPI 65 | uses: pypa/gh-action-pypi-publish@release/v1 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | pull_request: 8 | paths-ignore: 9 | - .gitignore 10 | - LICENSE 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | # - "3.14-dev" wait for beta 2 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | allow-prereleases: true 32 | - name: install dependencies 33 | run: pip install .[tests,asynq] pytest 34 | - name: test 35 | run: pytest pycroscope 36 | 37 | test-no-deps: 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-python@v5 43 | with: 44 | python-version: 3.13 45 | - name: install 46 | run: pip install . pytest 47 | - name: test-no-deps 48 | run: pytest pycroscope 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | pycroscope.egg-info/ 3 | *.pyc 4 | build/ 5 | *.code-workspace 6 | .vscode/ 7 | .venv 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.8 4 | hooks: 5 | - id: ruff 6 | args: [--fix] 7 | 8 | - repo: https://github.com/psf/black 9 | rev: 25.1.0 10 | hooks: 11 | - id: black 12 | language_version: python3.13 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v4.4.0 16 | hooks: 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | 20 | - repo: https://github.com/pre-commit/mirrors-prettier 21 | rev: v3.1.0 22 | hooks: 23 | - id: prettier 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.11" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Welcome! We'd like to make contributing to pycroscope as painless 2 | as possible. Here is a quick guide. 3 | 4 | It's useful to have a virtual environment to work in. I use 5 | commands like these: 6 | 7 | ``` 8 | $ cd pycroscope 9 | $ python3 -m venv .venv 10 | $ source .venv/bin/activate 11 | $ pip install -r requirements.txt 12 | $ pip install -e . 13 | ``` 14 | 15 | ## Black 16 | 17 | The code is formatted using [_Black_](https://black.readthedocs.io). 18 | You can run the formatter with: 19 | 20 | ``` 21 | $ black pycroscope 22 | ``` 23 | 24 | ## ruff 25 | 26 | We use [ruff](https://docs.astral.sh/ruff/) as a linter and import sorter: 27 | 28 | ``` 29 | $ ruff check pycroscope 30 | ``` 31 | 32 | ## Unit tests 33 | 34 | The unit tests are run with [pytest](https://docs.pytest.org/): 35 | 36 | ``` 37 | $ pytest -v pycroscope 38 | ``` 39 | 40 | Running all of the tests takes a few minutes, so I often use the 41 | `-k` option to select only the tests I am currently working on. 42 | For example: 43 | 44 | ``` 45 | $ pytest -v pycroscope -k PEP673 46 | ``` 47 | 48 | We run tests on all supported Python versions on GitHub Actions, 49 | but usually I don't bother when testing locally. If necessary, you 50 | can install all supported versions with a tool like 51 | [pyenv](https://github.com/pyenv/pyenv). 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pycroscope 2 | 3 | Pycroscope is a semi-static type checker for Python code. Like a static type checker (e.g., mypy or pyright), it 4 | detects type errors in your code so bugs can be found before they reach production. Unlike such tools, however, 5 | it imports the modules it type checks, enabling pycroscope to understand many dynamic constructs that other type 6 | checkers will reject. This property also makes it possible to extend pycroscope with plugins that interact directly 7 | with your code. 8 | 9 | Pycroscope is a friendly fork of [pyanalyze](https://github.com/quora/pyanalyze). 10 | 11 | ## Usage 12 | 13 | You can install pycroscope with: 14 | 15 | ```bash 16 | $ pip install pycroscope 17 | ``` 18 | 19 | Once it is installed, you can run pycroscope on a Python file or package as follows: 20 | 21 | ```bash 22 | $ python -m pycroscope file.py 23 | $ python -m pycroscope package/ 24 | ``` 25 | 26 | But note that this will try to import all Python files it is passed. If you have scripts that perform operations without `if __name__ == "__main__":` blocks, pycroscope may end up executing them. 27 | 28 | In order to run successfully, pycroscope needs to be able to import the code it checks. To make this work you may have to manually adjust Python's import path using the `$PYTHONPATH` environment variable. 29 | 30 | For quick experimentation, you can also use the `-c` option to directly type check a piece of code: 31 | 32 | ``` 33 | $ python -m pycroscope -c 'import typing; typing.reveal_type(1)' 34 | Runtime type is 'int' 35 | 36 | Revealed type is 'Literal[1]' (code: reveal_type) 37 | In at line 1 38 | 1: import typing; typing.reveal_type(1) 39 | ^ 40 | ``` 41 | 42 | ### Configuration 43 | 44 | Pycroscope has a number of command-line options, which you can see by running `python -m pycroscope --help`. Important ones include `-f`, which runs an interactive prompt that lets you examine and fix each error found by pycroscope, and `--enable`/`--disable`, which enable and disable specific error codes. 45 | 46 | Configuration through a `pyproject.toml` file is also supported. See 47 | [the documentation](https://pycroscope.readthedocs.io/en/latest/configuration.html) for 48 | details. 49 | 50 | ### Extending pycroscope 51 | 52 | One of the main ways to extend pycroscope is by providing a specification for a particular function. This allows you to run arbitrary code that inspects the arguments to the function and raises errors if something is wrong. 53 | 54 | As an example, suppose your codebase contains a function `database.run_query()` that takes as an argument a SQL string, like this: 55 | 56 | ```python 57 | database.run_query("SELECT answer, question FROM content") 58 | ``` 59 | 60 | You want to detect when a call to `run_query()` contains syntactically invalid SQL or refers to a non-existent table or column. You could set that up with code like this: 61 | 62 | ```python 63 | from pycroscope.error_code import ErrorCode 64 | from pycroscope.signature import CallContext, Signature, SigParameter 65 | from pycroscope.value import KnownValue, TypedValue, AnyValue, AnySource, Value 66 | 67 | from database import run_query, parse_sql 68 | 69 | 70 | def run_query_impl(ctx: CallContext) -> Value: 71 | sql = ctx.vars["sql"] 72 | if not isinstance(sql, KnownValue) or not isinstance(sql.val, str): 73 | ctx.show_error( 74 | "Argument to run_query() must be a string literal", 75 | ErrorCode.incompatible_call, 76 | ) 77 | return AnyValue(AnySource.error) 78 | 79 | try: 80 | parsed = parse_sql(sql) 81 | except ValueError as e: 82 | ctx.show_error( 83 | f"Invalid sql passed to run_query(): {e}", 84 | ErrorCode.incompatible_call, 85 | ) 86 | return AnyValue(AnySource.error) 87 | 88 | # check that the parsed SQL is valid... 89 | 90 | # pycroscope will use this as the inferred return type for the function 91 | return TypedValue(list) 92 | 93 | 94 | # in pyproject.toml, set: 95 | # known_signatures = [".get_known_argspecs"] 96 | def get_known_argspecs(arg_spec_cache): 97 | return { 98 | # This infers the parameter types and names from the function signature 99 | run_query: arg_spec_cache.get_argspec( 100 | run_query, impl=run_query_impl 101 | ), 102 | # You can also write the signature manually 103 | run_query: Signature.make( 104 | [SigParameter("sql", annotation=TypedValue(str))], 105 | callable=run_query, 106 | impl=run_query_impl, 107 | ), 108 | } 109 | ``` 110 | 111 | ### Supported features 112 | 113 | Pycroscope generally aims to implement [the Python typing spec](https://typing.readthedocs.io/en/latest/spec/index.html), 114 | but support for some features is incomplete. See [the documentation](https://pycroscope.readthedocs.io/en/latest/) 115 | for details. 116 | 117 | ### Ignoring errors 118 | 119 | Sometimes pycroscope gets things wrong and you need to ignore an error it emits. This can be done as follows: 120 | 121 | - Add `# static analysis: ignore` on a line by itself before the line that generates the error. 122 | - Add `# static analysis: ignore` at the end of the line that generates the error. 123 | - Add `# static analysis: ignore` at the top of the file; this will ignore errors in the entire file. 124 | 125 | You can add an error code, like `# static analysis: ignore[undefined_name]`, to ignore only a specific error code. This does not work for whole-file ignores. If the `bare_ignore` error code is turned on, pycroscope will emit an error if you don't specify an error code on an ignore comment. 126 | 127 | Pycroscope does not currently support the standard `# type: ignore` comment syntax. 128 | 129 | ### Python version support 130 | 131 | Pycroscope supports all versions of Python that have not reached end-of-life. Because it imports the code it checks, you have to run it using the same version of Python you use to run your code. 132 | 133 | ## Contributing 134 | 135 | We welcome your contributions. See [CONTRIBUTING.md](https://github.com/JelleZijlstra/pycroscope/blob/master/CONTRIBUTING.md) 136 | for how to get started. 137 | 138 | ## Documentation 139 | 140 | Documentation is available on [GitHub](https://github.com/JelleZijlstra/pycroscope/tree/master/docs). 141 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/annotated_types.md: -------------------------------------------------------------------------------- 1 | # Support for `annotated_types` 2 | 3 | Pycroscope supports the [annotated-types](https://pypi.org/project/annotated-types/) library, which provides a set of common primitives to use in `Annotated` metadata. 4 | 5 | This is useful for restricting the value of an object: 6 | 7 | ```python 8 | from typing_extensions import Annotated 9 | from annotated_types import Gt 10 | 11 | def takes_gt_5(x: Annotated[int, Gt(5)]) -> None: 12 | assert x > 5, "number too small" 13 | 14 | def caller() -> None: 15 | takes_gt_5(6) # ok 16 | takes_gt_5(5) # type checker error 17 | ``` 18 | 19 | Pycroscope enforces these annotations strictly: if it cannot determine whether or 20 | not a value fulfills the predicate, it shows an error. For example, the following 21 | will be rejected: 22 | 23 | ```python 24 | def caller(i: int) -> None: 25 | takes_gt_5(i) # type checker error, as it may be less than 5 26 | ``` 27 | 28 | ## Notes on specific predicates 29 | 30 | Pycroscope infers the interval attributes `Gt`, `Ge`, `Lt`, and `Le` based 31 | on comparisons with literals: 32 | 33 | ```python 34 | def caller(i: int) -> None: 35 | takes_gt_5(i) # error 36 | 37 | if i > 5: 38 | takes_gt_5(i) # accepted 39 | ``` 40 | 41 | Similarly, pycroscope infers the `MinLen` and `MaxLen` attributes after checks 42 | on `len()`. 43 | 44 | For the `MultipleOf` check, pycroscope follows Python semantics: values 45 | are accepted if `value % multiple_of == 0`. 46 | 47 | For the `Timezone` check, support for requiring string-based timezones is not implemented. 48 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Fix incorrect treatment of `ParamSpec` in certain contexts. 6 | - Add basic support for intersection types with `pycroscope.extensions.Intersection`. 7 | - Fix crash on checking the boolability of certain complex types. 8 | - Support subtyping between more kinds of heterogeneous tuples. 9 | - Treat `bool` and enum classes as equivalent to the union of all their 10 | members. 11 | - Add support for unpacked tuple types using native unpack syntax (e.g., 12 | `tuple[int, *tuple[int, ...]]`; the alternative syntax with `Unpack` 13 | was already supported). 14 | - `assert_type()` now checks for type equivalence, not equality of the 15 | internal representation of the type. 16 | - Improve parsing of annotation expressions as distinct from type expressions. 17 | Fixes crash on certain combinations of type qualifiers. 18 | - Improve support for recursive type aliases 19 | - Correctly handle type aliases and other types with fallbacks in more places 20 | - Fix edge case in `TypeIs` type narrowing with tuple types 21 | - Rewrite the implementation of assignability to be more in line with the typing 22 | specification 23 | - Fix handling of `ClassVar` annotations in stubs 24 | - Fix operations on `ParamSpecArgs` and `ParamSpecKwargs` values 25 | - Fix incorrect assignability relation between `TypedDict` types and 26 | `dict[Any, Any]`; the spec requires that these be considered incompatible 27 | - Fix bug where certain binary operations were incorrectly inferred as Any 28 | - Fix bug with generic self types on overloaded methods in stubs 29 | - Add support for NewTypes over any type, instead of just simple types 30 | - Add support for a concise output format (`--output-format concise`) 31 | - Fix treatment of aliases created through the `type` statement in union 32 | assignability and in iteration 33 | - Make `asynq` and `qcore` optional dependencies 34 | - Fix use of aliases created through the `type` statement in boolean conditions 35 | 36 | ## Version 0.1.0 (May 3, 2025) 37 | 38 | First release under the pycroscope name. 39 | See [the pyanalyze docs](https://github.com/quora/pyanalyze/blob/master/docs/changelog.md) 40 | for the previous changelog. 41 | 42 | Changes relative to pyanalyze 0.13.1: 43 | 44 | - Update PEP 728 support to the latest version, using the `extra_items=` 45 | class argument instead of an `__extra_items__` key in the dict. 46 | - Add support for Python 3.13 47 | - Drop support for Python 3.8 48 | - Flag invalid regexes in arguments to functions like `re.search`. 49 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | from pkg_resources import get_distribution 17 | 18 | sys.path.insert(0, os.path.abspath("..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "pycroscope" 24 | copyright = "2021, Jelle Zijlstra" 25 | author = "Jelle Zijlstra" 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = get_distribution("pycroscope").version 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ["sphinx.ext.autodoc"] 37 | extensions = [ 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.intersphinx", 40 | "sphinx.ext.napoleon", 41 | "myst_parser", 42 | ] 43 | 44 | # If you need extensions of a certain version or higher, list them here. 45 | needs_extensions = {"myst_parser": "0.13.7"} 46 | 47 | 48 | autodoc_member_order = "bysource" 49 | autodoc_default_options = {"inherited-members": False, "member-order": "bysource"} 50 | autodoc_inherit_docstrings = False 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ["_templates"] 54 | 55 | # List of patterns, relative to source directory, that match files and 56 | # directories to ignore when looking for source files. 57 | # This pattern also affects html_static_path and html_extra_path. 58 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 59 | 60 | 61 | # -- Options for HTML output ------------------------------------------------- 62 | 63 | # The theme to use for HTML and HTML Help pages. See the documentation for 64 | # a list of builtin themes. 65 | # 66 | html_theme = "alabaster" 67 | 68 | # Add any paths that contain custom static files (such as style sheets) here, 69 | # relative to this directory. They are copied after the builtin static files, 70 | # so a file named "default.css" will overwrite the builtin "default.css". 71 | html_static_path = [] 72 | 73 | # The suffix(es) of source filenames. 74 | # You can specify multiple suffix as a list of string: 75 | source_suffix = [".rst", ".md"] 76 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The preferred way to configure pycroscope is using the 4 | `pyproject.toml` configuration file: 5 | 6 | ```toml 7 | [tool.pycroscope] 8 | # Paths pycroscope should check by default 9 | paths = ["my_module/"] 10 | # Paths to import from 11 | import_paths = ["."] 12 | 13 | # Enable or disable some checks 14 | possibly_undefined_name = true 15 | duplicate_dict_key = false 16 | 17 | # But re-enable it for a specific module 18 | [[tool.pycroscope.overrides]] 19 | module = "my_module.submodule" 20 | duplicate_dict_key = true 21 | ``` 22 | 23 | It is recommended to always set the following configuration options: 24 | 25 | - _paths_: A list of paths (relative to the location of the `pyproject.toml` file) that pycroscope should check by default. 26 | - _import_paths_: A list of paths (also relative to the configuration file) that pycroscope should use as roots when trying to import files it is checking. If this is not set, pycroscope will use entries from `sys.path`, which may produce unexpected results. 27 | 28 | Other supported configuration options are listed below. 29 | 30 | Almost all configuration options can be overridden for individual modules or packages. To set a module-specific configuration, add an entry to the `tool.pycroscope.overrides` list (as in the example above), and set the `module` key to the fully qualified name of the module or package. 31 | 32 | To see the current value of all configuration options, pass the `--display-options` command-line option: 33 | 34 | ``` 35 | $ python -m pycroscope --config-file pyproject.toml --display-options 36 | Options: 37 | add_import (value: True) 38 | ... 39 | ``` 40 | 41 | To extend another configuration file, use the `extend_config` key: 42 | 43 | ```toml 44 | [tool.pycroscope] 45 | extend_config = "../path/to/other/pyproject.toml" 46 | ``` 47 | 48 | Options set in the included config file have lower priority. 49 | 50 | Most configuration options can also be set on the command line. Run 51 | `pycroscope --help` to see these options. 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently asked questions 2 | 3 | ## Why is it named pycroscope? 4 | 5 | It's like putting your Python code under a microscope and seeing everything that's 6 | happening. 7 | 8 | ## Why another typechecker? 9 | 10 | Pycroscope is a fork of [pyanalyze](https://github.com/quora/pyanalyze), a type 11 | checker developed at Quora. It started as an internal tool somewhere between a linter and a type 12 | checker, but it proved very useful in dealing with [asynq](https://github.com/quora/asynq), 13 | our asynchronous programming framework. This framework uses generators in an unusual way 14 | and pyanalyze made it possible to detect several tricky mistakes in asynq code statically. 15 | 16 | For Quora, asynq support remained important, but pyanalyze's architecture has also allowed us 17 | to perform numerous other static checks to make its codebase safer. For example, they use 18 | pycroscope to help keep the codebase safe against SQL injections, to enforce that names of 19 | A/B tests are valid, and to enforce that UI strings are translated correctly. 20 | 21 | I (Jelle Zijlstra) was the primary author of pyanalyze, but I left Quora in 2025. 22 | I continued using the project in some personal projects, and I think the ideas are worth 23 | developing further, so I created a fork called pycroscope so I can continue to develop 24 | the type checker under my own account. 25 | 26 | ## What makes pycroscope different? 27 | 28 | The biggest architectural difference between pycroscope and other Python type checkers 29 | is that pycroscope imports the code it checks, while other checkers purely look at the 30 | source code. This allows features that are very 31 | difficult to achieve with a fully static type checker: 32 | 33 | - pycroscope requires no special casing to understand the semantics of the `@dataclass` 34 | decorator, which creates a synthesized `__init__` method. It simply inspects the 35 | signature of the generated method and uses that for type checking. In general, 36 | many dynamic constructs unsupported by other type checkers will work immediately with 37 | pycroscope. 38 | - pycroscope can call back into user code to customize type checking behavior. For example, 39 | the `CustomCheck` extension provides a way for user code to get very precise control 40 | over type checking behavior. Possible use cases include allowing only literal values, 41 | disallowing usage of `Any` for specific APIs, and allowing only values that can be 42 | pickled at runtime. 43 | 44 | But pycroscope is still a static checker, and it has some advantages over a dynamic 45 | (runtime) typechecker: 46 | 47 | - All code paths are checked, not just the ones that are hit in a particular run. 48 | - The type system can carry around more information than just the runtime type of a 49 | value. For example, pycroscope supports `NewType` wrappers around runtime types. 50 | - pycroscope can use type stubs such as those in 51 | [typeshed](https://github.com/python/typeshed) for type checking. 52 | 53 | In addition, pycroscope checks each module mostly independently, keeping the AST for only 54 | one module in memory at once and using runtime function and module objects for computing 55 | signatures and types. This reduces memory usage and makes it easier to deal with circular 56 | dependencies. 57 | 58 | However, this approach also has some disadvantages: 59 | 60 | - It is difficult to engineer an incremental mode, because that would require reloading 61 | imported modules. At Quora, we run pycroscope locally on changed files only, which is 62 | much faster than running it on the entire codebase but does not catch issues where a 63 | change breaks type checking in an upstream module. 64 | - Scripts that do work on import cannot be usefully checked; you must guard execution 65 | with `if __name__ == "__main__":`. 66 | - Undefined attributes on instances of user-defined classes cannot be detected with full 67 | confidence, because the class object does not provide a good way to find out which 68 | attributes are created in the `__init__` method. Currently, pycroscope works around this 69 | by deferring detection of undefined attributes until the entire codebase has been checked, 70 | but this is fragile and not always reliable. 71 | - Initially, the implementation of `@typing.overload` did not provide a way to access the 72 | overloads at runtime, so there was no obvious way to support overloaded functions at runtime. 73 | However, this was fixed in Python 3.11. 74 | 75 | ## When should I use pycroscope? 76 | 77 | If you have a complex Python codebase and want to make sure it stays maintainable and 78 | stable, using a type checker is a great option. There are several options for type 79 | checking Python code (listed in 80 | [the typing documentation](https://typing.readthedocs.io/en/latest/)). Unique advantages 81 | of pycroscope include: 82 | 83 | - Better support for dynamic constructs and configurability thanks to its semi-static 84 | architecture (see "What makes pycroscope different?" above). 85 | - Support for specific checks, such as finding missing f prefixes in f-strings, 86 | finding missing `await` statements, detecting possibly undefined names, and warning 87 | about conditions on objects of suspicious types. 88 | - Type system extensions such as `CustomCheck`, `ParameterTypeGuard`, and `ExternalType`. 89 | - Strong support for checking code that uses the [asynq](https://github.com/quora/asynq) 90 | framework. 91 | 92 | ## What is the history of pycroscope? 93 | 94 | [//]: # "First commit is 6d671398f9de24ee8cc1baccadfed2420cba765c" 95 | 96 | The first incarnation of pycroscope dates back to July 2015 and merely detected undefined 97 | names. It was soon extended to detect incompatible function calls, find unused 98 | code, and perform some basic type checking. 99 | 100 | For context, pytype was started in March 2015 and mypy in 2012. PEP 484 was accepted in 2015. 101 | 102 | The initial version was closely tied to 103 | Quora's internal code structure, but in June 2017 it was split off into its own internal 104 | package, now named pyanalyze. By then, it had strong support for `asynq` and supported 105 | customization through implementation functions. After Quora moved to Python 3, pyanalyze 106 | gained support for parsing type annotations and 107 | its typing support moved closer to the standard type system. 108 | 109 | The first public release was in May 2020. Since then, work has focused on providing full 110 | support for the Python type system, including `Annotated`, `Callable`, `TypeVar`, and 111 | `TypeGuard`. 112 | 113 | After I left Quora in 2025, I created pycroscope in May 2025 as a fork under my 114 | own account. 115 | -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | Glossary 2 | ======== 3 | 4 | .. glossary:: 5 | 6 | constraint 7 | A :class:`pycroscope.stacked_scopes.Constraint` is a way to 8 | narrow down the type of a local variable (or other 9 | :term:`varname`). Constraints are inferred from function 10 | calls like :func:`isinstance`, conditions like ``is None``, 11 | and assertions. 12 | 13 | impl 14 | An impl function is a callback that gets called when the 15 | type checker encounters a particular function. For example, 16 | pycroscope contains an impl function for :func:`isinstance` 17 | that generates a :term:`constraint`. 18 | 19 | phase 20 | Type checking happens in two phases: *collecting* and 21 | *checking*. The collecting phase collects all definitions 22 | and reference; the checking phase checks types. Errors are 23 | usually emitted only during the checking phase. 24 | 25 | value 26 | Pycroscope infers and checks types, but the objects used 27 | to represent types are called :class:`pycroscope.value.Value`. 28 | Values are pervasive throughout the pycroscope codebase. 29 | 30 | varname 31 | The object that a :term:`constraint` operates on. This is 32 | either a string (representing a variable name) or a 33 | :class:`pycroscope.stacked_scopes.CompositeVariable`, 34 | representing an attribute or index on a variable. 35 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pycroscope documentation master file, created by 2 | sphinx-quickstart on Wed May 19 10:42:20 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | pycroscope: A semi-static typechecker 7 | ===================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | faq 14 | configuration 15 | typesystem 16 | design 17 | type_evaluation 18 | annotated_types 19 | glossary 20 | reference/annotations 21 | reference/ast_annotator 22 | reference/extensions 23 | reference/name_check_visitor 24 | reference/runtime 25 | reference/signature 26 | reference/stacked_scopes 27 | reference/value 28 | changelog 29 | 30 | 31 | Indices and tables 32 | ================== 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/reference/annotations.rst: -------------------------------------------------------------------------------- 1 | pycroscope.annotations 2 | ====================== 3 | 4 | .. automodule:: pycroscope.annotations 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/ast_annotator.rst: -------------------------------------------------------------------------------- 1 | pycroscope.ast_annotator 2 | ======================== 3 | 4 | .. automodule:: pycroscope.ast_annotator 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/extensions.rst: -------------------------------------------------------------------------------- 1 | pycroscope.extensions 2 | ===================== 3 | 4 | .. automodule:: pycroscope.extensions 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/name_check_visitor.rst: -------------------------------------------------------------------------------- 1 | pycroscope.name_check_visitor 2 | ============================= 3 | 4 | .. automodule:: pycroscope.name_check_visitor 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/runtime.rst: -------------------------------------------------------------------------------- 1 | pycroscope.runtime 2 | ================== 3 | 4 | .. automodule:: pycroscope.runtime 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/signature.rst: -------------------------------------------------------------------------------- 1 | pycroscope.signature 2 | ==================== 3 | 4 | .. automodule:: pycroscope.signature 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/stacked_scopes.rst: -------------------------------------------------------------------------------- 1 | pycroscope.stacked_scopes 2 | ========================= 3 | 4 | .. automodule:: pycroscope.stacked_scopes 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/reference/value.rst: -------------------------------------------------------------------------------- 1 | pycroscope.value 2 | ================ 3 | 4 | .. automodule:: pycroscope.value 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | myst-parser==4.0.0 2 | Sphinx==7.4.7 3 | pygments==2.19.1 4 | pycroscope 5 | -------------------------------------------------------------------------------- /pycroscope/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | pycroscope is a package for Python static analysis. 4 | 5 | """ 6 | 7 | # ignore unused import errors 8 | # flake8: noqa 9 | 10 | from . import ( 11 | analysis_lib, 12 | annotated_types, 13 | annotations, 14 | arg_spec, 15 | ast_annotator, 16 | asynq_checker, 17 | boolability, 18 | checker, 19 | error_code, 20 | extensions, 21 | find_unused, 22 | functions, 23 | implementation, 24 | input_sig, 25 | name_check_visitor, 26 | node_visitor, 27 | options, 28 | patma, 29 | predicates, 30 | reexport, 31 | safe, 32 | runtime, 33 | shared_options, 34 | signature, 35 | stacked_scopes, 36 | suggested_type, 37 | tests, 38 | type_object, 39 | typeshed, 40 | typevar, 41 | value, 42 | yield_checker, 43 | ) 44 | from .find_unused import used as used 45 | from .value import assert_is_value as assert_is_value, dump_value as dump_value 46 | 47 | # Exposed as APIs 48 | used(ast_annotator) 49 | used(assert_is_value) 50 | used(dump_value) 51 | used(extensions.LiteralOnly) 52 | used(extensions.NoAny) 53 | used(extensions.overload) 54 | used(extensions.evaluated) 55 | used(extensions.is_provided) 56 | used(extensions.is_keyword) 57 | used(extensions.is_positional) 58 | used(extensions.is_of_type) 59 | used(extensions.show_error) 60 | used(extensions.has_extra_keys) 61 | used(extensions.EnumName) 62 | used(extensions.ValidRegex) 63 | used(value.UNRESOLVED_VALUE) # keeping it around for now just in case 64 | used(reexport) 65 | used(patma) 66 | used(checker) 67 | used(suggested_type) 68 | used(options) 69 | used(shared_options) 70 | used(functions) 71 | used(predicates) 72 | used(typevar) 73 | used(annotated_types) 74 | used(runtime) 75 | used(input_sig) 76 | -------------------------------------------------------------------------------- /pycroscope/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pycroscope.name_check_visitor import NameCheckVisitor 5 | 6 | 7 | def main() -> None: 8 | if os.name == "nt": 9 | # Enable ANSI color codes for Windows cmd using this strange workaround 10 | # ( see https://github.com/python/cpython/issues/74261 ) 11 | os.system("") 12 | sys.exit(NameCheckVisitor.main()) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /pycroscope/analysis_lib.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Commonly useful components for static analysis tools. 4 | 5 | """ 6 | 7 | import ast 8 | import importlib 9 | import linecache 10 | import os 11 | import secrets 12 | import sys 13 | import types 14 | from collections.abc import Mapping 15 | from dataclasses import dataclass 16 | from pathlib import Path 17 | from typing import Any, Callable, Optional, TypeVar, Union 18 | 19 | from typing_extensions import ParamSpec 20 | 21 | from pycroscope.find_unused import used 22 | 23 | T = TypeVar("T") 24 | P = ParamSpec("P") 25 | 26 | 27 | def _all_files( 28 | root: Union[str, Path], filter_function: Optional[Callable[[str], bool]] = None 29 | ) -> set[str]: 30 | """Returns the set of all files at the given root. 31 | 32 | Filtered optionally by the filter_function. 33 | 34 | """ 35 | all_files = set() 36 | for dirpath, _, filenames in os.walk(root): 37 | for filename in filenames: 38 | if filter_function is not None and not filter_function(filename): 39 | continue 40 | all_files.add(os.path.join(dirpath, filename)) 41 | return all_files 42 | 43 | 44 | def files_with_extension_from_directory( 45 | extension: str, dirname: Union[str, Path] 46 | ) -> set[str]: 47 | """Finds all files in a given directory with this extension.""" 48 | return _all_files(dirname, filter_function=lambda fn: fn.endswith("." + extension)) 49 | 50 | 51 | def get_indentation(line: str) -> int: 52 | """Returns the indentation of a line of code.""" 53 | if len(line.lstrip()) == 0: 54 | # if it is a newline or a line with just spaces 55 | return 0 56 | return len(line) - len(line.lstrip()) 57 | 58 | 59 | def get_line_range_for_node( 60 | node: Union[ast.stmt, ast.expr], lines: list[str] 61 | ) -> list[int]: 62 | """Returns the lines taken up by a Python ast node. 63 | 64 | lines is a list of code lines for the file the node came from. 65 | 66 | """ 67 | first_lineno = node.lineno 68 | # iterate through all childnodes and find the max lineno 69 | last_lineno = first_lineno + 1 70 | for childnode in ast.walk(node): 71 | end_lineno = getattr(childnode, "end_lineno", None) 72 | if end_lineno is not None: 73 | last_lineno = max(last_lineno, end_lineno) 74 | elif hasattr(childnode, "lineno"): 75 | last_lineno = max(last_lineno, childnode.lineno) 76 | 77 | def is_part_of_same_node(first_line: str, line: str) -> bool: 78 | current_indent = get_indentation(line) 79 | first_indent = get_indentation(first_line) 80 | if current_indent > first_indent: 81 | return True 82 | # because closing parenthesis are at the same indentation 83 | # as the expression 84 | line = line.lstrip() 85 | if len(line) == 0: 86 | # if it is just a newline then the node has likely ended 87 | return False 88 | if current_indent == first_indent and line.lstrip()[0] in [")", "]", "}"]: 89 | return True 90 | # probably part of the same multiline string 91 | for multiline_delim in ('"""', "'''"): 92 | if multiline_delim in first_line and line.strip() == multiline_delim: 93 | return True 94 | return False 95 | 96 | first_line = lines[first_lineno - 1] 97 | 98 | while last_lineno - 1 < len(lines) and is_part_of_same_node( 99 | first_line, lines[last_lineno - 1] 100 | ): 101 | last_lineno += 1 102 | return list(range(first_lineno, last_lineno)) 103 | 104 | 105 | @dataclass 106 | class _FakeLoader: 107 | source: str 108 | 109 | def get_source(self, name: object) -> str: 110 | return self.source 111 | 112 | 113 | def make_module( 114 | code_str: str, extra_scope: Mapping[str, object] = {} 115 | ) -> types.ModuleType: 116 | """Creates a Python module with the given code.""" 117 | # Make the name unique to avoid clobbering the overloads dict 118 | # from pycroscope.extensions.overload. 119 | token = secrets.token_hex() 120 | module_name = f"" 121 | filename = f"{token}.py" 122 | mod = types.ModuleType(module_name) 123 | scope = mod.__dict__ 124 | scope["__name__"] = module_name 125 | scope["__file__"] = filename 126 | scope["__loader__"] = _FakeLoader(code_str) 127 | 128 | # This allows linecache later to retrieve source code 129 | # from this module, which helps the type evaluator. 130 | linecache.lazycache(filename, scope) 131 | scope.update(extra_scope) 132 | code = compile(code_str, filename, "exec") 133 | exec(code, scope) 134 | sys.modules[module_name] = mod 135 | return mod 136 | 137 | 138 | def is_positional_only_arg_name(name: str, class_name: Optional[str] = None) -> bool: 139 | # https://www.python.org/dev/peps/pep-0484/#positional-only-arguments 140 | # Work around Python's name mangling 141 | if class_name is not None: 142 | prefix = f"_{class_name}" 143 | if name.startswith(prefix): 144 | name = name[len(prefix) :] 145 | return name.startswith("__") and not name.endswith("__") 146 | 147 | 148 | def get_attribute_path(node: ast.AST) -> Optional[list[str]]: 149 | """Gets the full path of an attribute lookup. 150 | 151 | For example, the code string "a.model.question.Question" will resolve to the path 152 | ['a', 'model', 'question', 'Question']. This is used for comparing such paths to 153 | lists of functions that we treat specially. 154 | 155 | """ 156 | if isinstance(node, ast.Name): 157 | return [node.id] 158 | elif isinstance(node, ast.Attribute): 159 | root_value = get_attribute_path(node.value) 160 | if root_value is None: 161 | return None 162 | root_value.append(node.attr) 163 | return root_value 164 | else: 165 | return None 166 | 167 | 168 | class override: 169 | """Temporarily overrides an attribute of an object.""" 170 | 171 | def __init__(self, obj: Any, attr: str, value: Any) -> None: 172 | self.obj = obj 173 | self.attr = attr 174 | self.value = value 175 | 176 | def __enter__(self) -> None: 177 | self.old_value = getattr(self.obj, self.attr) 178 | setattr(self.obj, self.attr, self.value) 179 | 180 | def __exit__( 181 | self, 182 | exc_type: Optional[type], 183 | exc_value: Optional[BaseException], 184 | traceback: Optional[types.TracebackType], 185 | ) -> None: 186 | setattr(self.obj, self.attr, self.old_value) 187 | 188 | 189 | def object_from_string(object_reference: str) -> object: 190 | if ":" in object_reference: 191 | module_name, object_name = object_reference.split(":") 192 | mod = importlib.import_module(module_name) 193 | obj = mod 194 | for part in object_name.split("."): 195 | obj = getattr(obj, part) 196 | return obj 197 | else: 198 | parts = object_reference.split(".") 199 | for i in range(len(parts) - 1, 0, -1): 200 | module_path = parts[:i] 201 | object_name = parts[i:] 202 | try: 203 | mod = importlib.import_module(".".join(module_path)) 204 | except ImportError: 205 | if i == 1: 206 | raise 207 | else: 208 | obj = mod 209 | try: 210 | for part in object_name: 211 | obj = getattr(obj, part) 212 | except AttributeError: 213 | if i == 1: 214 | raise 215 | else: 216 | continue 217 | return obj 218 | raise ValueError(f"Could not find object {object_reference}") 219 | 220 | 221 | def get_subclasses_recursively(cls: type[T]) -> set[type[T]]: 222 | """Returns all subclasses of a class recursively.""" 223 | all_subclasses = set() 224 | for subcls in type.__subclasses__(cls): 225 | try: 226 | all_subclasses.add(subcls) 227 | except TypeError: 228 | pass # Ignore unhashable classes 229 | all_subclasses.update(get_subclasses_recursively(subcls)) 230 | return all_subclasses 231 | 232 | 233 | def is_cython_class(cls: type[object]) -> bool: 234 | """Returns whether a class is a Cython extension class.""" 235 | return "__pyx_vtable__" in cls.__dict__ 236 | 237 | 238 | @dataclass(frozen=True) 239 | class Sentinel: 240 | name: str 241 | 242 | 243 | @used 244 | def trace(func: Callable[P, T]) -> Callable[P, T]: 245 | """Decorator to trace function calls.""" 246 | 247 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 248 | result = func(*args, **kwargs) 249 | pieces = [func.__name__, "("] 250 | for i, arg in enumerate(args): 251 | if i > 0: 252 | pieces.append(", ") 253 | pieces.append(str(arg)) 254 | for i, (k, v) in enumerate(kwargs.items()): 255 | if i > 0 or args: 256 | pieces.append(", ") 257 | pieces.append(f"{k}={v}") 258 | pieces.append(")") 259 | pieces.append(" -> ") 260 | pieces.append(str(result)) 261 | print("".join(pieces)) 262 | return result 263 | 264 | return wrapper 265 | -------------------------------------------------------------------------------- /pycroscope/ast_annotator.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Functionality for annotating the AST of a module. 4 | 5 | The APIs in this module use pycroscope's type inference to annotate 6 | an AST with inferred :class:`pycroscope.value.Value` objects in `.inferred_value` 7 | attributes. 8 | 9 | """ 10 | 11 | import ast 12 | import os 13 | import textwrap 14 | import traceback 15 | import types 16 | from typing import Optional, Union 17 | 18 | from .analysis_lib import make_module 19 | from .error_code import ErrorCode 20 | from .find_unused import used 21 | from .importer import load_module_from_file 22 | from .name_check_visitor import ClassAttributeChecker, NameCheckVisitor 23 | 24 | 25 | @used # exposed as an API 26 | def annotate_code( 27 | code: str, 28 | *, 29 | visitor_cls: type[NameCheckVisitor] = NameCheckVisitor, 30 | dump: bool = False, 31 | show_errors: bool = False, 32 | verbose: bool = False, 33 | ) -> ast.Module: 34 | """Annotate a piece of Python code. Return an AST with extra `inferred_value` attributes. 35 | 36 | Example usage:: 37 | 38 | tree = annotate_code("a = 1") 39 | print(tree.body[0].targets[0].inferred_value) # Literal[1] 40 | 41 | This will import and ``exec()`` the provided code. If this fails, the code will 42 | still be annotated but the quality of the annotations will be much lower. 43 | 44 | :param visitor_cls: Pass a subclass of :class:`pycroscope.name_check_visitor.NameCheckVisitor` 45 | to customize pycroscope behavior. 46 | :type visitor_cls: Type[NameCheckVisitor] 47 | 48 | :param dump: If True, the annotated AST is printed out. 49 | :type dump: bool 50 | 51 | :param show_errors: If True, errors from pycroscope are printed. 52 | :type show_errors: bool 53 | 54 | :param verbose: If True, more details are printed. 55 | :type verbose: bool 56 | 57 | """ 58 | code = textwrap.dedent(code) 59 | tree = ast.parse(code) 60 | try: 61 | mod = make_module(code) 62 | except Exception: 63 | if verbose: 64 | traceback.print_exc() 65 | mod = None 66 | _annotate_module("", mod, tree, code, visitor_cls, show_errors=show_errors) 67 | if dump: 68 | dump_annotated_code(tree) 69 | return tree 70 | 71 | 72 | @used # exposed as an API 73 | def annotate_file( 74 | path: Union[str, "os.PathLike[str]"], 75 | *, 76 | visitor_cls: type[NameCheckVisitor] = NameCheckVisitor, 77 | verbose: bool = False, 78 | dump: bool = False, 79 | show_errors: bool = False, 80 | ) -> ast.AST: 81 | """Annotate the code in a Python source file. Return an AST with extra `inferred_value` 82 | attributes. 83 | 84 | Example usage:: 85 | 86 | tree = annotate_file("/some/file.py") 87 | print(tree.body[0].targets[0].inferred_value) # Literal[1] 88 | 89 | This will import and exec() the provided code. If this fails, the code will 90 | still be annotated but the quality of the annotations will be much lower. 91 | 92 | :param visitor_cls: Pass a subclass of :class:`pycroscope.name_check_visitor.NameCheckVisitor` 93 | to customize pycroscope behavior. 94 | :type visitor_cls: Type[NameCheckVisitor] 95 | 96 | :param dump: If True, the annotated AST is printed out. 97 | :type dump: bool 98 | 99 | :param show_errors: If True, errors from pycroscope are printed. 100 | :type show_errors: bool 101 | 102 | :param verbose: If True, more details are printed. 103 | :type verbose: bool 104 | 105 | """ 106 | filename = os.fspath(path) 107 | try: 108 | mod, _ = load_module_from_file(filename, verbose=verbose) 109 | except Exception: 110 | if verbose: 111 | traceback.print_exc() 112 | mod = None 113 | 114 | with open(filename, encoding="utf-8") as f: 115 | code = f.read() 116 | tree = ast.parse(code) 117 | _annotate_module(filename, mod, tree, code, visitor_cls, show_errors=show_errors) 118 | if dump: 119 | dump_annotated_code(tree) 120 | return tree 121 | 122 | 123 | def dump_annotated_code( 124 | node: ast.AST, depth: int = 0, field_name: Optional[str] = None 125 | ) -> None: 126 | """Print an annotated AST in a readable format.""" 127 | line = type(node).__name__ 128 | if field_name is not None: 129 | line = f"{field_name}: {line}" 130 | if ( 131 | hasattr(node, "lineno") 132 | and hasattr(node, "col_offset") 133 | and node.lineno is not None 134 | and node.col_offset is not None 135 | ): 136 | line = f"{line}(@{node.lineno}:{node.col_offset})" 137 | print(" " * depth + line) 138 | new_depth = depth + 2 139 | if hasattr(node, "inferred_value"): 140 | print(" " * new_depth + str(node.inferred_value)) 141 | for field_name, value in ast.iter_fields(node): 142 | if isinstance(value, ast.AST): 143 | dump_annotated_code(value, new_depth, field_name) 144 | elif isinstance(value, list): 145 | if not value: 146 | continue 147 | print(" " * new_depth + field_name) 148 | for element in value: 149 | if isinstance(element, ast.AST): 150 | dump_annotated_code(element, new_depth + 2) 151 | else: 152 | print(" " * (new_depth + 2) + repr(element)) 153 | elif value is not None: 154 | print(" " * new_depth + f"{field_name}: {value!r}") 155 | 156 | 157 | def _annotate_module( 158 | filename: str, 159 | module: Optional[types.ModuleType], 160 | tree: ast.Module, 161 | code_str: str, 162 | visitor_cls: type[NameCheckVisitor], 163 | show_errors: bool = False, 164 | ) -> None: 165 | """Annotate the AST for a module with inferred values. 166 | 167 | Takes the module objects, its AST tree, and its literal code. Modifies the AST object in place. 168 | 169 | """ 170 | kwargs = visitor_cls.prepare_constructor_kwargs({}) 171 | options = kwargs["checker"].options 172 | with ClassAttributeChecker(enabled=True, options=options) as attribute_checker: 173 | visitor = visitor_cls( 174 | filename, 175 | code_str, 176 | tree, 177 | module=module, 178 | settings={error_code: show_errors for error_code in ErrorCode}, 179 | attribute_checker=attribute_checker, 180 | annotate=True, 181 | **kwargs, 182 | ) 183 | visitor.check(ignore_missing_module=True) 184 | -------------------------------------------------------------------------------- /pycroscope/asynq_test.toml: -------------------------------------------------------------------------------- 1 | [tool.pycroscope] 2 | classes_checked_for_asynq = ["pycroscope.asynq_tests.CheckedForAsynq"] 3 | -------------------------------------------------------------------------------- /pycroscope/asynq_tests.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | """ 3 | 4 | Helpers for tests involving asynq. 5 | 6 | """ 7 | 8 | import qcore 9 | from asynq import AsyncTask, ConstFuture, async_proxy, asynq, get_async_fn, result 10 | from asynq.decorators import AsyncDecorator 11 | 12 | 13 | class CacheDecorator(AsyncDecorator): 14 | def __init__(self, fn, async_fn): 15 | super().__init__(fn, AsyncTask) 16 | self.async_fn = async_fn 17 | self.cache = {} 18 | 19 | @asynq(pure=True) 20 | def _call_pure(self, args, kwargs): 21 | try: 22 | result(self.cache[args]) 23 | return 24 | except KeyError: 25 | value = yield self.async_fn(*args) 26 | self.cache[args] = value 27 | result(value) 28 | return 29 | 30 | def dirty(self, *args): 31 | del self.cache[args] 32 | 33 | 34 | def cached(fn): 35 | return qcore.decorators.decorate(CacheDecorator, get_async_fn(fn))(fn) 36 | 37 | 38 | @async_proxy() 39 | def proxied_fn(): 40 | return ConstFuture("capybaras!") 41 | 42 | 43 | @cached 44 | @asynq() 45 | def l0cached_async_fn(): 46 | return "capybaras" 47 | 48 | 49 | @asynq() 50 | def autogenerated(aid): 51 | result((yield async_fn.asynq(aid))) 52 | return 53 | 54 | 55 | @cached 56 | def cached_fn(oid): 57 | return oid * 3 58 | 59 | 60 | @asynq() 61 | def async_fn(oid): 62 | result((yield cached_fn.asynq(oid))) 63 | return 64 | 65 | 66 | class ClassWithAsync: 67 | def get(self): 68 | return 1 69 | 70 | @asynq(pure=True) 71 | def get_async(self): 72 | yield async_fn.asynq(1) 73 | result(2) 74 | return 75 | 76 | 77 | class PropertyObject: 78 | def __init__(self, poid): 79 | self.poid = poid 80 | 81 | def non_async_method(self): 82 | pass 83 | 84 | @property 85 | def string_property(self) -> str: 86 | return str(self.poid) 87 | 88 | @property 89 | def prop(self): 90 | return cached_fn(self.poid) 91 | 92 | prop_with_get = prop 93 | prop_with_is = prop 94 | 95 | @asynq() 96 | def get_prop_with_get(self): 97 | result((yield cached_fn.asynq(self.poid))) 98 | return 99 | 100 | @asynq() 101 | def is_prop_with_is(self): 102 | result((yield cached_fn.asynq(self.poid))) 103 | return 104 | 105 | @property 106 | def no_decorator(self): 107 | return cached_fn(self.poid) 108 | 109 | @asynq() 110 | @classmethod 111 | def load(cls, poid, include_deleted=False): 112 | result((yield cls(poid).get_prop_with_get.asynq())) 113 | return 114 | 115 | @classmethod 116 | def sync_load(cls, poid, include_deleted=False): 117 | return cls.load(poid, include_deleted=include_deleted) 118 | 119 | @asynq() 120 | def async_method(self): 121 | result((yield cached_fn.asynq(self.poid))) 122 | return 123 | 124 | @asynq() 125 | @classmethod 126 | def async_classmethod(cls, poid): 127 | result((yield cls(poid).get_prop_with_get.asynq())) 128 | return 129 | 130 | def _private_method(self): 131 | pass 132 | 133 | @cached 134 | @asynq() 135 | def l0cached_async_method(self): 136 | return "capybaras" 137 | 138 | @asynq() 139 | @staticmethod 140 | def async_staticmethod(): 141 | pass 142 | 143 | @classmethod 144 | def no_args_classmethod(cls): 145 | pass 146 | 147 | 148 | class Subclass(PropertyObject): 149 | pass 150 | 151 | 152 | class CheckedForAsynq: 153 | """Subclasses of this class are checked for asynq in tests.""" 154 | 155 | def not_checked(self): 156 | """Except in this method.""" 157 | pass 158 | 159 | 160 | class FixedMethodReturnType: 161 | def should_return_none(self): 162 | pass 163 | 164 | def should_return_list(self): 165 | return [] 166 | 167 | 168 | class ClassWithCallAsynq: 169 | def __init__(self, name): 170 | pass 171 | 172 | @asynq() 173 | def async_method(self, x): 174 | pass 175 | 176 | @asynq() 177 | @staticmethod 178 | def async_staticmethod(y): 179 | pass 180 | 181 | @asynq() 182 | @classmethod 183 | def async_classmethod(cls, z): 184 | pass 185 | 186 | @asynq(pure=True) 187 | @classmethod 188 | def pure_async_classmethod(cls, ac): 189 | pass 190 | 191 | @classmethod 192 | @asynq() 193 | def classmethod_before_async(cls, ac): 194 | pass 195 | 196 | 197 | @asynq() 198 | def async_function(x, y): 199 | pass 200 | -------------------------------------------------------------------------------- /pycroscope/boolability.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Boolability is about whether a value can be used as a boolean. 4 | 5 | Objects like functions are always true, so it is likely an error to 6 | use them in a boolean context. This file helps figure out whether 7 | a particular type is usable as a boolean. 8 | 9 | """ 10 | 11 | import enum 12 | 13 | from typing_extensions import assert_never 14 | 15 | from pycroscope.maybe_asynq import asynq 16 | from pycroscope.safe import safe_getattr, safe_hasattr 17 | 18 | from .value import ( 19 | KNOWN_MUTABLE_TYPES, 20 | AnyValue, 21 | BasicType, 22 | DictIncompleteValue, 23 | IntersectionValue, 24 | KnownValue, 25 | MultiValuedValue, 26 | SequenceValue, 27 | SimpleType, 28 | SubclassValue, 29 | SyntheticModuleValue, 30 | TypedDictValue, 31 | TypedValue, 32 | UnboundMethodValue, 33 | Value, 34 | replace_known_sequence_value, 35 | ) 36 | 37 | 38 | class Boolability(enum.Enum): 39 | erroring_bool = 1 40 | """Throws an error if bool() is called on it.""" 41 | boolable = 2 42 | """Can be safely used as a bool.""" 43 | value_always_false_mutable = 3 44 | """Always False, but of a mutable type.""" 45 | value_always_true_mutable = 4 46 | """Always True, but of a mutable type.""" 47 | value_always_false = 5 48 | """Always False.""" 49 | value_always_true = 6 50 | """Always True, but of a type that can also be false.""" 51 | type_always_true = 7 52 | """Value of a type that is always True (because it does not override __bool__).""" 53 | 54 | def is_safely_true(self) -> bool: 55 | return self in _TRUE_BOOLABILITIES 56 | 57 | def is_safely_false(self) -> bool: 58 | # We don't treat value_always_false_mutable as safe because 59 | # empty containers too easily become nonempty. 60 | return self is Boolability.value_always_false 61 | 62 | 63 | _TRUE_BOOLABILITIES = { 64 | Boolability.value_always_true, 65 | Boolability.value_always_true_mutable, 66 | Boolability.type_always_true, 67 | } 68 | _FALSE_BOOLABILITIES = { 69 | Boolability.value_always_false, 70 | Boolability.value_always_false_mutable, 71 | } 72 | # doesn't exist if asynq is not compiled 73 | _ASYNQ_BOOL = ( 74 | getattr(asynq.FutureBase, "__bool__", object()) if asynq is not None else object() 75 | ) 76 | 77 | 78 | def get_boolability(value: Value) -> Boolability: 79 | value = replace_known_sequence_value(value) 80 | return _get_boolability_basic(value) 81 | 82 | 83 | def _get_boolability_basic(value: BasicType) -> Boolability: 84 | if isinstance(value, MultiValuedValue): 85 | boolabilities = { 86 | _get_boolability_basic(replace_known_sequence_value(subval)) 87 | for subval in value.vals 88 | } 89 | if Boolability.erroring_bool in boolabilities: 90 | return Boolability.erroring_bool 91 | elif Boolability.boolable in boolabilities: 92 | return Boolability.boolable 93 | elif (boolabilities & _TRUE_BOOLABILITIES) and ( 94 | boolabilities & _FALSE_BOOLABILITIES 95 | ): 96 | # If it contains both values that are always true and values that are always false, 97 | # it's boolable. 98 | return Boolability.boolable 99 | elif boolabilities: 100 | # This means the set contains either only truthy or only falsy options. 101 | # Choose the lowest-valued (and therefore weakest) one. 102 | return min(boolabilities, key=lambda b: b.value) 103 | else: 104 | # NO_RETURN_VALUE 105 | return Boolability.boolable 106 | elif isinstance(value, IntersectionValue): 107 | boolabilities = { 108 | _get_boolability_basic(replace_known_sequence_value(member)) 109 | for member in value.vals 110 | } 111 | return max(boolabilities, key=lambda b: b.value) 112 | else: 113 | return _get_boolability_no_mvv(value) 114 | 115 | 116 | def _get_boolability_no_mvv(value: SimpleType) -> Boolability: 117 | if isinstance(value, AnyValue): 118 | return Boolability.boolable 119 | elif isinstance(value, UnboundMethodValue): 120 | if value.secondary_attr_name: 121 | # Might be anything 122 | return Boolability.boolable 123 | else: 124 | return Boolability.type_always_true 125 | elif isinstance(value, TypedDictValue): 126 | if value.num_required_keys(): 127 | # Must be nonempty 128 | return Boolability.type_always_true 129 | else: 130 | return Boolability.boolable 131 | elif isinstance(value, SequenceValue): 132 | if not value.members: 133 | if value.typ is tuple: 134 | return Boolability.value_always_false 135 | else: 136 | return Boolability.value_always_false_mutable 137 | may_be_empty = all(is_many for is_many, _ in value.members) 138 | if may_be_empty: 139 | return Boolability.boolable 140 | if value.typ is tuple: 141 | # We lie slightly here, since at the type level a tuple 142 | # may be false. But tuples are a common source of boolability 143 | # bugs and they're rarely mutated, so we put a stronger 144 | # condition on them. 145 | return Boolability.type_always_true 146 | else: 147 | return Boolability.value_always_true_mutable 148 | elif isinstance(value, DictIncompleteValue): 149 | if any(pair.is_required and not pair.is_many for pair in value.kv_pairs): 150 | return Boolability.value_always_true_mutable 151 | elif value.kv_pairs: 152 | return Boolability.boolable 153 | else: 154 | return Boolability.value_always_false_mutable 155 | elif isinstance(value, (SubclassValue, SyntheticModuleValue)): 156 | # Could be false if a metaclass overrides __bool__, but we're 157 | # not handling that for now. 158 | return Boolability.type_always_true 159 | elif isinstance(value, KnownValue): 160 | if value.val is NotImplemented: 161 | return Boolability.erroring_bool 162 | try: 163 | boolean_value = bool(value.val) 164 | except Exception: 165 | return Boolability.erroring_bool 166 | if isinstance(value.val, KNOWN_MUTABLE_TYPES): 167 | if boolean_value: 168 | return Boolability.value_always_true_mutable 169 | else: 170 | return Boolability.value_always_false_mutable 171 | type_boolability = _get_type_boolability(type(value.val), is_exact=True) 172 | if boolean_value: 173 | if type_boolability is Boolability.boolable: 174 | return Boolability.value_always_true 175 | elif type_boolability is Boolability.type_always_true: 176 | return Boolability.type_always_true 177 | else: 178 | assert False, ( 179 | f"inconsistent boolabilities: {boolean_value}, {type_boolability}," 180 | f" {value!r}" 181 | ) 182 | else: 183 | if type_boolability is Boolability.boolable: 184 | return Boolability.value_always_false 185 | else: 186 | assert False, ( 187 | f"inconsistent boolabilities: {boolean_value}, {type_boolability}," 188 | f" {value!r}" 189 | ) 190 | elif isinstance(value, TypedValue): 191 | if isinstance(value.typ, str): 192 | return Boolability.boolable # TODO deal with synthetic types 193 | return _get_type_boolability(value.typ) 194 | else: 195 | assert_never(value) 196 | 197 | 198 | def _get_type_boolability(typ: type, *, is_exact: bool = False) -> Boolability: 199 | # Special-case object as boolable because it could easily be a subtype 200 | # that does support __bool__. 201 | if typ is object and not is_exact: 202 | return Boolability.boolable 203 | if safe_hasattr(typ, "__len__"): 204 | return Boolability.boolable 205 | dunder_bool = safe_getattr(typ, "__bool__", None) 206 | if dunder_bool is None: 207 | return Boolability.type_always_true 208 | elif dunder_bool is _ASYNQ_BOOL: 209 | return Boolability.erroring_bool 210 | else: 211 | return Boolability.boolable 212 | -------------------------------------------------------------------------------- /pycroscope/find_unused.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Implementation of unused object detection. 4 | 5 | """ 6 | 7 | import __future__ 8 | 9 | import enum 10 | import inspect 11 | from collections import defaultdict 12 | from collections.abc import Iterable 13 | from dataclasses import dataclass, field 14 | from types import ModuleType, TracebackType 15 | from typing import Optional, TypeVar 16 | 17 | import pycroscope 18 | 19 | from . import extensions 20 | from .safe import safe_in 21 | 22 | T = TypeVar("T") 23 | 24 | _used_objects = set() 25 | _test_helper_objects = set() 26 | 27 | 28 | def used(obj: T) -> T: 29 | """Decorator indicating that an object is being used. 30 | 31 | This stops the UnusedObjectFinder from marking it as unused. 32 | 33 | """ 34 | _used_objects.add(obj) 35 | return obj 36 | 37 | 38 | def test_helper(obj: T) -> T: 39 | """Decorator indicating that an object is intended as a helper for tests. 40 | 41 | If the object is used only in tests, this stops the UnusedObjectFinder from 42 | marking it as unused. 43 | 44 | """ 45 | _test_helper_objects.add(obj) 46 | return obj 47 | 48 | 49 | # so it doesn't itself get marked as unused 50 | used(used) 51 | used(test_helper) 52 | used(extensions) 53 | 54 | 55 | class _UsageKind(enum.IntEnum): 56 | unused = 1 57 | used_in_test = 2 58 | used = 3 59 | 60 | @classmethod 61 | def classify(cls, module_name: str) -> "_UsageKind": 62 | if "." not in module_name: 63 | return cls.used 64 | own_name = module_name.rsplit(".", maxsplit=1)[1] 65 | if own_name.startswith("test"): 66 | return cls.used_in_test 67 | else: 68 | return cls.used 69 | 70 | @classmethod 71 | def aggregate(cls, usages: Iterable["_UsageKind"]) -> "_UsageKind": 72 | return max(usages, default=cls.unused) 73 | 74 | @classmethod 75 | def aggregate_modules(cls, module_names: Iterable[str]) -> "_UsageKind": 76 | return cls.aggregate(cls.classify(module_name) for module_name in module_names) 77 | 78 | 79 | @dataclass 80 | class UnusedObject: 81 | module: ModuleType 82 | attribute: str 83 | value: object 84 | message: str 85 | 86 | def __str__(self) -> str: 87 | return f"{self.module.__name__}.{self.attribute}: {self.message}" 88 | 89 | 90 | @dataclass 91 | class UnusedObjectFinder: 92 | """Context to find unused objects. 93 | 94 | This records all accesses for Python functions and classes and prints out all existing 95 | objects that are completely unused. 96 | 97 | """ 98 | 99 | options: Optional["pycroscope.options.Options"] = None 100 | enabled: bool = False 101 | print_output: bool = True 102 | print_all: bool = False 103 | usages: dict[ModuleType, dict[str, set[str]]] = field( 104 | default_factory=lambda: defaultdict(lambda: defaultdict(set)), init=False 105 | ) 106 | import_stars: dict[ModuleType, set[ModuleType]] = field( 107 | default_factory=lambda: defaultdict(set), init=False 108 | ) 109 | module_to_import_stars: dict[ModuleType, set[ModuleType]] = field( 110 | default_factory=lambda: defaultdict(set), init=False 111 | ) 112 | visited_modules: list[ModuleType] = field(default_factory=list) 113 | recursive_stack: set[ModuleType] = field(default_factory=set) 114 | 115 | def __post_init__(self) -> None: 116 | if self.options is None: 117 | self.options = pycroscope.options.Options.from_option_list() 118 | 119 | def __enter__(self) -> Optional["UnusedObjectFinder"]: 120 | if self.enabled: 121 | return self 122 | else: 123 | return None 124 | 125 | def __exit__( 126 | self, 127 | exc_typ: Optional[type[BaseException]], 128 | exc_val: Optional[BaseException], 129 | exc_tb: Optional[TracebackType], 130 | ) -> None: 131 | if not self.enabled or not self.print_output: 132 | return 133 | 134 | for unused_object in self.get_unused_objects(): 135 | print(unused_object) 136 | 137 | def record(self, owner: ModuleType, attr: str, using_module: str) -> None: 138 | if not self.enabled: 139 | return 140 | try: 141 | self.usages[owner][attr].add(using_module) 142 | except Exception: 143 | pass 144 | 145 | def record_import_star( 146 | self, imported_module: ModuleType, importing_module: ModuleType 147 | ) -> None: 148 | self.import_stars[imported_module].add(importing_module) 149 | self.module_to_import_stars[importing_module].add(imported_module) 150 | 151 | def record_module_visited(self, module: ModuleType) -> None: 152 | self.visited_modules.append(module) 153 | 154 | def get_unused_objects(self) -> Iterable[UnusedObject]: 155 | for module in sorted(self.visited_modules, key=lambda mod: mod.__name__): 156 | yield from self._get_unused_from_module(module) 157 | 158 | def _get_unused_from_module(self, module: ModuleType) -> Iterable[UnusedObject]: 159 | is_test_module = any( 160 | part.startswith("test") for part in module.__name__.split(".") 161 | ) 162 | for attr, value in module.__dict__.items(): 163 | usages = self.usages[module][attr] 164 | if self.print_all: 165 | message = f"{len(usages)} ({usages})" 166 | yield UnusedObject(module, attr, value, message) 167 | continue 168 | # Ignore attributes injected by Python 169 | if attr.startswith("__") and attr.endswith("__"): 170 | continue 171 | # Ignore stuff injected by pytest 172 | if attr.startswith("@py"): 173 | continue 174 | # Ignore tests 175 | if is_test_module and attr.startswith(("test", "Test")): 176 | continue 177 | own_usage = _UsageKind.aggregate_modules(usages) 178 | star_usage = self._has_import_star_usage(module, attr) 179 | usage = _UsageKind.aggregate([own_usage, star_usage]) 180 | if usage is _UsageKind.used: 181 | continue 182 | if not self._should_record_as_unused(module, attr, value): 183 | continue 184 | if any( 185 | hasattr(import_starred, attr) 186 | for import_starred in self.module_to_import_stars[module] 187 | ): 188 | continue 189 | if usage is _UsageKind.used_in_test: 190 | if not is_test_module and not safe_in(value, _test_helper_objects): 191 | yield UnusedObject(module, attr, value, "used only in tests") 192 | else: 193 | yield UnusedObject(module, attr, value, "unused") 194 | 195 | def _has_import_star_usage(self, module: ModuleType, attr: str) -> _UsageKind: 196 | old_recursive_stack = self.recursive_stack 197 | self.recursive_stack = set() 198 | try: 199 | return self._has_import_star_usage_inner(module, attr) 200 | finally: 201 | self.recursive_stack = old_recursive_stack 202 | 203 | def _has_import_star_usage_inner(self, module: ModuleType, attr: str) -> _UsageKind: 204 | if module in self.recursive_stack: 205 | return _UsageKind.unused 206 | self.recursive_stack.add(module) 207 | usage = _UsageKind.aggregate_modules(self.usages[module][attr]) 208 | if usage is _UsageKind.used: 209 | return _UsageKind.used 210 | import_stars = self.import_stars[module] 211 | recursive_usage = _UsageKind.aggregate( 212 | self._has_import_star_usage_inner(importing_module, attr) 213 | for importing_module in import_stars 214 | ) 215 | return _UsageKind.aggregate([usage, recursive_usage]) 216 | 217 | def _should_record_as_unused( 218 | self, module: ModuleType, attr: str, value: object 219 | ) -> bool: 220 | if self.options is not None: 221 | ignore_funcs = self.options.get_value_for( 222 | pycroscope.shared_options.IgnoreUnused 223 | ) 224 | for func in ignore_funcs: 225 | if func(module, attr, value): 226 | return False 227 | if inspect.ismodule(value): 228 | # test modules will usually show up as unused 229 | if value.__name__.split(".")[-1].startswith("test"): 230 | return False 231 | # if it was ever import *ed from, don't treat it as unused 232 | if value in self.import_stars: 233 | return False 234 | if safe_in(value, _used_objects): 235 | return False 236 | try: 237 | # __future__ imports are usually unused 238 | return not isinstance(value, __future__._Feature) 239 | except Exception: 240 | return True 241 | -------------------------------------------------------------------------------- /pycroscope/importer.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Module responsible for importing files. 4 | 5 | """ 6 | 7 | import importlib 8 | import importlib.util 9 | import sys 10 | from collections.abc import Sequence 11 | from functools import lru_cache 12 | from pathlib import Path 13 | from types import ModuleType 14 | from typing import Optional, cast 15 | 16 | 17 | @lru_cache 18 | def directory_has_init(path: Path) -> bool: 19 | return (path / "__init__.py").exists() 20 | 21 | 22 | def load_module_from_file( 23 | filename: str, *, verbose: bool = False, import_paths: Sequence[str] = () 24 | ) -> tuple[Optional[ModuleType], bool]: 25 | """Import the Python code in the given file. 26 | 27 | Return a tuple (module object, whether it is a compiled file). 28 | 29 | May throw any errors that happen while the file is being imported. 30 | 31 | """ 32 | # Attempt to get the location of the module relative to sys.path so we can import it 33 | # somewhat properly 34 | abspath = Path(filename).resolve() 35 | candidate_paths = [] 36 | path: Sequence[str] = import_paths if import_paths else sys.path 37 | for sys_path_entry in path: 38 | if not sys_path_entry: 39 | continue 40 | import_path = Path(sys_path_entry) 41 | try: 42 | relative_path = abspath.relative_to(import_path) 43 | except ValueError: 44 | continue 45 | 46 | parts = [*relative_path.parts[:-1], relative_path.stem] 47 | if not all(part.isidentifier() for part in parts): 48 | continue 49 | if parts[-1] == "__init__": 50 | parts = parts[:-1] 51 | 52 | candidate_paths.append((import_path, ".".join(parts))) 53 | 54 | # First attempt to import only through paths that have __init__.py at every level 55 | # to avoid importing through unnecessary namespace packages. 56 | for restrict_init in (True, False): 57 | for import_path, module_path in candidate_paths: # use sys.path order 58 | if module_path in sys.modules: 59 | existing = cast(ModuleType, sys.modules[module_path]) 60 | is_compiled = getattr(existing, "__file__", None) != str(abspath) 61 | if verbose: 62 | print( 63 | f"found {abspath} already present as {module_path}" 64 | f" (is_compiled: {is_compiled})" 65 | ) 66 | return existing, is_compiled 67 | if restrict_init: 68 | missing_init = False 69 | for parent in abspath.parents: 70 | if parent == import_path: 71 | break 72 | if not directory_has_init(parent): 73 | missing_init = True 74 | break 75 | if missing_init: 76 | if verbose: 77 | print(f"skipping {import_path} because of missing __init__.py") 78 | continue 79 | 80 | if verbose: 81 | print(f"importing {abspath} as {module_path}") 82 | 83 | if "." in module_path: 84 | parent_module_path, child_name = module_path.rsplit(".", maxsplit=1) 85 | try: 86 | parent_module = importlib.import_module(parent_module_path) 87 | except ImportError: 88 | continue 89 | else: 90 | parent_module = child_name = None 91 | 92 | module = import_module(module_path, abspath) 93 | if parent_module is not None and child_name is not None: 94 | setattr(parent_module, child_name, module) 95 | return module, False 96 | 97 | # If all else fails, try to import it under its own name 98 | # regardless of sys.path. 99 | if import_paths: 100 | return None, False 101 | if verbose: 102 | print(f"falling back to importing {abspath} outside the import path") 103 | return import_module(str(abspath), abspath), False 104 | 105 | 106 | def import_module(module_path: str, filename: Path) -> ModuleType: 107 | """Import a file under an arbitrary module name.""" 108 | spec = importlib.util.spec_from_file_location(module_path, filename) 109 | module = importlib.util.module_from_spec(spec) 110 | sys.modules[module_path] = module 111 | spec.loader.exec_module(module) 112 | return module 113 | -------------------------------------------------------------------------------- /pycroscope/input_sig.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | from collections.abc import Container, Iterable, Sequence 4 | from dataclasses import dataclass 5 | from typing import Literal, Optional, Union 6 | 7 | import typing_extensions 8 | from typing_extensions import Self, assert_never 9 | 10 | import pycroscope 11 | from pycroscope.relations import Relation 12 | from pycroscope.safe import is_instance_of_typing_name 13 | from pycroscope.stacked_scopes import Composite 14 | from pycroscope.value import ( 15 | AnyValue, 16 | Bound, 17 | CanAssign, 18 | CanAssignContext, 19 | CanAssignError, 20 | LowerBound, 21 | TypeVarLike, 22 | TypeVarMap, 23 | TypeVarValue, 24 | UpperBound, 25 | Value, 26 | ) 27 | 28 | if sys.version_info >= (3, 10): 29 | ParamSpecLike = Union[typing_extensions.ParamSpec, typing.ParamSpec] 30 | else: 31 | ParamSpecLike = typing_extensions.ParamSpec 32 | 33 | 34 | @dataclass(frozen=True) 35 | class ParamSpecSig: 36 | param_spec: ParamSpecLike 37 | default: Optional[Value] = None # unsupported 38 | 39 | def substitute_typevars(self, typevars: TypeVarMap) -> "InputSig": 40 | if self.param_spec in typevars: 41 | return assert_input_sig(typevars[self.param_spec]) 42 | return self 43 | 44 | def walk_values(self) -> Iterable[Value]: 45 | if self.default is not None: 46 | yield from self.default.walk_values() 47 | 48 | 49 | @dataclass(frozen=True) 50 | class AnySig: 51 | def substitute_typevars(self, typevars: TypeVarMap) -> Self: 52 | return self 53 | 54 | def walk_values(self) -> Iterable[Value]: 55 | return [] 56 | 57 | 58 | ELLIPSIS = AnySig() 59 | 60 | 61 | @dataclass 62 | class ActualArguments: 63 | """Represents the actual arguments to a call. 64 | 65 | Before creating this class, we decompose ``*args`` and ``**kwargs`` arguments 66 | of known composition into additional positional and keyword arguments, and we 67 | merge multiple ``*args`` or ``**kwargs``. 68 | 69 | Creating the ``ActualArguments`` for a call is independent of the signature 70 | of the callee. 71 | 72 | """ 73 | 74 | positionals: list[tuple[bool, Composite]] 75 | star_args: Optional[Value] # represents the type of the elements of *args 76 | keywords: dict[str, tuple[bool, Composite]] 77 | star_kwargs: Optional[Value] # represents the type of the elements of **kwargs 78 | kwargs_required: bool 79 | pos_or_keyword_params: Container[Union[int, str]] 80 | ellipsis: bool = False 81 | param_spec: Optional[ParamSpecSig] = None 82 | 83 | def substitute_typevars(self, typevars: TypeVarMap) -> Self: 84 | return self 85 | 86 | def __hash__(self) -> int: 87 | return id(self) 88 | 89 | def walk_values(self) -> Iterable[Value]: 90 | for _, composite in self.positionals: 91 | yield from composite.value.walk_values() 92 | if self.star_args is not None: 93 | yield from self.star_args.walk_values() 94 | for _, composite in self.keywords.values(): 95 | yield from composite.value.walk_values() 96 | if self.star_kwargs is not None: 97 | yield from self.star_kwargs.walk_values() 98 | 99 | 100 | @dataclass(frozen=True) 101 | class FullSignature: 102 | sig: "pycroscope.signature.Signature" 103 | 104 | def substitute_typevars(self, typevars: TypeVarMap) -> "FullSignature": 105 | return FullSignature(sig=self.sig.substitute_typevars(typevars)) 106 | 107 | def walk_values(self) -> Iterable[Value]: 108 | yield from self.sig.walk_values() 109 | 110 | def __str__(self) -> str: 111 | return str(self.sig) 112 | 113 | 114 | InputSig = Union[ActualArguments, ParamSpecSig, AnySig, FullSignature] 115 | 116 | 117 | @dataclass(frozen=True) 118 | class InputSigValue(Value): 119 | """Dummy value wrapping an InputSig.""" 120 | 121 | input_sig: InputSig 122 | 123 | def substitute_typevars(self, typevars: TypeVarMap) -> Value: 124 | return InputSigValue(self.input_sig.substitute_typevars(typevars)) 125 | 126 | def walk_values(self) -> Iterable[Value]: 127 | yield self 128 | yield from self.input_sig.walk_values() 129 | 130 | def __str__(self) -> str: 131 | return str(self.input_sig) 132 | 133 | 134 | def assert_input_sig(value: Value) -> InputSig: 135 | """Assert that the value is an InputSig.""" 136 | if isinstance(value, InputSigValue): 137 | return value.input_sig 138 | elif isinstance(value, AnyValue): 139 | return ELLIPSIS 140 | raise TypeError(f"Expected InputSig, got {value!r}") 141 | 142 | 143 | def input_sigs_have_relation( 144 | left: InputSig, 145 | right: InputSig, 146 | relation: Literal[Relation.ASSIGNABLE, Relation.SUBTYPE], 147 | ctx: CanAssignContext, 148 | ) -> CanAssign: 149 | if isinstance(left, AnySig): 150 | if relation is Relation.SUBTYPE: 151 | return CanAssignError("Cannot be assigned to") 152 | return {} 153 | elif isinstance(left, ParamSpecSig): 154 | return {left.param_spec: [LowerBound(left.param_spec, InputSigValue(right))]} 155 | elif isinstance(left, ActualArguments): 156 | if left == right: 157 | return {} 158 | return CanAssignError("Cannot be assigned to") 159 | elif isinstance(left, FullSignature): 160 | if isinstance(right, AnySig): 161 | if relation is Relation.SUBTYPE: 162 | return CanAssignError("Cannot be assigned") 163 | return {} 164 | elif isinstance(right, ParamSpecSig): 165 | return { 166 | right.param_spec: [UpperBound(right.param_spec, InputSigValue(left))] 167 | } 168 | elif isinstance(right, ActualArguments): 169 | return pycroscope.signature.check_call_preprocessed(left.sig, right, ctx) 170 | elif isinstance(right, FullSignature): 171 | return pycroscope.signature.signatures_have_relation( 172 | left.sig, right.sig, relation, ctx 173 | ) 174 | else: 175 | assert_never(right) 176 | else: 177 | assert_never(left) 178 | 179 | 180 | def solve_paramspec( 181 | bounds: Sequence[Bound], ctx: CanAssignContext 182 | ) -> Union[Value, CanAssignError]: 183 | if not bounds: 184 | return CanAssignError("Unsupported ParamSpec") 185 | bound = bounds[0] 186 | if not isinstance(bound, LowerBound): 187 | return CanAssignError("Unsupported ParamSpec") 188 | solution = assert_input_sig(bound.value) 189 | for i, bound in enumerate(bounds): 190 | if i == 0: 191 | continue 192 | if isinstance(bound, LowerBound): 193 | value = assert_input_sig(bound.value) 194 | can_assign = input_sigs_have_relation( 195 | solution, value, Relation.ASSIGNABLE, ctx 196 | ) 197 | if isinstance(can_assign, CanAssignError): 198 | return can_assign 199 | elif isinstance(bound, UpperBound): 200 | value = assert_input_sig(bound.value) 201 | can_assign = input_sigs_have_relation( 202 | value, solution, Relation.ASSIGNABLE, ctx 203 | ) 204 | if isinstance(can_assign, CanAssignError): 205 | return can_assign 206 | else: 207 | return CanAssignError("Unsupported ParamSpec bound") 208 | return InputSigValue(solution) 209 | 210 | 211 | def extract_type_params(value: Value) -> Iterable[TypeVarLike]: 212 | for val in value.walk_values(): 213 | if isinstance(val, TypeVarValue): 214 | yield val.typevar 215 | elif isinstance(val, InputSigValue): 216 | input_sig = val.input_sig 217 | if isinstance(input_sig, ParamSpecSig): 218 | yield input_sig.param_spec 219 | 220 | 221 | def wrap_type_param(type_param: TypeVarLike) -> Value: 222 | """Wrap a type parameter in an InputSigValue.""" 223 | if is_instance_of_typing_name(type_param, "ParamSpec"): 224 | # static analysis: ignore[incompatible_argument] 225 | return InputSigValue(ParamSpecSig(type_param)) 226 | elif is_instance_of_typing_name(type_param, "TypeVar"): 227 | return TypeVarValue(type_param) 228 | else: 229 | raise TypeError(f"Unsupported type parameter: {type_param!r}") 230 | -------------------------------------------------------------------------------- /pycroscope/maybe_asynq.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Wrapper for the asynq and qcore modules, which are optional dependencies. 4 | 5 | """ 6 | 7 | try: 8 | import asynq as asynq 9 | except ImportError: 10 | asynq = None 11 | try: 12 | import qcore as qcore 13 | except ImportError: 14 | qcore = None 15 | -------------------------------------------------------------------------------- /pycroscope/name_check_visitor.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JelleZijlstra/pycroscope/385397ac151199638c65d298997fd8298eb1abb2/pycroscope/name_check_visitor.pxd -------------------------------------------------------------------------------- /pycroscope/predicates.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Reusable predicates. 4 | 5 | """ 6 | 7 | import enum 8 | import operator 9 | from collections.abc import Sequence 10 | from dataclasses import dataclass 11 | from typing import Optional 12 | 13 | from .safe import safe_issubclass 14 | from .value import ( 15 | NO_RETURN_VALUE, 16 | AnyValue, 17 | CanAssignContext, 18 | KnownValue, 19 | MultiValuedValue, 20 | SubclassValue, 21 | TypedValue, 22 | TypeVarValue, 23 | Value, 24 | is_overlapping, 25 | replace_fallback, 26 | unannotate, 27 | unite_values, 28 | ) 29 | 30 | 31 | def is_universally_assignable(value: Value, target_value: Value) -> bool: 32 | if isinstance(value, TypeVarValue): 33 | return True 34 | value = replace_fallback(value) 35 | if value is NO_RETURN_VALUE or isinstance(value, AnyValue): 36 | return True 37 | elif value == TypedValue(type) and isinstance(target_value, SubclassValue): 38 | return True 39 | elif isinstance(value, MultiValuedValue): 40 | return all( 41 | is_universally_assignable(subval, target_value) for subval in value.vals 42 | ) 43 | elif isinstance(value, TypeVarValue): 44 | return True 45 | return False 46 | 47 | 48 | @dataclass 49 | class IsAssignablePredicate: 50 | """Predicate that filters out values that are not assignable to pattern_value. 51 | 52 | This only works reliably for simple pattern_values, such as TypedValue. 53 | 54 | """ 55 | 56 | pattern_value: Value 57 | ctx: CanAssignContext 58 | positive_only: bool 59 | 60 | def __call__(self, value: Value, positive: bool) -> Optional[Value]: 61 | compatible = is_overlapping(self.pattern_value, value, self.ctx) 62 | if positive: 63 | if not compatible: 64 | return None 65 | if self.pattern_value.is_assignable(value, self.ctx): 66 | if is_universally_assignable(value, unannotate(self.pattern_value)): 67 | return self.pattern_value 68 | return value 69 | else: 70 | return self.pattern_value 71 | elif not self.positive_only: 72 | if self.pattern_value.is_assignable( 73 | value, self.ctx 74 | ) and not is_universally_assignable(value, unannotate(self.pattern_value)): 75 | return None 76 | return value 77 | 78 | 79 | _OPERATOR = { 80 | (True, True): operator.is_, 81 | (False, True): operator.is_not, 82 | (True, False): operator.eq, 83 | (False, False): operator.ne, 84 | } 85 | 86 | 87 | @dataclass 88 | class EqualsPredicate: 89 | """Predicate that filters out values that are not equal to pattern_val.""" 90 | 91 | pattern_val: object 92 | ctx: CanAssignContext 93 | use_is: bool = False 94 | 95 | def __call__(self, value: Value, positive: bool) -> Optional[Value]: 96 | inner_value = unannotate(value) 97 | if isinstance(inner_value, KnownValue): 98 | op = _OPERATOR[(positive, self.use_is)] 99 | try: 100 | result = op(inner_value.val, self.pattern_val) 101 | except Exception: 102 | pass 103 | else: 104 | if not result: 105 | return None 106 | elif positive: 107 | known_self = KnownValue(self.pattern_val) 108 | if value.is_assignable(known_self, self.ctx): 109 | return known_self 110 | else: 111 | return None 112 | else: 113 | pattern_type = type(self.pattern_val) 114 | if pattern_type is bool: 115 | simplified = unannotate(value) 116 | if isinstance(simplified, TypedValue) and simplified.typ is bool: 117 | return KnownValue(not self.pattern_val) 118 | elif safe_issubclass(pattern_type, enum.Enum): 119 | simplified = unannotate(value) 120 | if isinstance(simplified, TypedValue) and simplified.typ is type( 121 | self.pattern_val 122 | ): 123 | return unite_values( 124 | *[ 125 | KnownValue(val) 126 | for val in pattern_type 127 | if val is not self.pattern_val 128 | ] 129 | ) 130 | return value 131 | 132 | 133 | @dataclass 134 | class InPredicate: 135 | """Predicate that filters out values that are not in pattern_vals.""" 136 | 137 | pattern_vals: Sequence[object] 138 | pattern_type: type 139 | ctx: CanAssignContext 140 | 141 | def __call__(self, value: Value, positive: bool) -> Optional[Value]: 142 | inner_value = unannotate(value) 143 | if isinstance(inner_value, KnownValue): 144 | try: 145 | if positive: 146 | result = inner_value.val in self.pattern_vals 147 | else: 148 | result = inner_value.val not in self.pattern_vals 149 | except Exception: 150 | pass 151 | else: 152 | if not result: 153 | return None 154 | elif positive: 155 | acceptable_values = [ 156 | KnownValue(pattern_val) 157 | for pattern_val in self.pattern_vals 158 | if value.is_assignable(KnownValue(pattern_val), self.ctx) 159 | ] 160 | if acceptable_values: 161 | return unite_values(*acceptable_values) 162 | else: 163 | return None 164 | else: 165 | if safe_issubclass(self.pattern_type, enum.Enum): 166 | simplified = unannotate(value) 167 | if ( 168 | isinstance(simplified, TypedValue) 169 | and simplified.typ is self.pattern_type 170 | ): 171 | return unite_values( 172 | *[ 173 | KnownValue(val) 174 | for val in self.pattern_type 175 | if val not in self.pattern_vals 176 | ] 177 | ) 178 | return value 179 | -------------------------------------------------------------------------------- /pycroscope/reexport.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Functionality for dealing with implicit reexports. 4 | 5 | """ 6 | 7 | from ast import AST 8 | from collections import defaultdict 9 | from dataclasses import InitVar, dataclass, field 10 | from typing import Callable 11 | 12 | from .error_code import ErrorCode 13 | from .node_visitor import ErrorContext 14 | from .options import Options, PyObjectSequenceOption 15 | 16 | _ReexportConfigProvider = Callable[["ImplicitReexportTracker"], None] 17 | 18 | 19 | class ReexportConfig(PyObjectSequenceOption[_ReexportConfigProvider]): 20 | """Callbacks that can configure the :class:`ImplicitReexportTracker`, 21 | usually by setting names as explicitly exported.""" 22 | 23 | name = "reexport_config" 24 | is_global = True 25 | 26 | 27 | @dataclass 28 | class ImplicitReexportTracker: 29 | options: InitVar[Options] 30 | completed_modules: set[str] = field(default_factory=set) 31 | module_to_reexports: dict[str, set[str]] = field( 32 | default_factory=lambda: defaultdict(set) 33 | ) 34 | used_reexports: dict[str, list[tuple[str, AST, ErrorContext]]] = field( 35 | default_factory=lambda: defaultdict(list) 36 | ) 37 | 38 | def __post_init__(self, options: Options) -> None: 39 | for func in options.get_value_for(ReexportConfig): 40 | func(self) 41 | 42 | def record_exported_attribute(self, module: str, attr: str) -> None: 43 | self.module_to_reexports[module].add(attr) 44 | 45 | def record_module_completed(self, module: str) -> None: 46 | self.completed_modules.add(module) 47 | reexports = self.module_to_reexports[module] 48 | for attr, node, ctx in self.used_reexports[module]: 49 | if attr not in reexports: 50 | self.show_error(module, attr, node, ctx) 51 | 52 | def record_attribute_accessed( 53 | self, module: str, attr: str, node: AST, ctx: ErrorContext 54 | ) -> None: 55 | if module in self.completed_modules: 56 | if attr not in self.module_to_reexports[module]: 57 | self.show_error(module, attr, node, ctx) 58 | else: 59 | self.used_reexports[module].append((attr, node, ctx)) 60 | 61 | def show_error(self, module: str, attr: str, node: AST, ctx: ErrorContext) -> None: 62 | failure = ctx.show_error( 63 | node, 64 | f"Attribute '{attr}' is not exported by module '{module}'", 65 | ErrorCode.implicit_reexport, 66 | ) 67 | if failure is not None: 68 | ctx.all_failures.append(failure) 69 | -------------------------------------------------------------------------------- /pycroscope/runtime.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Expose an interface for a runtime type checker. 4 | 5 | """ 6 | 7 | from functools import cache 8 | from typing import Optional 9 | 10 | from typing_extensions import deprecated 11 | 12 | import pycroscope 13 | 14 | from .annotations import type_from_runtime 15 | from .find_unused import used 16 | from .value import CanAssignError, KnownValue 17 | 18 | 19 | @cache 20 | def _get_checker() -> "pycroscope.checker.Checker": 21 | return pycroscope.checker.Checker() 22 | 23 | 24 | def is_assignable(value: object, typ: object) -> bool: 25 | """Return whether ``value`` is assignable to ``typ``. 26 | 27 | This is essentially a more powerful version of ``isinstance()``. 28 | Examples:: 29 | 30 | >>> is_assignable(42, list[int]) 31 | False 32 | >>> is_assignable([], list[int]) 33 | True 34 | >>> is_assignable(["x"], list[int]) 35 | False 36 | 37 | The term "assignable" is defined in the typing specification: 38 | 39 | https://typing.readthedocs.io/en/latest/spec/glossary.html#term-assignable 40 | 41 | """ 42 | val = type_from_runtime(typ) 43 | can_assign = val.can_assign(KnownValue(value), _get_checker()) 44 | return not isinstance(can_assign, CanAssignError) 45 | 46 | 47 | @used 48 | def get_assignability_error(value: object, typ: object) -> Optional[str]: 49 | """Return an error message explaining why ``value`` is not 50 | assignable to ``type``, or None if it is assignable. 51 | 52 | Examples:: 53 | 54 | >>> print(get_assignability_error(42, list[int])) 55 | Cannot assign Literal[42] to list 56 | 57 | >>> print(get_assignability_error([], list[int])) 58 | None 59 | >>> print(get_assignability_error(["x"], list[int])) 60 | In element 0 61 | Cannot assign Literal['x'] to int 62 | 63 | """ 64 | val = type_from_runtime(typ) 65 | can_assign = val.can_assign(KnownValue(value), _get_checker()) 66 | if isinstance(can_assign, CanAssignError): 67 | return can_assign.display(depth=0) 68 | return None 69 | 70 | 71 | @used 72 | @deprecated("Use is_assignable instead") 73 | def is_compatible(value: object, typ: object) -> bool: 74 | """Deprecated alias for is_assignable(). Use that instead.""" 75 | return is_assignable(value, typ) 76 | 77 | 78 | @used 79 | @deprecated("Use get_assignability_error instead") 80 | def get_compatibility_error(value: object, typ: object) -> Optional[str]: 81 | """Deprecated alias for get_assignability_error(). Use that instead.""" 82 | return get_assignability_error(value, typ) 83 | -------------------------------------------------------------------------------- /pycroscope/safe.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | "Safe" operations that call into user code and catch any exceptions. 4 | 5 | """ 6 | 7 | import inspect 8 | import sys 9 | import types 10 | import typing 11 | from collections.abc import Container, Sequence 12 | from typing import Any, NewType, Optional, TypeVar, Union 13 | 14 | import typing_extensions 15 | 16 | try: 17 | import mypy_extensions 18 | except ImportError: 19 | mypy_extensions = None 20 | 21 | T = TypeVar("T") 22 | 23 | 24 | def hasattr_static(object: object, name: str) -> bool: 25 | """Similar to ``inspect.getattr_static()``.""" 26 | try: 27 | inspect.getattr_static(object, name) 28 | except AttributeError: 29 | return False 30 | else: 31 | return True 32 | 33 | 34 | def safe_hasattr(item: object, member: str) -> bool: 35 | """Safe version of ``hasattr()``.""" 36 | try: 37 | # some sketchy implementation (like paste.registry) of 38 | # __getattr__ cause hasattr() to throw an error. 39 | return hasattr(item, member) 40 | except Exception: 41 | return False 42 | 43 | 44 | def safe_getattr(value: object, attr: str, default: object) -> Any: 45 | """Return ``getattr(value, attr)`` or ``default`` if access raises an exception.""" 46 | try: 47 | return getattr(value, attr) 48 | except Exception: 49 | return default 50 | 51 | 52 | def safe_equals(left: object, right: object) -> bool: 53 | """Safely check whether two objects are equal.""" 54 | try: 55 | return bool(left == right) 56 | except Exception: 57 | return False 58 | 59 | 60 | def safe_issubclass(cls: type, class_or_tuple: Union[type, tuple[type, ...]]) -> bool: 61 | """Safe version of ``issubclass()``. 62 | 63 | Apart from incorrect arguments, ``issubclass(a, b)`` can throw an error 64 | only if `b` has a ``__subclasscheck__`` method that throws an error. 65 | Therefore, it is not necessary to use ``safe_issubclass()`` if the class 66 | is known to not override ``__subclasscheck__``. 67 | 68 | Defaults to False if ``issubclass()`` throws. 69 | 70 | """ 71 | try: 72 | return issubclass(cls, class_or_tuple) 73 | except Exception: 74 | return False 75 | 76 | 77 | def safe_isinstance( 78 | obj: object, class_or_tuple: Union[type[T], tuple[type[T], ...]] 79 | ) -> typing_extensions.TypeIs[T]: 80 | """Safe version of ``isinstance()``. 81 | 82 | ``isinstance(a, b)`` can throw an error in the following circumstances: 83 | 84 | - ``b`` is not a class 85 | - ``b`` has an ``__instancecheck__`` method that throws an error 86 | - ``a`` has a ``__class__`` property that throws an error 87 | 88 | Therefore, ``safe_isinstance()`` must be used when doing ``isinstance`` checks 89 | on arbitrary objects that come from user code. 90 | 91 | Defaults to False if ``isinstance()`` throws. 92 | 93 | """ 94 | try: 95 | return isinstance(obj, class_or_tuple) 96 | except Exception: 97 | return False 98 | 99 | 100 | def safe_in(item: T, collection: Container[T]) -> bool: 101 | """Safely check whether item is in collection. Defaults to returning false.""" 102 | # Workaround against mock objects sometimes throwing ValueError if you compare them, 103 | # and against objects throwing other kinds of errors if you use in. 104 | try: 105 | return item in collection 106 | except Exception: 107 | return False 108 | 109 | 110 | def is_hashable(obj: object) -> bool: 111 | """Return whether an object is hashable.""" 112 | try: 113 | hash(obj) 114 | except Exception: 115 | return False 116 | else: 117 | return True 118 | 119 | 120 | def all_of_type( 121 | elts: Sequence[object], typ: type[T] 122 | ) -> typing_extensions.TypeGuard[Sequence[T]]: 123 | """Returns whether all elements of elts are instances of typ.""" 124 | return all(isinstance(elt, typ) for elt in elts) 125 | 126 | 127 | if sys.version_info >= (3, 10): 128 | 129 | def is_newtype(obj: object) -> bool: 130 | return isinstance(obj, NewType) 131 | 132 | else: 133 | 134 | def is_newtype(obj: object) -> bool: 135 | return ( 136 | inspect.isfunction(obj) 137 | and hasattr(obj, "__supertype__") 138 | and isinstance(obj.__supertype__, type) 139 | ) 140 | 141 | 142 | def is_typing_name(obj: object, name: str) -> bool: 143 | objs, names = _fill_typing_name_cache(name) 144 | for typing_obj in objs: 145 | if obj is typing_obj: 146 | return True 147 | return safe_in(obj, names) 148 | 149 | 150 | try: 151 | from types import UnionType 152 | except ImportError: 153 | UnionType = None 154 | 155 | 156 | def is_union(obj: object) -> bool: 157 | return is_typing_name(obj, "Union") or (UnionType is not None and obj is UnionType) 158 | 159 | 160 | def is_instance_of_typing_name(obj: object, name: str) -> bool: 161 | objs, _ = _fill_typing_name_cache(name) 162 | return isinstance(obj, objs) 163 | 164 | 165 | _typing_name_cache: dict[str, tuple[tuple[Any, ...], tuple[str, ...]]] = {} 166 | 167 | 168 | def _fill_typing_name_cache(name: str) -> tuple[tuple[Any, ...], tuple[str, ...]]: 169 | try: 170 | return _typing_name_cache[name] 171 | except KeyError: 172 | objs = [] 173 | names = [] 174 | for mod in (typing, typing_extensions, mypy_extensions): 175 | if mod is None: 176 | continue 177 | try: 178 | objs.append(getattr(mod, name)) 179 | names.append(f"{mod}.{name}") 180 | except AttributeError: 181 | pass 182 | result = tuple(objs), tuple(names) 183 | _typing_name_cache[name] = result 184 | return result 185 | 186 | 187 | def get_fully_qualified_name(obj: object) -> Optional[str]: 188 | if safe_hasattr(obj, "__module__") and safe_hasattr(obj, "__qualname__"): 189 | return f"{obj.__module__}.{obj.__qualname__}" 190 | return None 191 | 192 | 193 | def is_dataclass_type(cls: type) -> bool: 194 | """Like dataclasses.is_dataclass(), but works correctly for a 195 | non-dataclass subclass of a dataclass.""" 196 | try: 197 | return "__dataclass_fields__" in cls.__dict__ 198 | except Exception: 199 | return False 200 | 201 | 202 | def is_bound_classmethod(obj: object) -> bool: 203 | """Returns whether the object is a bound classmethod.""" 204 | return safe_isinstance(obj, types.MethodType) and safe_isinstance( 205 | obj.__self__, type 206 | ) 207 | 208 | 209 | def safe_str(obj: object) -> str: 210 | """Like str(), but catches exceptions.""" 211 | try: 212 | return str(obj) 213 | except Exception as e: 214 | try: 215 | return f"" 216 | except Exception: 217 | return "" 218 | -------------------------------------------------------------------------------- /pycroscope/shared_options.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Defines some concrete options that cannot easily be placed elsewhere. 4 | 5 | """ 6 | 7 | from types import ModuleType 8 | from typing import Callable 9 | 10 | from .error_code import DISABLED_BY_DEFAULT, ErrorCode 11 | from .options import BooleanOption, PathSequenceOption, PyObjectSequenceOption 12 | from .value import VariableNameValue 13 | 14 | 15 | class Paths(PathSequenceOption): 16 | """Paths that pycroscope should type check.""" 17 | 18 | name = "paths" 19 | is_global = True 20 | should_create_command_line_option = False 21 | 22 | 23 | class ImportPaths(PathSequenceOption): 24 | """Directories that pycroscope may import from.""" 25 | 26 | name = "import_paths" 27 | is_global = True 28 | 29 | 30 | class EnforceNoUnused(BooleanOption): 31 | """If True, an error is raised when pycroscope finds any unused objects.""" 32 | 33 | name = "enforce_no_unused" 34 | is_global = True 35 | 36 | 37 | class VariableNameValues(PyObjectSequenceOption[VariableNameValue]): 38 | """List of :class:`pycroscope.value.VariableNameValue` instances that create pseudo-types 39 | associated with certain variable names.""" 40 | 41 | name = "variable_name_values" 42 | is_global = True 43 | 44 | 45 | for _code in ErrorCode: 46 | type( 47 | _code.name, 48 | (BooleanOption,), 49 | { 50 | "__doc__": _code.description, 51 | "name": _code.name, 52 | "default_value": _code not in DISABLED_BY_DEFAULT, 53 | "should_create_command_line_option": False, 54 | }, 55 | ) 56 | 57 | _IgnoreUnusedFunc = Callable[[ModuleType, str, object], bool] 58 | 59 | 60 | class IgnoreUnused(PyObjectSequenceOption[_IgnoreUnusedFunc]): 61 | """If any of these functions returns True, we will exclude this 62 | object from the unused object check. 63 | 64 | The arguments are the module the object was found in, the attribute used to 65 | access it, and the object itself. 66 | 67 | """ 68 | 69 | name = "ignore_unused" 70 | -------------------------------------------------------------------------------- /pycroscope/stubs/README: -------------------------------------------------------------------------------- 1 | Dummy stubs directory used in pycroscope's tests. 2 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/aliases.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import TypeAlias 2 | 3 | Alias = int 4 | ExplicitAlias: TypeAlias = int 5 | 6 | constant: int 7 | aliased_constant: Alias 8 | explicitly_aliased_constant: ExplicitAlias 9 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/args.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import TypedDict, Unpack 2 | 3 | class TD(TypedDict): 4 | x: int 5 | y: str 6 | 7 | def f(*args: Unpack[tuple[int, str]]) -> None: ... 8 | def g(**kwargs: Unpack[TD]) -> None: ... 9 | def h(*args: int) -> None: ... 10 | def i(**kwargs: str) -> None: ... 11 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/callable.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class StubCallable: 4 | def __call__(self, *args: Any, **kwds: Any) -> Any: ... 5 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/cdata.pyi: -------------------------------------------------------------------------------- 1 | import ctypes 2 | 3 | def f() -> ctypes._CData: ... 4 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/contextmanager.pyi: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | 3 | def cm() -> AbstractContextManager[int]: ... 4 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/defaults.pyi: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | T = TypeVar("T") 4 | U = TypeVar("U") 5 | V = TypeVar("V") 6 | W = TypeVar("W") 7 | 8 | def many_defaults( 9 | a: T = {"a": 1}, b: U = [1, ()], c: V = (1, 2), d: W = {1, 2} 10 | ) -> tuple[T, U, V, W]: ... 11 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/deprecated.pyi: -------------------------------------------------------------------------------- 1 | from typing import overload 2 | 3 | from pycroscope.extensions import deprecated 4 | 5 | @overload 6 | @deprecated("int support is deprecated") 7 | def deprecated_overload(x: int) -> int: ... 8 | @overload 9 | def deprecated_overload(x: str) -> str: ... 10 | @deprecated("no functioning capybaras") 11 | def deprecated_function(x: int) -> int: ... 12 | 13 | class Capybara: 14 | @deprecated("no methodical capybaras") 15 | def deprecated_method(self, x: int) -> int: ... 16 | 17 | @deprecated("no classy capybaras") 18 | class DeprecatedCapybara: ... 19 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/evaluated.pyi: -------------------------------------------------------------------------------- 1 | from typing import IO, Any, BinaryIO, TextIO 2 | 3 | from pycroscope.extensions import evaluated 4 | 5 | @evaluated 6 | def open(mode: str): 7 | if mode == "r": 8 | return TextIO 9 | elif mode == "rb": 10 | return BinaryIO 11 | else: 12 | return IO[Any] 13 | 14 | @evaluated 15 | def open2(mode: str) -> IO[Any]: 16 | if mode == "r": 17 | return TextIO 18 | elif mode == "rb": 19 | return BinaryIO 20 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/initnew.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Iterator 2 | from typing import Generic, TypeVar, overload 3 | 4 | _T = TypeVar("_T") 5 | 6 | class simple: 7 | def __init__(self, x: int) -> None: ... 8 | 9 | class my_enumerate(Iterator[tuple[int, _T]], Generic[_T]): 10 | def __init__(self, iterable: Iterable[_T], start: int = ...) -> None: ... 11 | 12 | class overloadinit(Generic[_T]): 13 | @overload 14 | def __init__(self, a: int, b: str, c: _T) -> None: ... 15 | @overload 16 | def __init__(self, a: str, b: int, c: _T) -> None: ... 17 | 18 | class simplenew: 19 | def __new__(cls, x: int) -> simplenew: ... 20 | 21 | class overloadnew(Generic[_T]): 22 | @overload 23 | def __new__(cls, a: int, b: str, c: _T) -> overloadnew[_T]: ... 24 | @overload 25 | def __new__(cls, a: str, b: int, c: _T) -> overloadnew[_T]: ... 26 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/nested.pyi: -------------------------------------------------------------------------------- 1 | class Outer: 2 | class Inner: 3 | def __init__(self, x: int) -> None: ... 4 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/overloaded.pyi: -------------------------------------------------------------------------------- 1 | from typing import overload 2 | 3 | from typing_extensions import Literal 4 | 5 | @overload 6 | def func(x: int, y: Literal[1] = ..., z: str = ...) -> int: ... 7 | @overload 8 | def func(x: int, y: int, z: str = ...) -> str: ... 9 | @overload 10 | def func(x: str, y: int = ..., z: str = ...) -> float: ... 11 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/paramspec.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from typing_extensions import ParamSpec, TypeVar 4 | 5 | T = TypeVar("T") 6 | P = ParamSpec("P") 7 | 8 | def f(x: T) -> T: ... 9 | def g(x: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: ... 10 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/posonly.pyi: -------------------------------------------------------------------------------- 1 | def f(x: int, /, y: int, z: int = ...) -> None: ... 2 | def g(x: int = ..., /, y: int = ...) -> None: ... 3 | def h(x: int, /, y: int = ..., z: int = ...) -> None: ... 4 | def two_pos_only(x: int, y: str = ..., /) -> None: ... 5 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/recursion.pyi: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | from typing import AnyStr 3 | 4 | from typing_extensions import TypeAlias 5 | 6 | class _ScandirIterator(AbstractContextManager[_ScandirIterator[AnyStr]]): 7 | def close(self) -> None: ... 8 | 9 | StrJson: TypeAlias = str | dict[str, StrJson] 10 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/self.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import Self 2 | 3 | class X: 4 | def ret(self) -> Self: ... 5 | @classmethod 6 | def from_config(cls) -> Self: ... 7 | def __new__(cls) -> Self: ... 8 | 9 | class Y(X): ... 10 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/tsself.pyi: -------------------------------------------------------------------------------- 1 | import _typeshed 2 | 3 | class X: 4 | def __new__(cls: type[_typeshed.Self]) -> _typeshed.Self: ... 5 | def method(self) -> int: ... 6 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/typeddict.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import NotRequired, Required, TypedDict 2 | 3 | class TD1(TypedDict): 4 | a: int 5 | b: str 6 | 7 | class TD2(TypedDict, total=False): 8 | a: int 9 | b: str 10 | 11 | class PEP655(TypedDict): 12 | a: NotRequired[int] 13 | b: Required[str] 14 | 15 | class Inherited(TD1): 16 | c: float 17 | -------------------------------------------------------------------------------- /pycroscope/stubs/_pycroscope_tests-stubs/typevar.pyi: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | # Just testing that the presence of a default doesn't 4 | # completely break type checking. 5 | _T = TypeVar("_T", default=None) 6 | 7 | def f(x: _T) -> _T: ... 8 | -------------------------------------------------------------------------------- /pycroscope/stubs/pycroscope-stubs/extensions.pyi: -------------------------------------------------------------------------------- 1 | # Has to exist for stubs to be able to import 2 | # from it, because typeshed_client doesn't let 3 | # stubs import from non-stub files. 4 | 5 | from collections.abc import Sequence 6 | from typing import Any, Callable 7 | 8 | def reveal_type(value: object) -> None: ... 9 | def get_overloads(fully_qualified_name: str) -> list[Callable[..., Any]]: ... 10 | def get_type_evaluation(fully_qualified_name: str) -> Sequence[Callable[..., Any]]: ... 11 | def overload(func: Callable[..., Any]) -> Callable[..., Any]: ... 12 | def evaluated(func: Callable[..., Any]) -> Callable[..., Any]: ... 13 | def is_provided(arg: Any) -> bool: ... 14 | def is_positional(arg: Any) -> bool: ... 15 | def is_keyword(arg: Any) -> bool: ... 16 | def is_of_type(arg: Any, type: Any, *, exclude_any: bool = ...) -> bool: ... 17 | def show_error(message: str, *, argument: Any | None = ...) -> bool: ... 18 | def deprecated(__message: str) -> Callable[..., Any]: ... 19 | def __getattr__(self, __arg: str) -> Any: ... 20 | -------------------------------------------------------------------------------- /pycroscope/test.toml: -------------------------------------------------------------------------------- 1 | [tool.pycroscope] 2 | #classes_checked_for_asynq = ["pycroscope.tests.CheckedForAsynq"] 3 | methods_not_checked_for_asynq = ["not_checked"] 4 | variable_name_values = ["pycroscope.tests.uid_vnv", "pycroscope.tests.qid_vnv"] 5 | constructor_hooks = ["pycroscope.test_config.get_constructor"] 6 | known_signatures = ["pycroscope.test_config.get_known_signatures"] 7 | unwrap_class = ["pycroscope.test_config.unwrap_class"] 8 | stub_path = ["./stubs"] 9 | functions_safe_to_call = [ 10 | "pycroscope.tests.make_simple_sequence", 11 | "pycroscope.value.make_coro_type", 12 | ] 13 | class_attribute_transformers = [ 14 | "pycroscope.test_config.transform_class_attribute" 15 | ] 16 | known_attribute_hook = [ 17 | "pycroscope.test_config.known_attribute_hook" 18 | ] 19 | disallowed_imports = [ 20 | "getopt", 21 | "email.quoprimime", 22 | "xml", 23 | ] 24 | -------------------------------------------------------------------------------- /pycroscope/test_analysis_lib.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import itertools 3 | import os.path 4 | 5 | import pytest 6 | 7 | from .analysis_lib import get_indentation, get_line_range_for_node, object_from_string 8 | 9 | 10 | def test_get_indentation() -> None: 11 | assert 0 == get_indentation("\n") 12 | assert 0 == get_indentation("") 13 | assert 4 == get_indentation(" pass\n") 14 | assert 1 == get_indentation(" hello") 15 | 16 | 17 | CODE = r'''from qcore.asserts import assert_eq 18 | 19 | from pycroscope.analysis_lib import get_indentation 20 | 21 | 22 | def test_get_indentation() -> None: 23 | assert_eq(0, get_indentation('\n')) 24 | assert_eq(0, get_indentation('')) 25 | assert_eq(4, get_indentation(' pass\n')) 26 | assert_eq(1, get_indentation(' hello')) 27 | 28 | 29 | def test_get_line_range_for_node() -> None: 30 | pass 31 | 32 | x = """ 33 | really 34 | long 35 | multiline 36 | string 37 | """ 38 | ''' 39 | 40 | 41 | def test_get_line_range_for_node() -> None: 42 | lines = CODE.splitlines() 43 | tree = ast.parse(CODE) 44 | assert [1] == get_line_range_for_node(tree.body[0], lines) 45 | assert [3] == get_line_range_for_node(tree.body[1], lines) 46 | assert [6, 7, 8, 9, 10] == get_line_range_for_node(tree.body[2], lines) 47 | assert [13, 14] == get_line_range_for_node(tree.body[3], lines) 48 | assert [16, 17, 18, 19, 20, 21] == get_line_range_for_node(tree.body[4], lines) 49 | 50 | 51 | def test_object_from_string() -> None: 52 | assert object_from_string("os.path") is os.path 53 | assert object_from_string("os.path.join") is os.path.join 54 | assert object_from_string("os.path:join") is os.path.join 55 | assert ( 56 | object_from_string("itertools.chain.from_iterable") 57 | == itertools.chain.from_iterable 58 | ) 59 | assert ( 60 | object_from_string("itertools:chain.from_iterable") 61 | == itertools.chain.from_iterable 62 | ) 63 | 64 | with pytest.raises(ImportError): 65 | object_from_string("itertools.chain:from_iterable") 66 | with pytest.raises(AttributeError): 67 | object_from_string("os:nonexistent") 68 | with pytest.raises(AttributeError): 69 | object_from_string("os.path.nonexistent") 70 | -------------------------------------------------------------------------------- /pycroscope/test_ast_annotator.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from pathlib import Path 3 | from typing import Callable, Type 4 | 5 | from .analysis_lib import files_with_extension_from_directory 6 | from .ast_annotator import annotate_code, annotate_file 7 | from .value import KnownValue, Value, unannotate 8 | 9 | 10 | def _check_inferred_value( 11 | tree: ast.Module, 12 | node_type: Type[ast.AST], 13 | value: Value, 14 | predicate: Callable[[ast.AST], bool] = lambda node: True, 15 | ) -> None: 16 | for node in ast.walk(tree): 17 | if isinstance(node, node_type) and predicate(node): 18 | assert hasattr(node, "inferred_value"), repr(node) 19 | assert value == unannotate(node.inferred_value), ast.dump(node) 20 | 21 | 22 | def test_annotate_code() -> None: 23 | tree = annotate_code("a = 1") 24 | _check_inferred_value(tree, ast.Constant, KnownValue(1)) 25 | _check_inferred_value(tree, ast.Name, KnownValue(1)) 26 | 27 | tree = annotate_code( 28 | """ 29 | class X: 30 | def __init__(self): 31 | self.a = 1 32 | """ 33 | ) 34 | _check_inferred_value(tree, ast.Attribute, KnownValue(1)) 35 | tree = annotate_code( 36 | """ 37 | class X: 38 | def __init__(self): 39 | self.a = 1 40 | 41 | x = X() 42 | x.a + 1 43 | """ 44 | ) 45 | _check_inferred_value(tree, ast.BinOp, KnownValue(2)) 46 | 47 | tree = annotate_code( 48 | """ 49 | class A: 50 | def __init__(self): 51 | self.a = 1 52 | 53 | def bla(self): 54 | return self.a 55 | 56 | 57 | a = A() 58 | b = a.bla() 59 | """ 60 | ) 61 | _check_inferred_value(tree, ast.Name, KnownValue(1), lambda node: node.id == "b") 62 | 63 | 64 | def test_everything_annotated() -> None: 65 | pycroscope_dir = Path(__file__).parent 66 | failures = [] 67 | for filename in sorted(files_with_extension_from_directory("py", pycroscope_dir)): 68 | tree = annotate_file(filename, show_errors=True) 69 | for node in ast.walk(tree): 70 | if ( 71 | hasattr(node, "lineno") 72 | and hasattr(node, "col_offset") 73 | and not hasattr(node, "inferred_value") 74 | and not isinstance(node, (ast.keyword, ast.arg)) 75 | ): 76 | failures.append((filename, node)) 77 | if failures: 78 | for filename, node in failures: 79 | print(f"{filename}:{node.lineno}:{node.col_offset}: {ast.dump(node)}") 80 | assert False, f"found no annotations on {len(failures)} expressions" 81 | -------------------------------------------------------------------------------- /pycroscope/test_boolability.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | 3 | from unittest.mock import ANY 4 | 5 | from typing_extensions import ParamSpec 6 | 7 | from .boolability import Boolability, get_boolability 8 | from .maybe_asynq import asynq 9 | from .stacked_scopes import Composite 10 | from .test_name_check_visitor import TestNameCheckVisitorBase 11 | from .test_node_visitor import assert_passes, skip_if, skip_if_not_installed 12 | from .value import ( 13 | NO_RETURN_VALUE, 14 | AnnotatedValue, 15 | AnySource, 16 | AnyValue, 17 | DictIncompleteValue, 18 | KnownValue, 19 | KVPair, 20 | NewTypeValue, 21 | ParamSpecArgsValue, 22 | ParamSpecKwargsValue, 23 | SequenceValue, 24 | TypeAlias, 25 | TypeAliasValue, 26 | TypedDictEntry, 27 | TypedDictValue, 28 | TypedValue, 29 | UnboundMethodValue, 30 | ) 31 | 32 | 33 | class BadBool: 34 | def __bool__(self): 35 | raise Exception("fooled ya") 36 | 37 | 38 | class HasLen: 39 | def __len__(self) -> int: 40 | return 42 41 | 42 | 43 | @skip_if(asynq is None) 44 | def test_get_boolability_async() -> None: 45 | from asynq.futures import FutureBase 46 | 47 | future = TypedValue(FutureBase) 48 | 49 | assert Boolability.erroring_bool == get_boolability(future) 50 | assert Boolability.erroring_bool == get_boolability( 51 | AnnotatedValue(future, [KnownValue(1)]) 52 | ) 53 | assert Boolability.erroring_bool == get_boolability(future | KnownValue(1)) 54 | 55 | 56 | def test_get_boolability() -> None: 57 | assert Boolability.boolable == get_boolability(AnyValue(AnySource.unannotated)) 58 | assert Boolability.type_always_true == get_boolability( 59 | UnboundMethodValue("method", Composite(TypedValue(int))) 60 | ) 61 | assert Boolability.boolable == get_boolability( 62 | UnboundMethodValue( 63 | "method", Composite(TypedValue(int)), secondary_attr_name="whatever" 64 | ) 65 | ) 66 | 67 | # Sequence/dict values 68 | assert Boolability.type_always_true == get_boolability( 69 | TypedDictValue({"a": TypedDictEntry(TypedValue(int))}) 70 | ) 71 | assert Boolability.boolable == get_boolability( 72 | TypedDictValue({"a": TypedDictEntry(TypedValue(int), required=False)}) 73 | ) 74 | assert Boolability.type_always_true == get_boolability( 75 | SequenceValue(tuple, [(False, KnownValue(1))]) 76 | ) 77 | assert Boolability.value_always_false == get_boolability(SequenceValue(tuple, [])) 78 | assert Boolability.boolable == get_boolability( 79 | SequenceValue(tuple, [(True, KnownValue(1))]) 80 | ) 81 | assert Boolability.type_always_true == get_boolability( 82 | # many 1s followed by one 2 83 | SequenceValue(tuple, [(True, KnownValue(1)), (False, KnownValue(2))]) 84 | ) 85 | assert Boolability.value_always_true_mutable == get_boolability( 86 | SequenceValue(list, [(False, KnownValue(1))]) 87 | ) 88 | assert Boolability.value_always_false_mutable == get_boolability( 89 | SequenceValue(list, []) 90 | ) 91 | assert Boolability.boolable == get_boolability( 92 | SequenceValue(list, [(True, KnownValue(1))]) 93 | ) 94 | assert Boolability.value_always_true_mutable == get_boolability( 95 | # many 1s followed by one 2 96 | SequenceValue(list, [(True, KnownValue(1)), (False, KnownValue(2))]) 97 | ) 98 | 99 | assert Boolability.value_always_true_mutable == get_boolability( 100 | DictIncompleteValue(dict, [KVPair(KnownValue(1), KnownValue(1))]) 101 | ) 102 | assert Boolability.boolable == get_boolability( 103 | DictIncompleteValue( 104 | dict, [KVPair(KnownValue(1), KnownValue(1), is_required=False)] 105 | ) 106 | ) 107 | assert Boolability.boolable == get_boolability( 108 | DictIncompleteValue( 109 | dict, [KVPair(TypedValue(int), KnownValue(1), is_many=True)] 110 | ) 111 | ) 112 | assert Boolability.value_always_false_mutable == get_boolability( 113 | DictIncompleteValue(dict, []) 114 | ) 115 | 116 | # KnownValue 117 | assert Boolability.erroring_bool == get_boolability(KnownValue(BadBool())) 118 | assert Boolability.value_always_true == get_boolability(KnownValue(1)) 119 | assert Boolability.type_always_true == get_boolability(KnownValue(int)) 120 | assert Boolability.value_always_false == get_boolability(KnownValue(0)) 121 | 122 | # TypedValue 123 | assert Boolability.boolable == get_boolability(TypedValue(HasLen)) 124 | assert Boolability.boolable == get_boolability(TypedValue(object)) 125 | assert Boolability.boolable == get_boolability(TypedValue(int)) 126 | assert Boolability.boolable == get_boolability(TypedValue(object)) 127 | 128 | # ParamSpec args and kwargs 129 | P = ParamSpec("P") 130 | assert get_boolability(ParamSpecArgsValue(P)) == Boolability.boolable 131 | assert get_boolability(ParamSpecKwargsValue(P)) == Boolability.boolable 132 | 133 | # MultiValuedValue and AnnotatedValue 134 | assert Boolability.boolable == get_boolability(TypedValue(int) | TypedValue(str)) 135 | assert Boolability.boolable == get_boolability(TypedValue(int) | KnownValue("")) 136 | assert Boolability.boolable == get_boolability(KnownValue(True) | KnownValue(False)) 137 | assert Boolability.boolable == get_boolability(TypedValue(type) | KnownValue(False)) 138 | assert Boolability.value_always_true == get_boolability( 139 | TypedValue(type) | KnownValue(True) 140 | ) 141 | assert Boolability.value_always_true_mutable == get_boolability( 142 | TypedValue(type) | KnownValue([1]) 143 | ) 144 | assert Boolability.value_always_true_mutable == get_boolability( 145 | KnownValue([1]) | KnownValue(True) 146 | ) 147 | assert Boolability.value_always_false_mutable == get_boolability( 148 | KnownValue(False) | KnownValue([]) 149 | ) 150 | assert Boolability.value_always_false == get_boolability( 151 | KnownValue(False) | KnownValue("") 152 | ) 153 | assert Boolability.boolable == get_boolability(NO_RETURN_VALUE) 154 | 155 | # TypeAliasValue 156 | alias = TypeAliasValue( 157 | "alias", __name__, TypeAlias(lambda: TypedValue(int), lambda: ()) 158 | ) 159 | assert get_boolability(alias) == Boolability.boolable 160 | alias = TypeAliasValue( 161 | "alias", __name__, TypeAlias(lambda: KnownValue(True), lambda: ()) 162 | ) 163 | assert get_boolability(alias) == Boolability.value_always_true 164 | 165 | assert ( 166 | get_boolability(NewTypeValue("NT1", KnownValue(True), ANY)) 167 | == Boolability.value_always_true 168 | ) 169 | assert ( 170 | get_boolability(NewTypeValue("NT2", KnownValue(False), ANY)) 171 | == Boolability.value_always_false 172 | ) 173 | assert ( 174 | get_boolability(NewTypeValue("NT3", TypedValue(int), ANY)) 175 | == Boolability.boolable 176 | ) 177 | 178 | 179 | class TestAssert(TestNameCheckVisitorBase): 180 | @assert_passes() 181 | def test_assert_never_fails(self): 182 | def capybara(): 183 | tpl = "this", "doesn't", "work" 184 | assert tpl # E: type_always_true 185 | 186 | @assert_passes() 187 | def test_assert_bad_bool(self): 188 | class X(object): 189 | def __bool__(self): 190 | raise Exception("I am a poorly behaved object") 191 | 192 | __nonzero__ = __bool__ 193 | 194 | x = X() 195 | 196 | def capybara(): 197 | assert x # E: type_does_not_support_bool 198 | 199 | 200 | class TestConditionAlwaysTrue(TestNameCheckVisitorBase): 201 | @assert_passes() 202 | def test_method(self): 203 | class Capybara(object): 204 | def eat(self): 205 | pass 206 | 207 | def maybe_eat(self): 208 | if self.eat: # E: type_always_true 209 | self.eat() 210 | 211 | @assert_passes() 212 | def test_typed_value(self): 213 | class Capybara(object): 214 | pass 215 | 216 | if Capybara(): # E: type_always_true 217 | pass 218 | 219 | @assert_passes() 220 | def test_overrides_len(self): 221 | class Capybara(object): 222 | def __len__(self): 223 | return 42 224 | 225 | if Capybara(): 226 | pass 227 | 228 | @assert_passes() 229 | def test_object(): 230 | obj = object() 231 | 232 | def capybara(): 233 | True if obj else False # E: type_always_true 234 | obj and False # E: type_always_true 235 | [] and obj and False # E: type_always_true 236 | obj or True # E: type_always_true 237 | not obj # E: type_always_true 238 | 239 | @skip_if_not_installed("asynq") 240 | @assert_passes() 241 | def test_async_yield_or(self): 242 | from asynq import asynq 243 | 244 | @asynq() 245 | def kerodon(): 246 | return 42 247 | 248 | @asynq() 249 | def capybara(): 250 | yield kerodon.asynq() or None # E: type_does_not_support_bool 251 | -------------------------------------------------------------------------------- /pycroscope/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Configuration file specific to tests. 4 | 5 | """ 6 | 7 | from pathlib import Path 8 | from typing import Dict, Optional, Tuple 9 | 10 | from pycroscope.annotated_types import Gt 11 | 12 | from . import tests, value 13 | from .arg_spec import ArgSpecCache 14 | from .error_code import ErrorCode, register_error_code 15 | from .find_unused import used 16 | from .options import Options 17 | from .signature import ( 18 | CallContext, 19 | ConcreteSignature, 20 | OverloadedSignature, 21 | ParameterKind, 22 | Signature, 23 | SigParameter, 24 | ) 25 | 26 | register_error_code("internal_test", "Used in an internal test") 27 | 28 | 29 | def _failing_impl(ctx: CallContext) -> value.Value: 30 | ctx.show_error("Always errors") 31 | return value.AnyValue(value.AnySource.error) 32 | 33 | 34 | def _custom_code_impl(ctx: CallContext) -> value.Value: 35 | ctx.show_error("Always errors", ErrorCode.internal_test) 36 | return value.AnyValue(value.AnySource.error) 37 | 38 | 39 | @used # in test.toml 40 | def get_constructor(cls: type) -> Optional[Signature]: 41 | """Return a constructor signature for this class. 42 | 43 | May return either a function that pycroscope will use the signature of, an inspect 44 | Signature object, or a pycroscope Signature object. The function or signature 45 | should take a self parameter. 46 | 47 | """ 48 | if issubclass(cls, tests.KeywordOnlyArguments): 49 | return Signature.make( 50 | [ 51 | SigParameter("self", kind=ParameterKind.POSITIONAL_ONLY), 52 | SigParameter("args", kind=ParameterKind.VAR_POSITIONAL), 53 | SigParameter( 54 | "kwonly_arg", 55 | kind=ParameterKind.KEYWORD_ONLY, 56 | default=value.KnownValue(None), 57 | ), 58 | ], 59 | callable=tests.KeywordOnlyArguments.__init__, 60 | ) 61 | return None 62 | 63 | 64 | def _make_union_in_annotated_impl(ctx: CallContext) -> value.Value: 65 | return value.AnnotatedValue( 66 | value.TypedValue(float) | value.TypedValue(int), 67 | [value.CustomCheckExtension(Gt(0))], 68 | ) 69 | 70 | 71 | @used # in test.toml 72 | def get_known_signatures( 73 | arg_spec_cache: ArgSpecCache, 74 | ) -> Dict[object, ConcreteSignature]: 75 | failing_impl_sig = arg_spec_cache.get_argspec(tests.FailingImpl, impl=_failing_impl) 76 | custom_sig = arg_spec_cache.get_argspec(tests.custom_code, impl=_custom_code_impl) 77 | union_in_anno_sig = arg_spec_cache.get_argspec( 78 | tests.make_union_in_annotated, impl=_make_union_in_annotated_impl 79 | ) 80 | assert isinstance(custom_sig, Signature), custom_sig 81 | assert isinstance(failing_impl_sig, Signature), failing_impl_sig 82 | assert isinstance(union_in_anno_sig, Signature), union_in_anno_sig 83 | return { 84 | tests.takes_kwonly_argument: Signature.make( 85 | [ 86 | SigParameter("a"), 87 | SigParameter( 88 | "kwonly_arg", 89 | ParameterKind.KEYWORD_ONLY, 90 | annotation=value.TypedValue(bool), 91 | ), 92 | ], 93 | callable=tests.takes_kwonly_argument, 94 | ), 95 | tests.FailingImpl: failing_impl_sig, 96 | tests.custom_code: custom_sig, 97 | tests.make_union_in_annotated: union_in_anno_sig, 98 | tests.overloaded: OverloadedSignature( 99 | [ 100 | Signature.make([], value.TypedValue(int), callable=tests.overloaded), 101 | Signature.make( 102 | [ 103 | SigParameter( 104 | "x", 105 | ParameterKind.POSITIONAL_ONLY, 106 | annotation=value.TypedValue(str), 107 | ) 108 | ], 109 | value.TypedValue(str), 110 | callable=tests.overloaded, 111 | ), 112 | ] 113 | ), 114 | } 115 | 116 | 117 | @used # in test.toml 118 | def unwrap_class(cls: type) -> type: 119 | """Does any application-specific unwrapping logic for wrapper classes.""" 120 | if ( 121 | isinstance(cls, type) 122 | and issubclass(cls, tests.Wrapper) 123 | and cls is not tests.Wrapper 124 | ): 125 | return cls.base 126 | return cls 127 | 128 | 129 | class StringField: 130 | pass 131 | 132 | 133 | @used # in test.toml 134 | def transform_class_attribute( 135 | attr: object, 136 | ) -> Optional[Tuple[value.Value, value.Value]]: 137 | """Transforms a StringField attribute.""" 138 | if isinstance(attr, StringField): 139 | return value.TypedValue(str), value.NO_RETURN_VALUE 140 | return None 141 | 142 | 143 | SPECIAL_STRING = "capybara!" 144 | 145 | 146 | @used # in test.toml 147 | def known_attribute_hook(obj: object, attr: str) -> Optional[value.Value]: 148 | if obj == SPECIAL_STRING and attr == "special": 149 | return value.KnownValue("special") 150 | return None 151 | 152 | 153 | CONFIG_PATH = Path(__file__).parent / "test.toml" 154 | TEST_OPTIONS = Options.from_option_list(config_file_path=CONFIG_PATH) 155 | -------------------------------------------------------------------------------- /pycroscope/test_definite_value.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes 4 | 5 | 6 | class TestSysPlatform(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test(self): 9 | import os 10 | import sys 11 | 12 | from typing_extensions import assert_type 13 | 14 | def capybara() -> None: 15 | if sys.platform == "win32": 16 | assert_type(os.P_DETACH, int) 17 | else: 18 | os.P_DETACH # E: undefined_attribute 19 | 20 | 21 | class TestSysVersion(TestNameCheckVisitorBase): 22 | @assert_passes() 23 | def test(self): 24 | import ast 25 | import sys 26 | 27 | from typing_extensions import assert_type 28 | 29 | if sys.version_info >= (3, 10): 30 | 31 | def capybara(m: ast.Match) -> None: 32 | assert_type(m, ast.Match) 33 | 34 | if sys.version_info >= (3, 12): 35 | 36 | def pacarana(m: ast.TypeVar) -> None: 37 | assert_type(m, ast.TypeVar) 38 | -------------------------------------------------------------------------------- /pycroscope/test_deprecated.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes, skip_before 4 | 5 | 6 | class TestStub(TestNameCheckVisitorBase): 7 | @skip_before((3, 10)) # line number changed, don't care enough about 3.9 8 | @assert_passes() 9 | def test(self): 10 | def capybara(): 11 | print("keep") 12 | from _pycroscope_tests.deprecated import DeprecatedCapybara # E: deprecated 13 | 14 | print("these imports") 15 | from _pycroscope_tests.deprecated import ( 16 | deprecated_function, # E: deprecated 17 | ) 18 | 19 | print("separate") 20 | from _pycroscope_tests.deprecated import deprecated_overload 21 | 22 | deprecated_overload(1) # E: deprecated 23 | deprecated_overload("x") 24 | 25 | deprecated_function(1) 26 | print(deprecated_function) 27 | DeprecatedCapybara() 28 | print(DeprecatedCapybara) 29 | 30 | @skip_before((3, 10)) 31 | @assert_passes() 32 | def test_multiline_import(self): 33 | def capybara(): 34 | from _pycroscope_tests.deprecated import ( 35 | DeprecatedCapybara, # E: deprecated 36 | deprecated_function, # E: deprecated 37 | deprecated_overload, 38 | ) 39 | 40 | return [deprecated_function, deprecated_overload, DeprecatedCapybara] 41 | 42 | 43 | class TestRuntime(TestNameCheckVisitorBase): 44 | @assert_passes() 45 | def test_overload(self): 46 | from pycroscope.extensions import deprecated, overload 47 | 48 | @overload 49 | @deprecated("int support is deprecated") 50 | def deprecated_overload(x: int) -> int: ... 51 | 52 | @overload 53 | def deprecated_overload(x: str) -> str: ... 54 | 55 | def deprecated_overload(x): 56 | return x 57 | 58 | def capybara(): 59 | deprecated_overload(1) # E: deprecated 60 | deprecated_overload("x") 61 | 62 | @assert_passes() 63 | def test_function(self): 64 | from pycroscope.extensions import deprecated 65 | 66 | @deprecated("no functioning capybaras") 67 | def deprecated_function(x: int) -> int: 68 | return x 69 | 70 | def capybara(): 71 | print(deprecated_function) # E: deprecated 72 | deprecated_function(1) # E: deprecated 73 | 74 | @assert_passes() 75 | def test_method(self): 76 | from pycroscope.extensions import deprecated 77 | 78 | class Cls: 79 | @deprecated("no methodical capybaras") 80 | def deprecated_method(self, x: int) -> int: 81 | return x 82 | 83 | def capybara(): 84 | Cls().deprecated_method(1) # E: deprecated 85 | print(Cls.deprecated_method) # E: deprecated 86 | 87 | @assert_passes() 88 | def test_class(self): 89 | from pycroscope.extensions import deprecated 90 | 91 | @deprecated("no classy capybaras") 92 | class DeprecatedClass: 93 | pass 94 | 95 | def capybara(): 96 | print(DeprecatedClass) # E: deprecated 97 | return DeprecatedClass() # E: deprecated 98 | -------------------------------------------------------------------------------- /pycroscope/test_enum.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes 5 | from .value import AnySource, AnyValue, KnownValue, SubclassValue, TypedValue 6 | 7 | 8 | class TestEnum(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_functional(self): 11 | from enum import Enum, IntEnum 12 | 13 | def capybara(): 14 | X = Enum("X", ["a", "b", "c"]) 15 | assert_is_value(X, SubclassValue(TypedValue(Enum))) 16 | 17 | IE = IntEnum("X", ["a", "b", "c"]) 18 | assert_is_value(IE, SubclassValue(TypedValue(Enum))) 19 | 20 | @assert_passes() 21 | def test_call(self): 22 | from enum import Enum 23 | 24 | class X(Enum): 25 | a = 1 26 | b = 2 27 | 28 | def capybara(): 29 | assert_is_value(X(1), TypedValue(X)) 30 | # This should be an error, but the typeshed 31 | # stubs are too lenient. 32 | assert_is_value(X(None), TypedValue(X)) 33 | 34 | @assert_passes() 35 | def test_iteration(self): 36 | from enum import Enum, IntEnum 37 | from typing import Type 38 | 39 | class X(Enum): 40 | a = 1 41 | b = 2 42 | 43 | class MySubclass(str, Enum): 44 | pass 45 | 46 | def capybara( 47 | enum_t: Type[Enum], int_enum_t: Type[IntEnum], subclass_t: Type[MySubclass] 48 | ): 49 | for x in X: 50 | assert_is_value(x, TypedValue(X)) 51 | 52 | for et in enum_t: 53 | assert_is_value(et, TypedValue(Enum)) 54 | 55 | for iet in int_enum_t: 56 | assert_is_value(iet, TypedValue(IntEnum)) 57 | 58 | for st in subclass_t: 59 | assert_is_value(st, TypedValue(MySubclass)) 60 | 61 | @assert_passes() 62 | def test_duplicate_enum_member(self): 63 | import enum 64 | 65 | class Foo(enum.Enum): 66 | a = 1 67 | b = 1 # E: duplicate_enum_member 68 | 69 | 70 | class TestNarrowing(TestNameCheckVisitorBase): 71 | @assert_passes() 72 | def test_exhaustive(self): 73 | from enum import Enum 74 | 75 | from typing_extensions import assert_never 76 | 77 | class X(Enum): 78 | a = 1 79 | b = 2 80 | 81 | def capybara_eq(x: X): 82 | if x == X.a: 83 | assert_is_value(x, KnownValue(X.a)) 84 | else: 85 | assert_is_value(x, KnownValue(X.b)) 86 | 87 | def capybara_is(x: X): 88 | if x is X.a: 89 | assert_is_value(x, KnownValue(X.a)) 90 | else: 91 | assert_is_value(x, KnownValue(X.b)) 92 | 93 | def capybara_in_list(x: X): 94 | if x in [X.a]: 95 | assert_is_value(x, KnownValue(X.a)) 96 | else: 97 | assert_is_value(x, KnownValue(X.b)) 98 | 99 | def capybara_in_tuple(x: X): 100 | if x in (X.a,): 101 | assert_is_value(x, KnownValue(X.a)) 102 | else: 103 | assert_is_value(x, KnownValue(X.b)) 104 | 105 | def test_multi_in(x: X): 106 | if x in (X.a, X.b): 107 | assert_is_value(x, KnownValue(X.a) | KnownValue(X.b)) 108 | else: 109 | assert_never(x) 110 | 111 | def whatever(x): 112 | if x == X.a: 113 | assert_is_value(x, KnownValue(X.a)) 114 | return 115 | assert_is_value(x, AnyValue(AnySource.unannotated)) 116 | 117 | 118 | class TestEnumName(TestNameCheckVisitorBase): 119 | @assert_passes() 120 | def test(self): 121 | import enum 122 | 123 | from pycroscope.extensions import EnumName 124 | 125 | class Rodent(enum.IntEnum): 126 | capybara = 1 127 | agouti = 2 128 | 129 | def capybara(x: EnumName[Rodent]): 130 | pass 131 | 132 | def needs_str(s: str): 133 | pass 134 | 135 | def caller(r: Rodent, s: str): 136 | capybara(s) # E: incompatible_argument 137 | capybara(r) # E: incompatible_argument 138 | needs_str(r.name) # OK 139 | capybara(r.name) 140 | -------------------------------------------------------------------------------- /pycroscope/test_error_code.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes 4 | 5 | 6 | class TestRegisterErrorCode(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test(self) -> None: 9 | from pycroscope.tests import custom_code 10 | 11 | custom_code() # E: internal_test 12 | -------------------------------------------------------------------------------- /pycroscope/test_extensions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from types import FunctionType 3 | from typing import List, Optional, TypeVar, Union 4 | 5 | import pytest 6 | from typing_extensions import get_args 7 | 8 | from .extensions import AsynqCallable, get_overloads, overload 9 | from .safe import all_of_type 10 | 11 | T = TypeVar("T") 12 | U = TypeVar("U") 13 | 14 | 15 | def test_asynq_callable() -> None: 16 | AC = AsynqCallable[[int], str] 17 | assert (AC, type(None)) == get_args(Optional[AC]) 18 | assert (int, AC) == get_args(Union[int, AC]) 19 | 20 | GAC = AsynqCallable[[T], str] 21 | assert AC == GAC[int] 22 | 23 | assert ( 24 | AsynqCallable[[List[int]], List[str]] 25 | == AsynqCallable[[List[T]], List[U]][int, str] 26 | ) 27 | 28 | if sys.version_info >= (3, 11): 29 | assert List[AsynqCallable[[str], int]] == List[AsynqCallable[[T], int]][str] 30 | else: 31 | with pytest.raises(TypeError): 32 | # Unfortunately this doesn't work because typing doesn't know how to 33 | # get TypeVars out of an AsynqCallable instances. Solving this is hard 34 | # because Callable is special-cased at various places in typing.py. 35 | # Somehow it works in 3.11 though. 36 | assert List[AsynqCallable[[str], int]] == List[AsynqCallable[[T], int]][str] 37 | 38 | 39 | @overload 40 | def f() -> int: 41 | raise NotImplementedError 42 | 43 | 44 | @overload 45 | def f(a: int) -> str: 46 | raise NotImplementedError 47 | 48 | 49 | def f(*args: object) -> object: 50 | raise NotImplementedError 51 | 52 | 53 | class WithOverloadedMethods: 54 | @overload 55 | def f(self) -> int: 56 | raise NotImplementedError 57 | 58 | @overload 59 | def f(self, a: int) -> str: 60 | raise NotImplementedError 61 | 62 | def f(self, *args: object) -> object: 63 | raise NotImplementedError 64 | 65 | 66 | def test_overload() -> None: 67 | overloads = get_overloads("pycroscope.test_extensions.f") 68 | assert len(overloads) == 2 69 | assert all_of_type(overloads, FunctionType) 70 | assert f not in overloads 71 | assert overloads[0].__code__.co_argcount == 0 72 | assert overloads[1].__code__.co_argcount == 1 73 | 74 | method_overloads = get_overloads( 75 | "pycroscope.test_extensions.WithOverloadedMethods.f" 76 | ) 77 | assert len(method_overloads) == 2 78 | assert all_of_type(method_overloads, FunctionType) 79 | assert WithOverloadedMethods.f not in overloads 80 | assert method_overloads[0].__code__.co_argcount == 1 81 | assert method_overloads[1].__code__.co_argcount == 2 82 | -------------------------------------------------------------------------------- /pycroscope/test_functions.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .error_code import ErrorCode 3 | from .implementation import assert_is_value 4 | from .test_name_check_visitor import TestNameCheckVisitorBase 5 | from .test_node_visitor import assert_passes, skip_before, skip_if_not_installed 6 | from .value import AnySource, AnyValue, GenericValue, KnownValue, TypedValue 7 | 8 | 9 | class TestNestedFunction(TestNameCheckVisitorBase): 10 | @assert_passes() 11 | def test_inference(self): 12 | def capybara(): 13 | def nested(): 14 | pass 15 | 16 | class NestedClass(object): 17 | pass 18 | 19 | assert_is_value(nested(), KnownValue(None)) 20 | nested(1) # E: incompatible_call 21 | # Should ideally be something more specific 22 | assert_is_value(NestedClass, AnyValue(AnySource.inference)) 23 | 24 | @assert_passes() 25 | def test_usage_in_nested_scope(): 26 | def capybara(cond, x): 27 | if cond: 28 | 29 | def nested(y): 30 | pass 31 | 32 | ys = [nested(y) for y in x] 33 | 34 | class Nested(object): 35 | xs = ys 36 | 37 | @skip_if_not_installed("asynq") 38 | @assert_passes() 39 | def test_asynq(self): 40 | from asynq import asynq 41 | from typing_extensions import Literal 42 | 43 | @asynq() 44 | def capybara(): 45 | @asynq() 46 | def nested() -> Literal[3]: 47 | return 3 48 | 49 | assert_is_value(nested(), KnownValue(3)) 50 | val = yield nested.asynq() 51 | assert_is_value(val, KnownValue(3)) 52 | 53 | @assert_passes() 54 | def test_async_def(self): 55 | from pycroscope.value import make_coro_type 56 | 57 | def capybara(): 58 | async def nested() -> int: 59 | return 1 60 | 61 | assert_is_value(nested(), make_coro_type(TypedValue(int))) 62 | 63 | @assert_passes() 64 | def test_bad_decorator(self): 65 | def decorator(fn): 66 | return fn 67 | 68 | def capybara(): 69 | @decorator 70 | def nested(): 71 | pass 72 | 73 | assert_is_value(nested, AnyValue(AnySource.unannotated)) 74 | 75 | @assert_passes() 76 | def test_attribute_set(self): 77 | def capybara(): 78 | def inner(): 79 | pass 80 | 81 | inner.punare = 3 82 | assert_is_value(inner.punare, KnownValue(3)) 83 | 84 | @assert_passes() 85 | def test_nested_in_method(self): 86 | class Capybara: 87 | def method(self): 88 | def nested(arg) -> int: 89 | assert_is_value(arg, AnyValue(AnySource.unannotated)) 90 | # Make sure we don't think this is an access to Capybara.numerator 91 | print(arg.numerator) 92 | return 1 93 | 94 | assert_is_value(nested(1), TypedValue(int)) 95 | 96 | 97 | class TestFunctionDefinitions(TestNameCheckVisitorBase): 98 | @assert_passes() 99 | def test_keyword_only(self): 100 | def capybara(a, *, b, c=3): 101 | assert_is_value(a, AnyValue(AnySource.unannotated)) 102 | assert_is_value(b, AnyValue(AnySource.unannotated)) 103 | assert_is_value(c, AnyValue(AnySource.unannotated) | KnownValue(3)) 104 | capybara(1, b=2) 105 | 106 | fn = lambda a, *, b: None 107 | fn(a, b=b) 108 | 109 | def failing_capybara(a, *, b): 110 | capybara(1, 2) # E: incompatible_call 111 | 112 | @assert_passes(settings={ErrorCode.missing_parameter_annotation: True}) 113 | def test_pos_only(self): 114 | from typing import Optional 115 | 116 | def f(a: int, /) -> None: 117 | assert_is_value(a, TypedValue(int)) 118 | 119 | def g(a: Optional[str] = None, /, b: int = 1) -> None: 120 | assert_is_value(a, TypedValue(str) | KnownValue(None)) 121 | assert_is_value(b, TypedValue(int)) 122 | 123 | def h(a, b: int = 1, /, c: int = 2) -> None: # E: missing_parameter_annotation 124 | assert_is_value(a, AnyValue(AnySource.unannotated)) 125 | assert_is_value(b, TypedValue(int)) 126 | 127 | def capybara() -> None: 128 | f(1) 129 | f("x") # E: incompatible_argument 130 | f(a=1) # E: incompatible_call 131 | g(a=1) # E: incompatible_call 132 | g(b=1) 133 | g(None, b=1) 134 | h(1, 1, c=2) 135 | h(1) 136 | h(1, b=1) # E: incompatible_call 137 | 138 | @assert_passes() 139 | def test_lambda(self): 140 | from typing import Callable 141 | 142 | def capybara(): 143 | fun = lambda: 1 144 | x: Callable[[], int] = fun 145 | y: Callable[[], str] = fun # E: incompatible_assignment 146 | fun(1) # E: incompatible_call 147 | assert_is_value(fun(), KnownValue(1)) 148 | 149 | fun2 = lambda a: a 150 | fun2() # E: incompatible_call 151 | assert_is_value(fun2(1), KnownValue(1)) 152 | 153 | fun3 = lambda c=3: c 154 | assert_is_value( 155 | fun3(), KnownValue(3) | AnyValue(AnySource.generic_argument) 156 | ) 157 | assert_is_value(fun3(2), KnownValue(2) | KnownValue(3)) 158 | 159 | fun4 = lambda a, b, c: a if c else b 160 | assert_is_value(fun4(1, 2, 3), KnownValue(1) | KnownValue(2)) 161 | 162 | 163 | class TestDecorators(TestNameCheckVisitorBase): 164 | @assert_passes() 165 | def test_applied(self) -> None: 166 | def bad_deco(x: int) -> str: 167 | return "x" 168 | 169 | @bad_deco # E: incompatible_argument 170 | def capybara(): 171 | pass 172 | 173 | @assert_passes() 174 | def test_asynccontextmanager(self): 175 | from contextlib import asynccontextmanager 176 | from typing import AsyncIterator 177 | 178 | @asynccontextmanager 179 | async def make_cm() -> AsyncIterator[None]: 180 | yield 181 | 182 | 183 | class TestAsyncGenerator(TestNameCheckVisitorBase): 184 | @assert_passes() 185 | def test_not_a_generator(self): 186 | from pycroscope.value import make_coro_type 187 | 188 | async def capybara() -> None: 189 | async def agen(): 190 | yield 1 191 | 192 | def gen(): 193 | yield 2 194 | 195 | print(agen, gen, lambda: (yield 3)) 196 | 197 | def caller() -> None: 198 | x = capybara() 199 | assert_is_value(x, make_coro_type(KnownValue(None))) 200 | 201 | @assert_passes() 202 | def test_is_a_generator(self): 203 | import collections.abc 204 | from typing import AsyncIterator 205 | 206 | async def capybara() -> AsyncIterator[int]: 207 | yield 1 208 | 209 | def caller() -> None: 210 | x = capybara() 211 | assert_is_value( 212 | x, GenericValue(collections.abc.AsyncIterator, [TypedValue(int)]) 213 | ) 214 | 215 | 216 | class TestGenericFunctions(TestNameCheckVisitorBase): 217 | @skip_before((3, 12)) 218 | def test_generic(self): 219 | self.assert_passes( 220 | """ 221 | from typing_extensions import assert_type 222 | 223 | def func[T](x: T) -> T: 224 | return x 225 | 226 | def capybara(i: int): 227 | assert_type(func(i), int) 228 | """ 229 | ) 230 | 231 | @skip_before((3, 12)) 232 | def test_generic_with_bound(self): 233 | self.assert_passes( 234 | """ 235 | from typing_extensions import assert_type 236 | 237 | def func[T: int](x: T) -> T: 238 | return x 239 | 240 | def capybara(i: int, s: str, b: bool): 241 | assert_type(func(i), int) 242 | assert_type(func(b), bool) 243 | func(s) # E: incompatible_argument 244 | """ 245 | ) 246 | -------------------------------------------------------------------------------- /pycroscope/test_generators.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes, skip_if_not_installed 5 | from .value import AnySource, AnyValue, KnownValue, TypedValue 6 | 7 | 8 | class TestGenerator(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_generator_return(self): 11 | from typing import Generator 12 | 13 | def gen(cond) -> Generator[int, str, float]: 14 | x = yield 1 15 | assert_is_value(x, TypedValue(str)) 16 | yield "x" # E: incompatible_yield 17 | if cond: 18 | return 3.0 19 | else: 20 | return "hello" # E: incompatible_return_value 21 | 22 | def capybara() -> Generator[int, int, int]: 23 | x = yield from gen(True) # E: incompatible_yield 24 | assert_is_value(x, TypedValue(float)) 25 | 26 | return 3 27 | 28 | @assert_passes() 29 | def test_iterable_return(self): 30 | from typing import Iterable 31 | 32 | def gen(cond) -> Iterable[int]: 33 | x = yield 1 34 | assert_is_value(x, KnownValue(None)) 35 | 36 | yield "x" # E: incompatible_yield 37 | 38 | if cond: 39 | return 40 | else: 41 | return 3 # E: incompatible_return_value 42 | 43 | def caller() -> Iterable[int]: 44 | x = yield from gen(True) 45 | assert_is_value(x, AnyValue(AnySource.generic_argument)) 46 | 47 | 48 | class TestGeneratorReturn(TestNameCheckVisitorBase): 49 | @assert_passes() 50 | def test_sync(self): 51 | from typing import Generator, Iterable 52 | 53 | def gen() -> int: # E: generator_return 54 | yield 1 55 | 56 | def caller() -> int: # E: generator_return 57 | x = yield from [1, 2] 58 | print(x) 59 | 60 | def gen2() -> Iterable[int]: 61 | yield 1 62 | 63 | def caller2() -> Generator[int, None, None]: 64 | x = yield from [1, 2] 65 | print(x) 66 | 67 | @skip_if_not_installed("asynq") 68 | @assert_passes() 69 | def test_asynq(self): 70 | from asynq import ConstFuture, asynq 71 | 72 | @asynq() 73 | def asynq_gen() -> int: 74 | x = yield ConstFuture(1) 75 | return x 76 | 77 | @assert_passes() 78 | def test_async(self): 79 | from typing import AsyncGenerator, AsyncIterable 80 | 81 | async def gen() -> int: # E: generator_return 82 | yield 1 83 | 84 | async def gen2() -> AsyncIterable[int]: 85 | yield 1 86 | 87 | async def gen3() -> AsyncGenerator[int, None]: 88 | yield 1 89 | -------------------------------------------------------------------------------- /pycroscope/test_import.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes 5 | from .value import KnownValue 6 | 7 | 8 | class TestImport(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_import(self): 11 | import pycroscope as P 12 | 13 | def capybara() -> None: 14 | import pycroscope 15 | import pycroscope as py 16 | import pycroscope.extensions as E 17 | 18 | assert_is_value(pycroscope, KnownValue(P)) 19 | assert_is_value(py, KnownValue(P)) 20 | assert_is_value(E, KnownValue(P.extensions)) 21 | 22 | @assert_passes() 23 | def test_import_from(self): 24 | def capybara(): 25 | import pycroscope as P 26 | 27 | def capybara(): 28 | from pycroscope import extensions 29 | from pycroscope.extensions import assert_error 30 | 31 | assert_is_value(extensions, KnownValue(P.extensions)) 32 | assert_is_value(assert_error, KnownValue(P.extensions.assert_error)) 33 | 34 | def test_import_star(self): 35 | self.assert_passes( 36 | """ 37 | import pycroscope as P 38 | 39 | if False: 40 | from pycroscope import * 41 | 42 | assert_is_value(extensions, KnownValue(P.extensions)) 43 | not_a_name # E: undefined_name 44 | """ 45 | ) 46 | 47 | 48 | class TestDisallowedImport(TestNameCheckVisitorBase): 49 | @assert_passes() 50 | def test_top_level(self): 51 | import getopt # E: disallowed_import 52 | import xml.etree.ElementTree # E: disallowed_import 53 | from getopt import GetoptError # E: disallowed_import 54 | 55 | print(getopt, GetoptError, xml) # shut up flake8 56 | 57 | def capybara(): 58 | import getopt # E: disallowed_import 59 | import xml.etree.ElementTree # E: disallowed_import 60 | from getopt import GetoptError # E: disallowed_import 61 | 62 | print(getopt, GetoptError, xml) 63 | 64 | @assert_passes() 65 | def test_nested(self): 66 | import email.base64mime # ok 67 | import email.quoprimime # E: disallowed_import 68 | from email.quoprimime import unquote # E: disallowed_import 69 | from xml.etree import ElementTree # E: disallowed_import 70 | 71 | print(email, unquote, ElementTree) 72 | 73 | def capybara(): 74 | import email.base64mime # ok 75 | import email.quoprimime # E: disallowed_import 76 | from email.quoprimime import unquote # E: disallowed_import 77 | from xml.etree import ElementTree # E: disallowed_import 78 | 79 | print(email, unquote, ElementTree) 80 | 81 | @assert_passes() 82 | def test_import_from(self): 83 | from email import base64mime, quoprimime # ok # E: disallowed_import 84 | 85 | print(quoprimime, base64mime) 86 | 87 | def capybara(): 88 | from email import base64mime, quoprimime # ok # E: disallowed_import 89 | 90 | print(quoprimime, base64mime) 91 | -------------------------------------------------------------------------------- /pycroscope/test_inference_helpers.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes 4 | from .value import KnownValue 5 | 6 | 7 | class TestInferenceHelpers(TestNameCheckVisitorBase): 8 | @assert_passes() 9 | def test(self) -> None: 10 | from pycroscope import assert_is_value, dump_value 11 | from pycroscope.value import Value 12 | 13 | def capybara(val: Value) -> None: 14 | reveal_type(dump_value) # E: reveal_type 15 | dump_value(reveal_type) # E: reveal_type 16 | assert_is_value(1, KnownValue(1)) 17 | assert_is_value(1, KnownValue(2)) # E: inference_failure 18 | assert_is_value(1, val) # E: inference_failure 19 | 20 | @assert_passes() 21 | def test_return_value(self) -> None: 22 | from pycroscope import assert_is_value, dump_value 23 | 24 | def capybara(): 25 | x = dump_value(1) # E: reveal_type 26 | y = reveal_type(1) # E: reveal_type 27 | assert_is_value(x, KnownValue(1)) 28 | assert_is_value(y, KnownValue(1)) 29 | 30 | @assert_passes() 31 | def test_assert_type(self) -> None: 32 | from typing import Any 33 | 34 | from pycroscope.extensions import assert_type 35 | 36 | def capybara(x: int) -> None: 37 | assert_type(x, int) 38 | assert_type(x, "int") 39 | assert_type(x, Any) # E: inference_failure 40 | assert_type(x, str) # E: inference_failure 41 | 42 | 43 | class TestAssertError(TestNameCheckVisitorBase): 44 | @assert_passes() 45 | def test(self) -> None: 46 | from pycroscope.extensions import assert_error 47 | 48 | def f(x: int) -> None: 49 | pass 50 | 51 | def capybara() -> None: 52 | with assert_error(): 53 | f("x") 54 | 55 | with assert_error(): # E: inference_failure 56 | f(1) 57 | 58 | 59 | class TestRevealLocals(TestNameCheckVisitorBase): 60 | @assert_passes() 61 | def test(self) -> None: 62 | from pycroscope.extensions import reveal_locals 63 | 64 | def capybara(a: object, b: str) -> None: 65 | c = 3 66 | if b == "x": 67 | reveal_locals() # E: reveal_type 68 | print(a, b, c) 69 | -------------------------------------------------------------------------------- /pycroscope/test_literal_string.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes 5 | from .value import TypedValue 6 | 7 | 8 | class TestLiteralString(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test(self): 11 | from typing_extensions import LiteralString 12 | 13 | def f(x: LiteralString) -> LiteralString: 14 | return "x" 15 | 16 | def capybara(x: str, y: LiteralString): 17 | f(x) # E: incompatible_argument 18 | f(y) 19 | f("x") 20 | assert_is_value(f("x"), TypedValue(str, literal_only=True)) 21 | -------------------------------------------------------------------------------- /pycroscope/test_never.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes, skip_before 5 | from .value import NO_RETURN_VALUE, TypedValue 6 | 7 | 8 | class TestAnnotations(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_te_never(self): 11 | from typing_extensions import Never 12 | 13 | def capybara(n: Never, o: "Never"): 14 | assert_is_value(n, NO_RETURN_VALUE) 15 | assert_is_value(o, NO_RETURN_VALUE) 16 | 17 | @skip_before((3, 11)) 18 | @assert_passes() 19 | def test_never(self): 20 | from typing import Never 21 | 22 | def capybara(n: Never, o: "Never"): 23 | assert_is_value(n, NO_RETURN_VALUE) 24 | assert_is_value(o, NO_RETURN_VALUE) 25 | 26 | @assert_passes() 27 | def test_typing_noreturn(self): 28 | from typing import NoReturn 29 | 30 | def capybara(n: NoReturn, o: "NoReturn"): 31 | assert_is_value(n, NO_RETURN_VALUE) 32 | assert_is_value(o, NO_RETURN_VALUE) 33 | 34 | 35 | class TestNoReturn(TestNameCheckVisitorBase): 36 | @assert_passes() 37 | def test_no_return(self): 38 | from typing import Optional 39 | 40 | from typing_extensions import NoReturn 41 | 42 | def f() -> NoReturn: 43 | raise Exception 44 | 45 | def capybara(x: Optional[int]) -> None: 46 | if x is None: 47 | f() 48 | assert_is_value(x, TypedValue(int)) 49 | 50 | @assert_passes() 51 | def test_no_return_parameter(self): 52 | from typing_extensions import NoReturn 53 | 54 | def assert_unreachable(x: NoReturn) -> None: 55 | pass 56 | 57 | def capybara(): 58 | assert_unreachable(1) # E: incompatible_argument 59 | 60 | @assert_passes() 61 | def test_assignability(self): 62 | from typing_extensions import NoReturn 63 | 64 | def takes_never(x: NoReturn): 65 | print(x) 66 | 67 | 68 | class TestAssertNever(TestNameCheckVisitorBase): 69 | @assert_passes() 70 | def test_if(self): 71 | from typing import Union 72 | 73 | from pycroscope.tests import assert_never 74 | 75 | def capybara(x: Union[int, str]) -> None: 76 | if isinstance(x, int): 77 | print("int") 78 | elif isinstance(x, str): 79 | print("str") 80 | else: 81 | assert_never(x) 82 | 83 | @assert_passes() 84 | def test_enum_in(self): 85 | import enum 86 | 87 | from typing_extensions import assert_never 88 | 89 | class Capybara(enum.Enum): 90 | hydrochaeris = 1 91 | isthmius = 2 92 | 93 | def capybara(x: Capybara) -> None: 94 | if x in (Capybara.hydrochaeris, Capybara.isthmius): 95 | pass 96 | else: 97 | assert_never(x) 98 | 99 | @assert_passes() 100 | def test_enum_is_or(self): 101 | import enum 102 | 103 | from typing_extensions import Literal, assert_never, assert_type 104 | 105 | class Capybara(enum.Enum): 106 | hydrochaeris = 1 107 | isthmius = 2 108 | hesperotiganites = 3 109 | 110 | def neochoerus(x: Capybara) -> None: 111 | if x is Capybara.hydrochaeris or x is Capybara.isthmius: 112 | assert_type(x, Literal[Capybara.hydrochaeris, Capybara.isthmius]) 113 | else: 114 | assert_type(x, Literal[Capybara.hesperotiganites]) 115 | 116 | def capybara(x: Capybara) -> None: 117 | if x is Capybara.hydrochaeris or x is Capybara.isthmius: 118 | assert_type(x, Literal[Capybara.hydrochaeris, Capybara.isthmius]) 119 | elif x is Capybara.hesperotiganites: 120 | assert_type(x, Literal[Capybara.hesperotiganites]) 121 | else: 122 | assert_never(x) 123 | 124 | @assert_passes() 125 | def test_literal(self): 126 | from typing_extensions import Literal, assert_never, assert_type 127 | 128 | def capybara(x: Literal["a", "b", "c"]) -> None: 129 | if x in ("a", "b"): 130 | assert_type(x, Literal["a", "b"]) 131 | elif x == "c": 132 | assert_type(x, Literal["c"]) 133 | else: 134 | assert_never(x) 135 | 136 | 137 | class TestNeverCall(TestNameCheckVisitorBase): 138 | @assert_passes() 139 | def test_empty_list(self): 140 | def callee(a: int): 141 | pass 142 | 143 | def capybara(): 144 | for args in []: 145 | callee(*args) 146 | 147 | for kwargs in []: 148 | callee(**kwargs) 149 | -------------------------------------------------------------------------------- /pycroscope/test_override.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .error_code import ErrorCode 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_fails, assert_passes 5 | 6 | 7 | class TestOverride(TestNameCheckVisitorBase): 8 | @assert_fails(ErrorCode.invalid_override_decorator) 9 | def test_invalid_usage(self): 10 | from typing_extensions import override 11 | 12 | @override 13 | def not_a_method(): 14 | pass 15 | 16 | @assert_passes() 17 | def test_valid_method(self): 18 | from typing_extensions import override 19 | 20 | class Base: 21 | def method(self): 22 | pass 23 | 24 | class Capybara(Base): 25 | @override 26 | def method(self): 27 | pass 28 | 29 | @assert_fails(ErrorCode.override_does_not_override) 30 | def test_invalid_method(self): 31 | from typing_extensions import override 32 | 33 | class Base: 34 | def method(self): 35 | pass 36 | 37 | class Capybara(Base): 38 | @override 39 | def no_base_method(self): # E: override_does_not_override 40 | pass 41 | -------------------------------------------------------------------------------- /pycroscope/test_pep673.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .implementation import assert_is_value 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes 5 | from .value import GenericValue, KnownValue, TypedValue 6 | 7 | 8 | class TestPEP673(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_instance_attribute(self): 11 | from typing_extensions import Self 12 | 13 | class X: 14 | parent: Self 15 | 16 | @property 17 | def prop(self) -> Self: 18 | raise NotImplementedError 19 | 20 | class Y(X): 21 | pass 22 | 23 | def capybara(x: X, y: Y): 24 | assert_is_value(x.parent, TypedValue(X)) 25 | assert_is_value(y.parent, TypedValue(Y)) 26 | 27 | assert_is_value(x.prop, TypedValue(X)) 28 | assert_is_value(y.prop, TypedValue(Y)) 29 | 30 | @assert_passes() 31 | def test_method(self): 32 | from typing_extensions import Self 33 | 34 | class X: 35 | def ret(self) -> Self: 36 | return self 37 | 38 | @classmethod 39 | def from_config(cls) -> Self: 40 | return cls() 41 | 42 | class Y(X): 43 | pass 44 | 45 | def capybara(x: X, y: Y): 46 | assert_is_value(x.ret(), TypedValue(X)) 47 | assert_is_value(y.ret(), TypedValue(Y)) 48 | 49 | assert_is_value(X.from_config(), TypedValue(X)) 50 | assert_is_value(Y.from_config(), TypedValue(Y)) 51 | 52 | @assert_passes() 53 | def test_parameter_type(self): 54 | from typing import Callable 55 | 56 | from typing_extensions import Self 57 | 58 | class Shape: 59 | def difference(self, other: Self) -> float: 60 | raise NotImplementedError 61 | 62 | def apply(self, f: Callable[[Self], None]) -> None: 63 | raise NotImplementedError 64 | 65 | class Circle(Shape): 66 | pass 67 | 68 | def difference(): 69 | s = Shape() 70 | s.difference(s) 71 | s.difference(1.0) # E: incompatible_argument 72 | s.difference(Circle()) 73 | 74 | c = Circle() 75 | c.difference(c) 76 | c.difference(s) # E: incompatible_argument 77 | c.difference("x") # E: incompatible_argument 78 | 79 | def takes_shape(s: Shape) -> None: 80 | pass 81 | 82 | def takes_circle(c: Circle) -> None: 83 | pass 84 | 85 | def takes_int(i: int) -> None: 86 | pass 87 | 88 | def apply(): 89 | s = Shape() 90 | c = Circle() 91 | s.apply(takes_shape) 92 | s.apply(takes_circle) # E: incompatible_argument 93 | s.apply(takes_int) # E: incompatible_argument 94 | c.apply(takes_shape) 95 | c.apply(takes_circle) 96 | c.apply(takes_int) # E: incompatible_argument 97 | 98 | @assert_passes() 99 | def test_linked_list(self): 100 | from dataclasses import dataclass 101 | from typing import Generic, Optional, TypeVar 102 | 103 | from typing_extensions import Self 104 | 105 | T = TypeVar("T") 106 | 107 | @dataclass 108 | class LinkedList(Generic[T]): 109 | value: T 110 | next: Optional[Self] = None 111 | 112 | @dataclass 113 | class OrdinalLinkedList(LinkedList[int]): 114 | pass 115 | 116 | def capybara(o: OrdinalLinkedList): 117 | # Unfortunately we don't fully support the example in 118 | assert_is_value(o.next, KnownValue(None) | TypedValue(OrdinalLinkedList)) 119 | 120 | @assert_passes() 121 | def test_generic(self): 122 | from typing import Generic, TypeVar 123 | 124 | from typing_extensions import Self 125 | 126 | T = TypeVar("T") 127 | 128 | class Container(Generic[T]): 129 | value: T 130 | 131 | def set_value(self, value: T) -> Self: 132 | return self 133 | 134 | def capybara(c: Container[int]): 135 | assert_is_value(c.value, TypedValue(int)) 136 | assert_is_value(c.set_value(3), GenericValue(Container, [TypedValue(int)])) 137 | 138 | @assert_passes() 139 | def test_classvar(self): 140 | from typing import ClassVar, List 141 | 142 | from typing_extensions import Self 143 | 144 | class Registry: 145 | children: ClassVar[List[Self]] 146 | 147 | def capybara(): 148 | assert_is_value( 149 | Registry.children, GenericValue(list, [TypedValue(Registry)]) 150 | ) 151 | 152 | @assert_passes() 153 | def test_stub(self): 154 | def capybara(): 155 | from _pycroscope_tests.self import X, Y 156 | from typing_extensions import assert_type 157 | 158 | x = X() 159 | y = Y() 160 | assert_type(x, X) 161 | assert_type(y, Y) 162 | 163 | def want_x(x: X): 164 | pass 165 | 166 | def want_y(y: Y): 167 | pass 168 | 169 | want_x(x.ret()) 170 | want_y(y.ret()) 171 | 172 | want_x(X.from_config()) 173 | want_y(Y.from_config()) 174 | 175 | @assert_passes() 176 | def test_typeshed_self(self): 177 | def capybara(): 178 | from _pycroscope_tests.tsself import X 179 | from typing_extensions import assert_type 180 | 181 | x = X() 182 | assert_type(x, X) 183 | -------------------------------------------------------------------------------- /pycroscope/test_recursion.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes 4 | 5 | 6 | class TestRecursion(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test_runtime(self): 9 | from typing import Dict, List, Union 10 | 11 | JSON = Union[Dict[str, "JSON"], List["JSON"], int, str, float, bool, None] 12 | 13 | def f(x: JSON): 14 | pass 15 | 16 | def capybara() -> None: 17 | f([]) 18 | f(b"x") # E: incompatible_argument 19 | 20 | @assert_passes() 21 | def test_stub(self): 22 | def capybara(): 23 | from _pycroscope_tests.recursion import StrJson 24 | 25 | def want_str(cm: StrJson) -> None: 26 | pass 27 | 28 | def f(x: str): 29 | want_str(x) 30 | want_str(3) # E: incompatible_argument 31 | -------------------------------------------------------------------------------- /pycroscope/test_relations.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | 3 | from pycroscope.test_name_check_visitor import TestNameCheckVisitorBase 4 | from pycroscope.test_node_visitor import assert_passes, skip_before 5 | 6 | 7 | class TestRelations(TestNameCheckVisitorBase): 8 | @skip_before((3, 12)) 9 | def test_unbounded_tuple_unions(self): 10 | self.assert_passes( 11 | """ 12 | from typing import assert_type 13 | 14 | type Eq0 = tuple[()] 15 | type Eq1 = tuple[int] 16 | Ge0 = tuple[int, ...] 17 | type Ge1 = tuple[int, *Ge0] 18 | 19 | def capybara(eq0: Eq0, eq1: Eq1, ge0: Ge0, ge1: Ge1) -> None: 20 | eq0_ge1__eq0: Eq0 | Ge1 = eq0 21 | eq0_ge1__eq1: Eq0 | Ge1 = eq1 22 | eq0_ge1__ge0: Eq0 | Ge1 = ge0 23 | eq0_ge1__ge1: Eq0 | Ge1 = ge1 24 | 25 | assert_type(ge0, Eq0 | Ge1) 26 | """ 27 | ) 28 | 29 | 30 | class TestIntersections(TestNameCheckVisitorBase): 31 | @assert_passes() 32 | def test_equivalence(self): 33 | from typing_extensions import Any, Literal, Never, assert_type 34 | 35 | from pycroscope.extensions import Intersection 36 | 37 | class A: 38 | x: Any 39 | 40 | class B: 41 | x: int 42 | 43 | def capybara( 44 | x: Intersection[Literal[1], Literal[2]], y: Intersection[A, B] 45 | ) -> None: 46 | assert_type(x, Never) 47 | 48 | assert_type(y, Intersection[A, B]) 49 | assert_type(y.x, Intersection[int, Any]) 50 | -------------------------------------------------------------------------------- /pycroscope/test_runtime.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from typing import List 3 | 4 | from .runtime import get_assignability_error, is_assignable 5 | from .test_name_check_visitor import TestNameCheckVisitorBase 6 | from .test_node_visitor import assert_passes, skip_if_not_installed 7 | 8 | 9 | def test_is_assignable() -> None: 10 | assert not is_assignable(42, List[int]) 11 | assert is_assignable([], List[int]) 12 | assert not is_assignable(["x"], List[int]) 13 | assert is_assignable([1, 2, 3], List[int]) 14 | 15 | 16 | def test_get_assignability_error() -> None: 17 | assert ( 18 | get_assignability_error(42, List[int]) == "Cannot assign Literal[42] to list\n" 19 | ) 20 | assert get_assignability_error([], List[int]) is None 21 | assert ( 22 | get_assignability_error(["x"], List[int]) 23 | == "In element 0\n Cannot assign Literal['x'] to int\n" 24 | ) 25 | assert get_assignability_error([1, 2, 3], List[int]) is None 26 | 27 | 28 | class TestRuntimeTypeGuard(TestNameCheckVisitorBase): 29 | @skip_if_not_installed("annotated_types") 30 | @assert_passes() 31 | def test_runtime(self): 32 | from annotated_types import Predicate 33 | from typing_extensions import Annotated 34 | 35 | from pycroscope.runtime import is_assignable 36 | 37 | IsLower = Annotated[str, Predicate(str.islower)] 38 | 39 | def want_lowercase(s: IsLower) -> None: 40 | assert s.islower() 41 | 42 | def capybara(s: str) -> None: 43 | want_lowercase(1) # E: incompatible_argument 44 | want_lowercase(s) # E: incompatible_argument 45 | if is_assignable(s, IsLower): 46 | want_lowercase(s) 47 | 48 | def asserting_capybara(s: str) -> None: 49 | assert is_assignable(s, IsLower) 50 | want_lowercase(s) 51 | -------------------------------------------------------------------------------- /pycroscope/test_self.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Runs pycroscope on itself. 4 | 5 | """ 6 | 7 | import pycroscope 8 | from pycroscope.test_node_visitor import skip_if_not_installed 9 | 10 | 11 | class PycroscopeVisitor(pycroscope.name_check_visitor.NameCheckVisitor): 12 | should_check_environ_for_files = False 13 | config_filename = "../pyproject.toml" 14 | 15 | 16 | @skip_if_not_installed("asynq") 17 | def test_all() -> None: 18 | PycroscopeVisitor.check_all_files() 19 | 20 | 21 | if __name__ == "__main__": 22 | PycroscopeVisitor.main() 23 | -------------------------------------------------------------------------------- /pycroscope/test_suggested_type.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .error_code import ErrorCode 3 | from .suggested_type import prepare_type 4 | from .test_name_check_visitor import TestNameCheckVisitorBase 5 | from .test_node_visitor import assert_passes 6 | from .value import KnownValue, SubclassValue, TypedValue 7 | 8 | 9 | class TestSuggestedType(TestNameCheckVisitorBase): 10 | @assert_passes(settings={ErrorCode.suggested_return_type: True}) 11 | def test_return(self): 12 | def capybara(): # E: suggested_return_type 13 | return 1 14 | 15 | def kerodon(cond): # E: suggested_return_type 16 | if cond: 17 | return 1 18 | else: 19 | return 2 20 | 21 | @assert_passes(settings={ErrorCode.suggested_parameter_type: True}) 22 | def test_parameter(self): 23 | def capybara(a): # E: suggested_parameter_type 24 | pass 25 | 26 | def annotated(b: int): 27 | pass 28 | 29 | class Mammalia: 30 | # should not suggest a type for this 31 | def method(self): 32 | pass 33 | 34 | def kerodon(unannotated): 35 | capybara(1) 36 | annotated(2) 37 | 38 | m = Mammalia() 39 | m.method() 40 | Mammalia.method(unannotated) 41 | 42 | 43 | class A: 44 | pass 45 | 46 | 47 | class B(A): 48 | pass 49 | 50 | 51 | class C(A): 52 | pass 53 | 54 | 55 | def test_prepare_type() -> None: 56 | assert prepare_type(KnownValue(int) | KnownValue(str)) == TypedValue(type) 57 | assert prepare_type(KnownValue(C) | KnownValue(B)) == SubclassValue(TypedValue(A)) 58 | assert prepare_type(KnownValue(int)) == SubclassValue(TypedValue(int)) 59 | 60 | assert prepare_type(SubclassValue(TypedValue(B)) | KnownValue(C)) == SubclassValue( 61 | TypedValue(A) 62 | ) 63 | assert prepare_type(SubclassValue(TypedValue(B)) | KnownValue(B)) == SubclassValue( 64 | TypedValue(B) 65 | ) 66 | assert prepare_type(KnownValue(None) | TypedValue(str)) == KnownValue( 67 | None 68 | ) | TypedValue(str) 69 | assert prepare_type(KnownValue(True) | KnownValue(False)) == TypedValue(bool) 70 | -------------------------------------------------------------------------------- /pycroscope/test_thrift_enum.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | 3 | from .test_name_check_visitor import TestNameCheckVisitorBase 4 | from .test_node_visitor import assert_passes 5 | from .value import KnownValue, TypedValue, assert_is_value 6 | 7 | 8 | class TestThriftEnum(TestNameCheckVisitorBase): 9 | @assert_passes() 10 | def test_basic(self): 11 | class ThriftEnum(object): 12 | X = 0 13 | Y = 1 14 | 15 | _VALUES_TO_NAMES = {0: "X", 1: "Y"} 16 | _NAMES_TO_VALUES = {"X": 0, "Y": 1} 17 | 18 | def want_enum(e: ThriftEnum): 19 | pass 20 | 21 | def want_int(i: int): 22 | pass 23 | 24 | def capybara(e: ThriftEnum): 25 | want_enum(e) 26 | want_enum(ThriftEnum.X) 27 | want_enum(ThriftEnum.Y) 28 | want_enum(0) 29 | want_enum(1) 30 | want_enum(42) # E: incompatible_argument 31 | want_enum(str(e)) # E: incompatible_argument 32 | want_int(e) 33 | want_int(e.X) 34 | 35 | @assert_passes() 36 | def test_typevar(self): 37 | from typing import TypeVar 38 | 39 | from typing_extensions import Annotated 40 | 41 | class ThriftEnum(object): 42 | X = 0 43 | Y = 1 44 | 45 | _VALUES_TO_NAMES = {0: "X", 1: "Y"} 46 | _NAMES_TO_VALUES = {"X": 0, "Y": 1} 47 | 48 | TET = TypeVar("TET", bound=ThriftEnum) 49 | 50 | def want_enum(te: ThriftEnum) -> None: 51 | pass 52 | 53 | def get_it(te: TET) -> TET: 54 | want_enum(te) 55 | return te 56 | 57 | def get_it_annotated(te: Annotated[TET, 3]) -> TET: 58 | want_enum(te) 59 | return te 60 | 61 | def capybara(e: ThriftEnum): 62 | assert_is_value(get_it(e), TypedValue(ThriftEnum)) 63 | assert_is_value(get_it(ThriftEnum.X), KnownValue(ThriftEnum.X)) 64 | 65 | assert_is_value(get_it_annotated(e), TypedValue(ThriftEnum)) 66 | assert_is_value(get_it_annotated(ThriftEnum.X), KnownValue(ThriftEnum.X)) 67 | 68 | @assert_passes() 69 | def test_int_protocol(self): 70 | from typing_extensions import Protocol 71 | 72 | class SupportsIndex(Protocol): 73 | def __index__(self) -> int: 74 | raise NotImplementedError 75 | 76 | class ThriftEnum(object): 77 | X = 0 78 | Y = 1 79 | 80 | _VALUES_TO_NAMES = {0: "X", 1: "Y"} 81 | _NAMES_TO_VALUES = {"X": 0, "Y": 1} 82 | 83 | def want_si(si: SupportsIndex): 84 | pass 85 | 86 | def capybara(te: ThriftEnum): 87 | want_si(te) 88 | -------------------------------------------------------------------------------- /pycroscope/test_try.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes, skip_before 4 | 5 | 6 | class TestExoticTry(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test_except_everything(self): 9 | from typing import Tuple, Type, Union 10 | 11 | from typing_extensions import Literal, assert_type 12 | 13 | def capybara( 14 | typ: Literal[TypeError, ValueError], 15 | typ2: Union[Tuple[Literal[RuntimeError], ...], Literal[KeyError]], 16 | typ3: Union[Type[RuntimeError], Type[KeyError]], 17 | typ4: Union[Tuple[Type[RuntimeError], ...], Type[KeyError]], 18 | cond: bool, 19 | ): 20 | try: 21 | pass 22 | except typ as e1: 23 | assert_type(e1, Union[TypeError, ValueError]) 24 | except typ2 as e2: 25 | assert_type(e2, Union[RuntimeError, KeyError]) 26 | except typ3 as e3: 27 | assert_type(e3, Union[RuntimeError, KeyError]) 28 | except typ4 as e4: 29 | assert_type(e4, Union[RuntimeError, KeyError]) 30 | except FileNotFoundError if cond else FileExistsError as e5: 31 | assert_type(e5, Union[FileNotFoundError, FileExistsError]) 32 | except (KeyError, (ValueError, (TypeError, RuntimeError))) as e6: 33 | assert_type(e6, Union[KeyError, ValueError, TypeError, RuntimeError]) 34 | except GeneratorExit as e7: 35 | assert_type(e7, GeneratorExit) 36 | 37 | 38 | class TestTryStar(TestNameCheckVisitorBase): 39 | @skip_before((3, 11)) 40 | def test_eg_types(self): 41 | self.assert_passes( 42 | """ 43 | from typing import assert_type 44 | 45 | def capybara(): 46 | try: 47 | pass 48 | except* ValueError as eg: 49 | assert_type(eg, ExceptionGroup[ValueError]) 50 | except* KeyboardInterrupt as eg: 51 | assert_type(eg, BaseExceptionGroup[KeyboardInterrupt]) 52 | except* (OSError, (RuntimeError, KeyError)) as eg: 53 | assert_type(eg, ExceptionGroup[OSError | RuntimeError | KeyError]) 54 | except *ExceptionGroup as eg: # E: bad_except_handler 55 | pass 56 | except *int as eg: # E: bad_except_handler 57 | pass 58 | """ 59 | ) 60 | 61 | @skip_before((3, 11)) 62 | def test_variable_scope(self): 63 | self.assert_passes( 64 | """ 65 | from typing import assert_type, Literal 66 | 67 | def capybara(): 68 | x = 0 69 | try: 70 | x = 1 71 | assert_type(x, Literal[1]) 72 | except* ValueError as eg: 73 | assert_type(x, Literal[0, 1]) 74 | x = 2 75 | except* TypeError as eg: 76 | assert_type(x, Literal[0, 1, 2]) 77 | x = 3 78 | assert_type(x, Literal[1, 2, 3]) 79 | """ 80 | ) 81 | 82 | @skip_before((3, 11)) 83 | def test_try_else(self): 84 | self.assert_passes( 85 | """ 86 | from typing import assert_type, Literal 87 | 88 | def capybara(): 89 | x = 0 90 | try: 91 | x = 1 92 | assert_type(x, Literal[1]) 93 | except* ValueError as eg: 94 | assert_type(x, Literal[0, 1]) 95 | x = 2 96 | except* TypeError as eg: 97 | assert_type(x, Literal[0, 1, 2]) 98 | x = 3 99 | else: 100 | assert_type(x, Literal[1]) 101 | x = 4 102 | assert_type(x, Literal[2, 3, 4]) 103 | """ 104 | ) 105 | 106 | @skip_before((3, 11)) 107 | def test_try_finally(self): 108 | self.assert_passes( 109 | """ 110 | from typing import assert_type, Literal 111 | 112 | def capybara(): 113 | x = 0 114 | try: 115 | x = 1 116 | assert_type(x, Literal[1]) 117 | except* ValueError as eg: 118 | assert_type(x, Literal[0, 1]) 119 | x = 2 120 | except* TypeError as eg: 121 | assert_type(x, Literal[0, 1, 2]) 122 | x = 3 123 | finally: 124 | assert_type(x, Literal[0, 1, 2, 3]) 125 | x = 4 126 | assert_type(x, Literal[4]) 127 | """ 128 | ) 129 | -------------------------------------------------------------------------------- /pycroscope/test_type_aliases.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes, skip_before 4 | 5 | 6 | class TestRecursion(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test(self): 9 | from typing import Dict, List, Union 10 | 11 | JSON = Union[Dict[str, "JSON"], List["JSON"], int, str, float, bool, None] 12 | 13 | def f(x: JSON): 14 | pass 15 | 16 | def capybara(): 17 | f([]) 18 | f([1, 2, 3]) 19 | f([[{1}]]) # E: incompatible_argument 20 | 21 | @assert_passes() 22 | def test_simple(self): 23 | from typing import Union 24 | 25 | Alias = Union[list["Alias"], int] 26 | 27 | x: Alias = 1 28 | 29 | def f(y: Alias): 30 | pass 31 | 32 | def capybara(): 33 | f(x) 34 | f([x]) 35 | f([1, 2, 3]) 36 | f([[{1}]]) # E: incompatible_argument 37 | 38 | 39 | class TestTypeAliasType(TestNameCheckVisitorBase): 40 | @assert_passes() 41 | def test_typing_extensions(self): 42 | from typing_extensions import TypeAliasType, assert_type 43 | 44 | MyType = TypeAliasType("MyType", int) 45 | 46 | def f(x: MyType): 47 | assert_type(x, MyType) 48 | assert_type(x + 1, int) 49 | 50 | def capybara(i: int, s: str): 51 | f(i) 52 | f(s) # E: incompatible_argument 53 | 54 | @assert_passes() 55 | def test_typing_extensions_generic(self): 56 | from typing import List, Set, TypeVar, Union 57 | 58 | from typing_extensions import TypeAliasType, assert_type 59 | 60 | T = TypeVar("T") 61 | MyType = TypeAliasType("MyType", Union[List[T], Set[T]], type_params=(T,)) 62 | 63 | def f(x: MyType[int]): 64 | assert_type(x, MyType[int]) 65 | assert_type(list(x), List[int]) 66 | 67 | def capybara(i: int, s: str): 68 | f([i]) 69 | f([s]) # E: incompatible_argument 70 | 71 | @skip_before((3, 12)) 72 | def test_312(self): 73 | self.assert_passes( 74 | """ 75 | from typing_extensions import assert_type 76 | type MyType = int 77 | 78 | def f(x: MyType): 79 | assert_type(x, MyType) 80 | assert_type(x + 1, int) 81 | 82 | def capybara(i: int, s: str): 83 | f(i) 84 | f(s) # E: incompatible_argument 85 | """ 86 | ) 87 | 88 | @skip_before((3, 12)) 89 | def test_312_generic(self): 90 | self.assert_passes( 91 | """ 92 | from typing_extensions import assert_type 93 | type MyType[T] = list[T] | set[T] 94 | 95 | def f(x: MyType[int]): 96 | assert_type(x, MyType[int]) 97 | assert_type(list(x), list[int]) 98 | 99 | def capybara(i: int, s: str): 100 | f([i]) 101 | f([s]) # E: incompatible_argument 102 | """ 103 | ) 104 | 105 | @skip_before((3, 12)) 106 | def test_312_local_alias(self): 107 | self.assert_passes( 108 | """ 109 | from typing_extensions import assert_type 110 | 111 | def capybara(): 112 | type MyType = int 113 | def f(x: MyType): 114 | assert_type(x, MyType) 115 | assert_type(x + 1, int) 116 | 117 | f(1) 118 | f("x") # E: incompatible_argument 119 | """ 120 | ) 121 | 122 | @skip_before((3, 12)) 123 | def test_312_literal(self): 124 | self.assert_passes( 125 | """ 126 | from typing import assert_type, Literal 127 | 128 | type MyType = Literal[1, 2, 3] 129 | 130 | def capybara(x: MyType): 131 | assert_type(x + 1, Literal[2, 3, 4]) 132 | 133 | def pacarana(x: MyType): 134 | capybara(x) 135 | """ 136 | ) 137 | 138 | @skip_before((3, 12)) 139 | def test_312_iteration(self): 140 | self.assert_passes( 141 | """ 142 | from typing import assert_type, Literal 143 | 144 | type MyType = tuple[int, str, float] 145 | 146 | def capybara(t: MyType): 147 | x, y, z = t 148 | assert_type(x, int) 149 | assert_type(y, str) 150 | assert_type(z, float) 151 | 152 | def pacarana(x: MyType): 153 | capybara(x) 154 | """ 155 | ) 156 | -------------------------------------------------------------------------------- /pycroscope/test_unsafe_comparison.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | from .test_name_check_visitor import TestNameCheckVisitorBase 3 | from .test_node_visitor import assert_passes, skip_if_not_installed 4 | 5 | 6 | class TestUnsafeOverlap(TestNameCheckVisitorBase): 7 | @assert_passes() 8 | def test_simple(self): 9 | from typing_extensions import Never 10 | 11 | def capybara(x: Never, y: str, z: int): 12 | assert x == 1 13 | assert 1 == 2 14 | assert __name__ == "__main__" 15 | assert y == z # E: unsafe_comparison 16 | 17 | @assert_passes() 18 | def test_none(self): 19 | def capybara(x: str): 20 | assert x is not None 21 | 22 | @assert_passes() 23 | def test_fancy_none(self): 24 | class X: 25 | def __init__(self) -> None: 26 | self.y = None 27 | 28 | def capybara(x: X): 29 | assert x.y == 42 # OK 30 | 31 | @assert_passes() 32 | def test_union(self): 33 | from typing import Union 34 | 35 | def capybara(x: Union[int, str], z: Union[str, bytes]): 36 | assert x == z # ok 37 | assert x == b"y" # E: unsafe_comparison 38 | assert 1 == z # E: unsafe_comparison 39 | 40 | @assert_passes() 41 | def test_subclass_value(self): 42 | from typing import Type 43 | 44 | def capybara(x: type, y: Type[int], marker: int): 45 | if marker == 0: 46 | assert x == int 47 | elif marker == 1: 48 | assert int == x 49 | elif marker == 2: 50 | assert x == y 51 | elif marker == 3: 52 | assert y == x 53 | elif marker == 4: 54 | assert str == y # E: unsafe_comparison 55 | elif marker == 5: 56 | assert y == str # E: unsafe_comparison 57 | 58 | @assert_passes() 59 | def test_mock_any(self): 60 | from unittest.mock import ANY 61 | 62 | def capybara(x: int): 63 | assert x == ANY 64 | 65 | @skip_if_not_installed("asynq") 66 | @assert_passes() 67 | def test_asynq(self): 68 | from asynq import AsyncTask, asynq 69 | 70 | from pycroscope.value import ( 71 | AsyncTaskIncompleteValue, 72 | TypedValue, 73 | assert_is_value, 74 | ) 75 | 76 | @asynq() 77 | def f() -> int: 78 | return 42 79 | 80 | @asynq() 81 | def caller(): 82 | x = f.asynq() 83 | assert_is_value(x, AsyncTaskIncompleteValue(AsyncTask, TypedValue(int))) 84 | if x == 42: # E: unsafe_comparison 85 | print("yay") 86 | 87 | @assert_passes() 88 | def test_inner_none(self): 89 | from typing import List 90 | 91 | def f(x: List[int], y: List[None]): 92 | assert x == y # E: unsafe_comparison 93 | 94 | @assert_passes() 95 | def test_allow_is_with_literals(self): 96 | from typing_extensions import Literal 97 | 98 | def f(x: Literal[True], y: object): 99 | assert True is False 100 | if y == 0: 101 | assert x is True 102 | else: 103 | assert x is False 104 | 105 | 106 | class TestOverrideEq(TestNameCheckVisitorBase): 107 | @assert_passes() 108 | def test_simple_eq(self): 109 | from typing_extensions import Literal, overload 110 | 111 | class HasSimpleEq: 112 | def __eq__(self, other: object) -> bool: 113 | return self is other 114 | 115 | class FancyEq1: 116 | @overload 117 | def __eq__(self, x: int) -> Literal[False]: ... 118 | @overload 119 | def __eq__(self, x: str) -> Literal[False]: ... 120 | def __eq__(self, x: object) -> bool: 121 | return False 122 | 123 | class FancyEq2: 124 | def __eq__(self, x: object, extra_arg: bool = False) -> bool: 125 | return False 126 | 127 | class FancyEq3: 128 | def __eq__(self, *args: object) -> bool: 129 | return False 130 | 131 | def capybara( 132 | x: HasSimpleEq, y: int, fe1: FancyEq1, fe2: FancyEq2, fe3: FancyEq3 133 | ): 134 | assert x == y # E: unsafe_comparison 135 | assert y == x # E: unsafe_comparison 136 | assert fe1 == y # OK 137 | assert fe2 == y # OK 138 | assert fe3 == y # OK 139 | -------------------------------------------------------------------------------- /pycroscope/tests.py: -------------------------------------------------------------------------------- 1 | # static analysis: ignore 2 | """ 3 | 4 | Functions to be used in test_scope unit tests. 5 | 6 | """ 7 | 8 | from collections.abc import Sequence 9 | from typing import ClassVar, NoReturn, Union, overload 10 | 11 | from typing_extensions import final 12 | 13 | from .value import SequenceValue, Value, VariableNameValue 14 | 15 | ASYNQ_METHOD_NAME = "asynq" 16 | ASYNQ_METHOD_NAMES = ("asynq",) 17 | 18 | uid_vnv = VariableNameValue(["uid"]) 19 | qid_vnv = VariableNameValue(["qid"]) 20 | 21 | 22 | class Wrapper: 23 | base: ClassVar[type] 24 | 25 | 26 | def wrap(cls): 27 | """Decorator that wraps a class.""" 28 | 29 | class NewWrapper(Wrapper): 30 | base = cls 31 | 32 | return NewWrapper 33 | 34 | 35 | def takes_kwonly_argument(a, **kwargs): 36 | assert set(kwargs) == {"kwonly_arg"} 37 | 38 | 39 | class PropertyObject: 40 | def __init__(self, poid): 41 | self.poid = poid 42 | 43 | def non_async_method(self): 44 | pass 45 | 46 | @final 47 | def decorated_method(self): 48 | pass 49 | 50 | @property 51 | def string_property(self) -> str: 52 | return str(self.poid) 53 | 54 | @property 55 | def prop(self): 56 | return 42 57 | 58 | prop_with_get = prop 59 | prop_with_is = prop 60 | 61 | def _private_method(self): 62 | pass 63 | 64 | @classmethod 65 | def no_args_classmethod(cls): 66 | pass 67 | 68 | 69 | class KeywordOnlyArguments: 70 | def __init__(self, *args, **kwargs): 71 | assert set(kwargs) <= {"kwonly_arg"} 72 | 73 | 74 | class WhatIsMyName: 75 | def __init__(self): 76 | pass 77 | 78 | 79 | WhatIsMyName.__name__ = "Capybara" 80 | WhatIsMyName.__init__.__name__ = "capybara" 81 | 82 | 83 | class FailingImpl: 84 | def __init__(self) -> None: 85 | pass 86 | 87 | 88 | def custom_code() -> None: 89 | pass 90 | 91 | 92 | @overload 93 | def overloaded() -> int: ... 94 | 95 | 96 | @overload 97 | def overloaded(x: str) -> str: ... 98 | 99 | 100 | def overloaded(*args: str) -> Union[int, str]: 101 | if len(args) == 0: 102 | return len(args) 103 | elif len(args) == 1: 104 | return args[0] 105 | else: 106 | raise TypeError("too many arguments") 107 | 108 | 109 | def assert_never(arg: NoReturn) -> NoReturn: 110 | raise RuntimeError("no way") 111 | 112 | 113 | def make_simple_sequence(typ: type, vals: Sequence[Value]) -> SequenceValue: 114 | return SequenceValue(typ, [(False, val) for val in vals]) 115 | 116 | 117 | def make_union_in_annotated() -> object: 118 | return 42 119 | -------------------------------------------------------------------------------- /pycroscope/typevar.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | TypeVar solver. 4 | 5 | """ 6 | 7 | from collections.abc import Iterable, Sequence 8 | from typing import Union 9 | 10 | import pycroscope 11 | 12 | from .analysis_lib import Sentinel 13 | from .safe import all_of_type, is_instance_of_typing_name 14 | from .value import ( 15 | AnySource, 16 | AnyValue, 17 | Bound, 18 | BoundsMap, 19 | CanAssignContext, 20 | CanAssignError, 21 | IsOneOf, 22 | LowerBound, 23 | OrBound, 24 | TypeVarLike, 25 | TypeVarMap, 26 | UpperBound, 27 | Value, 28 | unite_values, 29 | ) 30 | 31 | BOTTOM = Sentinel("") 32 | TOP = Sentinel("") 33 | 34 | 35 | def resolve_bounds_map( 36 | bounds_map: BoundsMap, 37 | ctx: CanAssignContext, 38 | *, 39 | all_typevars: Iterable[TypeVarLike] = (), 40 | ) -> tuple[TypeVarMap, Sequence[CanAssignError]]: 41 | tv_map = {tv: AnyValue(AnySource.generic_argument) for tv in all_typevars} 42 | errors = [] 43 | for tv, bounds in bounds_map.items(): 44 | bounds = tuple(dict.fromkeys(bounds)) 45 | if is_instance_of_typing_name(tv, "ParamSpec"): 46 | # For ParamSpec, we use a simpler approach 47 | solution = pycroscope.input_sig.solve_paramspec(bounds, ctx) 48 | else: 49 | solution = solve(bounds, ctx) 50 | if isinstance(solution, CanAssignError): 51 | errors.append(solution) 52 | solution = AnyValue(AnySource.error) 53 | tv_map[tv] = solution 54 | return tv_map, errors 55 | 56 | 57 | def solve( 58 | bounds: Iterable[Bound], ctx: CanAssignContext 59 | ) -> Union[Value, CanAssignError]: 60 | bottom = BOTTOM 61 | top = TOP 62 | options = None 63 | 64 | for bound in bounds: 65 | if isinstance(bound, LowerBound): 66 | # Ignore lower bounds to Any 67 | if isinstance(bound.value, AnyValue) and bottom is not BOTTOM: 68 | continue 69 | if bottom is BOTTOM or bound.value.is_assignable(bottom, ctx): 70 | # New bound is more specific. Adopt it. 71 | bottom = bound.value 72 | elif bottom.is_assignable(bound.value, ctx): 73 | # New bound is less specific. Ignore it. 74 | pass 75 | else: 76 | # New bound is separate. We have to satisfy both. 77 | # TODO shouldn't this use intersection? 78 | bottom = unite_values(bottom, bound.value) 79 | elif isinstance(bound, UpperBound): 80 | if top is TOP or top.is_assignable(bound.value, ctx): 81 | top = bound.value 82 | elif bound.value.is_assignable(top, ctx): 83 | pass 84 | else: 85 | top = unite_values(top, bound.value) 86 | elif isinstance(bound, OrBound): 87 | # TODO figure out how to handle this 88 | continue 89 | elif isinstance(bound, IsOneOf): 90 | options = bound.constraints 91 | else: 92 | assert False, f"unrecognized bound {bound}" 93 | 94 | if bottom is BOTTOM: 95 | if top is TOP: 96 | solution = AnyValue(AnySource.generic_argument) 97 | else: 98 | solution = top 99 | elif top is TOP: 100 | solution = bottom 101 | else: 102 | can_assign = top.can_assign(bottom, ctx) 103 | if isinstance(can_assign, CanAssignError): 104 | return CanAssignError( 105 | "Incompatible bounds on type variable", 106 | [ 107 | can_assign, 108 | CanAssignError( 109 | children=[CanAssignError(str(bound)) for bound in bounds] 110 | ), 111 | ], 112 | ) 113 | solution = bottom 114 | 115 | if options is not None: 116 | can_assigns = [option.can_assign(solution, ctx) for option in options] 117 | if all_of_type(can_assigns, CanAssignError): 118 | return CanAssignError(children=list(can_assigns)) 119 | available = [ 120 | option 121 | for option, can_assign in zip(options, can_assigns) 122 | if not isinstance(can_assign, CanAssignError) 123 | ] 124 | # If there's only one solution, pick it. 125 | if len(available) == 1: 126 | return available[0] 127 | # If we inferred Any, keep it; all the solutions will be valid, and 128 | # picking one will lead to weird errors down the line. 129 | if isinstance(solution, AnyValue): 130 | return solution 131 | available = remove_redundant_solutions(available, ctx) 132 | if len(available) == 1: 133 | return available[0] 134 | # If there are still multiple options, we fall back to Any. 135 | return AnyValue(AnySource.inference) 136 | return solution 137 | 138 | 139 | def remove_redundant_solutions( 140 | solutions: Sequence[Value], ctx: CanAssignContext 141 | ) -> Sequence[Value]: 142 | # This is going to be quadratic, so don't do it when there's too many 143 | # opttions. 144 | initial_count = len(solutions) 145 | if initial_count > 10: 146 | return solutions 147 | 148 | temp_solutions = list(solutions) 149 | for i in range(initial_count): 150 | sol = temp_solutions[i] 151 | for j, other in enumerate(temp_solutions): 152 | if i == j or other is None: 153 | continue 154 | if sol.is_assignable(other, ctx) and not other.is_assignable(sol, ctx): 155 | temp_solutions[i] = None 156 | return [sol for sol in temp_solutions if sol is not None] 157 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pycroscope" 7 | version = "0.1.0" 8 | description = "A static analyzer for Python" 9 | readme = {file = "README.md", content-type = "text/markdown"} 10 | authors = [ 11 | { name = "Jelle Zijlstra", email = "jelle.zijlstra@gmail.com" } 12 | ] 13 | license = {text = "Apache Software License"} 14 | requires-python = ">=3.9" 15 | keywords = ["type checker", "static analysis"] 16 | classifiers = [ 17 | "License :: OSI Approved :: Apache Software License", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | 26 | dependencies = [ 27 | "ast_decompiler>=0.4.0", 28 | "typeshed_client>=2.1.0", 29 | "typing_extensions>=4.12.0", 30 | "codemod", 31 | "tomli>=1.1.0" 32 | ] 33 | 34 | [project.optional-dependencies] 35 | tests = [ 36 | "pytest", 37 | "mypy_extensions", 38 | "attrs", 39 | "pydantic; python_version < '3.14'", # TODO currently fails to build on 3.14 40 | "annotated-types" 41 | ] 42 | asynq = ["asynq", "qcore>=0.5.1"] 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/JelleZijlstra/pycroscope" 46 | 47 | [project.scripts] 48 | pycroscope = "pycroscope.__main__:main" 49 | 50 | [tool.setuptools] 51 | packages = ["pycroscope"] 52 | 53 | [tool.setuptools.package-data] 54 | # These are useful for unit tests of pycroscope extensions 55 | # outside the package. 56 | pycroscope = ["test.toml", "asynq_test.toml", "stubs/*/*.pyi"] 57 | 58 | [tool.black] 59 | target-version = ['py39'] 60 | include = '\.pyi?$' 61 | skip-magic-trailing-comma = true 62 | preview = true 63 | exclude = ''' 64 | /( 65 | \.git 66 | | \.ipython 67 | | \.ipython64 68 | | \.mypy_cache 69 | | \.tox 70 | | \.venv 71 | )/ 72 | ''' 73 | 74 | [tool.pycroscope] 75 | paths = ["pycroscope"] 76 | import_paths = ["."] 77 | enforce_no_unused = true 78 | 79 | possibly_undefined_name = true 80 | use_fstrings = true 81 | missing_return_annotation = true 82 | missing_parameter_annotation = true 83 | unused_variable = true 84 | value_always_true = true 85 | suggested_parameter_type = true 86 | suggested_return_type = true 87 | incompatible_override = true 88 | missing_generic_parameters = true 89 | 90 | [[tool.pycroscope.overrides]] 91 | module = "pycroscope.typevar" 92 | implicit_any = true 93 | 94 | [[tool.pycroscope.overrides]] 95 | module = "pycroscope.yield_checker" 96 | implicit_any = true 97 | 98 | [tool.ruff] 99 | line-length = 100 100 | target-version = "py39" 101 | 102 | [tool.ruff.lint] 103 | select = [ 104 | "F", 105 | "E", 106 | "I", # import sorting 107 | "UP", 108 | ] 109 | 110 | ignore = [ 111 | "B008", # do not perform function calls in argument defaults 112 | "F811", # redefinition of unused '...' 113 | "F821", # undefined name 114 | "F505", # .format() stuff 115 | "F507", # .format() stuff 116 | "F522", # .format() stuff 117 | "F523", # .format() stuff 118 | "F524", # .format() stuff 119 | "F823", # local variable referenced before assignment 120 | "F601", # dictionary key name repeated with different values 121 | "E721", # do not compare types, use 'isinstance()' 122 | "F841", # local variable is assigned to but never used 123 | "E742", # Ambiguous class name 124 | "E731", # do not assign a lambda expression, use a def 125 | "E741", # ambiguous variable name 126 | ] 127 | 128 | [tool.ruff.lint.per-file-ignores] 129 | "pycroscope/test_*.py" = [ 130 | "UP", # Want to test old-style code 131 | ] 132 | "pycroscope/annotations.py" = [ 133 | "UP006", # Need to refer to typing.Type 134 | ] 135 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Needed for ReadTheDocs 2 | asynq 3 | qcore>=0.5.1 4 | ast_decompiler>=0.4.0 5 | typeshed_client>=2.0.0 6 | typing_extensions>=4.1.1 7 | codemod 8 | myst-parser==4.0.0 9 | Sphinx==7.4.7 10 | black==25.1.0 11 | ruff==0.11.10 12 | --------------------------------------------------------------------------------