A simple "dumb" python type stub generator
Project description
dubstub
A simple "dumb" python type stub generator.
Overview
Dubstub is a tool that takes a python codebase as an input (mix of .py and .pyi files),
and generates a matching set of .pyi files.
It does this purely based on a simple syntactic transformation of an input file, with some hardcoded heuristics and customizable configs. It is designed that way to avoid the following complications:
- It does not require running any kind of type inference or other semantic analysis of the python code, which might have performance overhead and have unpredictable output over time.
- It does not require following references to other files, so source files can be converted in isolation.
- It does not require the python code to be runnable, importable, or even be semantically correct.
It is intended to be simple and stable enough to use for automated use cases, without
requiring manual review and adjustment of its output. Naturally this will only
work well for python code with fairly "static" APIs without dynamically generated members,
though you can always mix it with manually defined .pyi files.
Installation
This tool can be installed from PYPI or from source:
pip install dubstub
Optional features (Package Extras):
def_fmt: Installsisortandblackin a known good version, so that they can be used by the default commands if formatting of generated.pyifiles is enabled (it is off per default).pip install dubstub[def_fmt]
eval: Installs dependencies needed for thedubstub evalanddubstub diffcommands, which are not necessary for normal operation of the tool.pip install dubstub[eval]
Development
To work on this tool itself, you can set up your environment like this:
- Create a venv and activate it.
- Run
pip install -e .[dev]to install all dev dependencies and the package itself in editable mode. - Run
dev/check.pyto run all style checks and pytest tests, including those for different python versions (usinguv).
Maintenance Status
This project is provided as-is. You may open issues and PRs, but I can not promise that I will react to them.
Usage
CLI
For details see dubstub --help. As a quick overview:
dubstub gengenerates type stubs for a provided file or directory tree. Example:dubstub gen --input ./src --output ./out
dubstub configallows checking which config settings apply. Example:dubstub config --show-format toml --profile pyright
dubstub evalallows evaluating the typestub generation of multiple tools, by generating stubs with all of them. Example:dubstub eval --input ./src --output ./out --format True --profile pyright
dubstub diffcan show the differences of the output ofdubstub evalin a compact way. Example:dubstub diff --eval ./out | less -R
From code
The tool can also be directly invoked from python code:
Wrapping main function
from dubstub import main
if __name__ == "__main__":
main()
Generating stubs
from pathlib import Path
from dubstub import generate_stubs
# stub single file
generate_stubs(Path("input.py"), Path("output.pyi"))
# stub entire directory tree
generate_stubs(Path("input_dir/"), Path("output_dir/"))
Custom configs
from pathlib import Path
from dubstub import Config, generate_stubs
# Loading config from file
config = Config.parse_config(path=Path("pyproject.toml"))
generate_stubs(Path("input2.py"), Path("output2.pyi"), config=config)
# Constructing config
config = Config(
profile="no_privacy",
keep_unused_imports=False,
)
generate_stubs(Path("input1.py"), Path("output1.pyi"), config=config)
Config
This tool has a number of config settings that can be configured in a number of ways.
In general, for any individual setting, the following precedence applies:
- If the setting is explicit set on the commandline or in code, use it.
- If the setting is explicitly set in a config file that has been loaded, use it.
- If a config profile is selected that defines the setting, use the setting from the profile.
- As a final fallback, use the setting from the
defaultprofile.
The usage of config files is optional. If one is used, it has to have the toml
format, and define all config settings under the tool.dubstub key. The filename
is arbitrary, so you can store the settings either in a pyproject.toml,
or in a separate file.
Config Settings
| Config Setting | Description |
|---|---|
type: |
Base profile to use for config settings. Any explicitly set config values will overwrite those defined by the profile. |
type: |
Version of python syntax to target. Valid forms are |
type: |
Whether to keep a class, function, or variable definition during type stubbing. This can be used to remove private elements (ie, those starting with a single |
type: |
Whether to keep a trailing docstring for an element. A trailing docstring is defined as a string literal that is not the first statement in a module, class or function block. These kinds of docstrings are supported by some documentation generators and IDE plugins. Example: class foo:
"""normal docstring"""
x: y = z
"""trailing docstring"""
|
type: |
Whether to add a |
type: |
Whether to keep an If an |
type: |
Whether to flatten the first body of an This setting combines with This does not apply to |
type: |
Whether to add redundant ellipsis ( Example: class foo:
... # always added
class bar:
x = y
... # redundant
|
type: |
Whether to keep the assignment value of a variable definition.
If the value is not kept, it is replaced with an ellipsis ( Example: foo = 42 # value kept
foo: bar = 42 # value kept
foo = ... # value not kept
foo: bar = ... # value not kept
|
type: |
Whether to keep import statements that seem to not be used anywhere in the module. Note that this is a heuristic based on searching for the imported name, it can have false positives, which erroneously consider an import used. Both normal python code, as well the contents of Certain imports will be interpreted as re-exports, and always considered used. For example, given this code: x: Foo
Then imports would be considered like this: from foo import Foo # considered used
from bar import Bar # considered not used
import X as X # considered used (always)
import a.b.X as X # considered not used
from Y import X as X # considered used (always)
from Y import * # considered used (always)
See also https://typing.readthedocs.io/en/latest/spec/distributing.html#import-conventions |
type: |
Enable autoformatting of the generated typestub code. The default output of this tool follows no consistent formatting style: Individual lines either contain a single large statement, or are copy-pasted from the original source verbatim. If enabled, the generated or copied |
type: |
List of formatter cmds that will be executed on generated and copied Each [[tool.dubstub.formatter_cmds]]
name = "<cmd name>"
cmdline = [
"<cmd>",
"<arg1>",
"<arg2>",
...
]
There are a number of variable substitutions supported for the
The commands will be executed in series, with the output of one feeding into the next one.
The difference between the last two substitution options is that Examples:
|
Patterns
Most of the config settings are patterns that get evaluated against a node in the AST of the stubbed file to decide if the config should apply to the node.
A Pattern can be specified in two ways:
- As a boolean.
- As a string that contains a python expression that evaluates to a boolean.
The python expression is allowed to make use of parenthesis, and, or and not, and of special function calls
to check properties of the current node. The expression may also contain line breaks.
Currently all provided functions take a single string literal as argument,
which is interpreted as a regular expression that is matched against the full value
(re.fullmatch semantic).
Functions
| Function Name | Description |
|---|---|
parent_node_is |
Match against the tags of the parent AST node. |
node_is |
Match against the tags of the AST node. |
file_path_is |
Match against the path to the current source file, relative to the path the stub generator was started with. |
name_is |
Match against the name of the AST node, if it has one. |
annotation_is |
Match against the stringified annotation expression of an AST node, if it has one. |
value_is |
Match against the stringified assignment or condition expression of an AST node, if it has one. |
any_child_node_is |
Match against the tags of all child AST nodes. |
Tags
| Tag value | Description |
|---|---|
module |
Module |
class |
Class definition (class ...) |
function |
Function definition (def ...) |
import |
Import statement |
type_alias |
Explicit type alias statement (type Foo = ...) |
variable |
Variable definition (x: y, x: y = z or x = z) |
annotated |
Variable with type annotation (x: y or x: y = z) |
assigned |
Variable with value assignment (x = z or x: y = z) |
if |
If statement |
docstring |
Docstring expression statement |
ellipsis |
Ellipsis expression statement (...) |
Profiles
Profile default
The default profile tries to have sensible defaults that match the official recommendations:
- Private definitions are removed, and identified by exactly one leading
_. - Trailing docstrings are kept for variables.
- We add a
-> Nonereturn type on functions that should have them. ifstatements are kept, butTYPE_CHECKINGguards are merged into the surrounding scope.- Variable values are kept if they define types according to the
typingmodule. - Unused imports are remove.
- Autoformatting is disabled, but will use isort and black with default settings if enabled.
Exact config settings set by the profile:
[tool.dubstub]
python_version = "auto"
keep_definitions = """
not name_is('_[^_].*')
"""
keep_trailing_docstrings = """
node_is('variable')
"""
add_implicit_none_return = """
parent_node_is('class') and name_is('__init__')
"""
keep_if_statements = true
flatten_if = """
value_is('TYPE_CHECKING')
"""
add_redundant_ellipsis = false
keep_variable_value = """
annotation_is('TypeAlias|([tT]ype(\\\\[.*\\\\])?)')
or value_is('(TypeVar|TypeVarTuple|ParamSpec)\\\\(.*\\\\)')
or value_is('(NamedTuple|NewType|TypedDict)\\\\(.*\\\\)')
or (parent_node_is('module') and name_is('__all__'))
or (parent_node_is('class') and name_is('__model__'))
"""
keep_unused_imports = false
format = false
[[tool.dubstub.formatter_cmds]]
name = "isort"
cmdline = [
"isort",
"--py",
"${dubstub_py_major}${dubstub_py_minor}",
"--profile",
"black",
"--settings",
"/dev/null",
"${dubstub_file_args}",
]
[[tool.dubstub.formatter_cmds]]
name = "black"
cmdline = [
"black",
"--pyi",
"--target-version",
"py${dubstub_py_major}${dubstub_py_minor}",
"--skip-magic-trailing-comma",
"--fast",
"--config",
"/dev/null",
"${dubstub_file_args}",
]
Profile no_privacy
This profile is the same as default, but with privacy disabled:
- No definitions with "private" names are filtered out.
- No unused imports are filtered out.
Exact config settings set by the profile:
[tool.dubstub]
keep_definitions = true
keep_unused_imports = true
Profile pyright
This profiles tries to approximate pyrights stubgen behavior better:
- Only private functions are removed.
- Redundant ellipsis are added to a few locations.
- Trailing docstrings are removed.
Exact config settings set by the profile:
[tool.dubstub]
keep_definitions = """
not (node_is('function') and name_is('_[^_].*'))
"""
keep_trailing_docstrings = false
add_redundant_ellipsis = """
not any_child_node_is('function|assigned|class')
"""
Specification
Filesystem traversal
The gen and eval commands can either process a single file, or a whole directory tree:
.pyfiles will be stubbed to.pyifiles and optionally formatted..pyifiles will be copied as-is and optionally formatted.py.typedfiles will be copied as-is.- If there is both a
.pyand.pyifile for the same module, the.pyfile will be ignored in favour of th.pyifile. - Other files will be ignored.
- Directories will be recursively traversed.
The --input and --output options accept the following combination of of arguments:
- If the input path is a file, and the output path either does not exist or is a file, then the output will be written to the output path (even if the file name does not match the input).
- If the input path is a directory, and the output path either does not exist or is a directory, the same relative directory structure found in input will be created in output, and any files in it recursively processed.
- If the input path is a file, and the output path is an existing directory, then the output file will be written to the directory.
Any output directory that would be created as part of this processed will be cleaned out if they already exist beforehand, to prevent polluting the output with old files.
This cleanup process skips over directories without python files (eg as found in namespace packages), so that only directories that contain python files will get replaced.
This allows the type stubs of different namespace packages to be written into the same shared output directory without them overwriting each other.
Stubbing
At its most basic, this tool takes type-annotated python code, and removes any code not relevant for type checkers.
There is a link to the official specification of what that should entail at the end of the readme, and this tool tries to follow that, but it will also do some simplifications due to its syntax-only nature.
We define the tools behavior mostly in terms of what it keeps of the original source code, and what it removes, though there are also some explicit transformations of code involved.
The process is recursive, and mostly works the same for the body of the module, a class, or a control flow statement. Starting with a source file, the following logic applies:
- For a module, all statements in it are recursively converted to type stubs.
- Imports statements (
import ...orfrom ... import ...) will get filtered or entirely removed.- Relevant config settings:
keep_trailing_docstringskeep_unused_imports
- Relevant config settings:
- "Variable definitions" in the form of assignments like
x [: y] [= z]will get simplified or removed.- Assignment that do not target a variable directly are ignored (eg,
obj.field = 42). - The
yexpression will be interpreted and rewritten as a "type expression" (see below). - Relevant config settings:
keep_definitionskeep_trailing_docstringskeep_variable_value
- Assignment that do not target a variable directly are ignored (eg,
- Class definitions will get simplified or removed.
- Class decorators are kept as they are.
- Class bases and keyword arguments are kept as they are.
- All statements in the class body are recursively converted to type stubs.
- Classes that would be left empty will contain an ellipsis (
...). - Relevant config settings:
add_redundant_ellipsiskeep_definitionskeep_trailing_docstrings
- Function definitions will get simplified or removed.
- Function decorators are kept as they are.
- The function signature is stubbed:
- Argument type annotations will be interpreted and rewritten as a "type expression" (see below).
- Any return type annotation will be interpreted and rewritten as a "type expression" (see below).
- Default value assignments are replaced by an ellipsis (
...).
- All statements in the function body are removed.
- Relevant config settings:
add_implicit_none_returnadd_redundant_ellipsiskeep_definitionskeep_trailing_docstrings
- (Doc)String expressions are kept as they are or removed.
- If the docstring is the first statement in the body of an outer statement, it is kept.
- Otherwise the
keep_trailing_docstringsconfig of the preceding statement applies. - Any other expression statement is not kept.
- If statements are either kept, removed, or flattened into the outer scope.
- If they are kept, their bodies will be recursively converted to type stubs.
- Keeping if statements supports conditional code that checks
sys.platform, for example. if/elif/elsebodies that would be left empty will contain an ellipsis (...).- Relevant config settings:
add_redundant_ellipsisflatten_ifkeep_if_statementskeep_trailing_docstrings
- Try and With statements are flattened into the outer scope.
- Any other statement type is ignored, and not kept.
- Some statements are explicitly ignored, like
pass,del,for,assert,raise, function calls, etc. - Other statement will also be ignored, but trigger a warning if encountered, to indicate that this tool is lacking explicit support for them for now.
- Some statements are explicitly ignored, like
Type expressions
We define a "type expression" as a python expression that has to be evaluated as a type, eg because it was written at type annotation position in the code. The official docs define this term as well, and draw a distinction between an "annotated expression" and a "type expression".
We mainly care about this because types in a type expression can be quoted to avoid cyclic references during normal evaluation of code. Eg all these expressions have the same meaning: Optional[MyType], "Optional[MyType]", Optional["MyType"]. During type stubbing, we want to remove these superfluous quotes, as .pyi files are allowed to have cycling references for type annotations.
Due to the constraints of this tool, we are a less precise with following the official definitions. The issue here is that we do not actually have a semantic type analysis of the code, so we can only evaluate type expressions based on their raw syntax and some heuristics.
Converting to unquoted from is again a recursive process:
- String expressions are replaced by their content:
"Foo" -> Foo. Literal[...]is kept as it is.- For
Annotated[ty, ...], the first index operator argument is recursively converted. - For any other
Foo[..., ...], the index operator arguments are recursively converted (we assume its a generic). - Likewise, lists
[..., ...]are recursively converted (which handles cases likeCallable[[T], U]). - Chains of the bitor/union operator
... | ...are recursively converted. - Any other case has not been relevant for me so far, and is not handled yet. Generally unhandled syntax is kept as it is.
Syntax Limitations
- The tool currently ignores
# typecomments entirely - The tool does not currently support all syntax of the language, including future syntax of newer versions.
Useful links
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dubstub-1.1.1.tar.gz.
File metadata
- Download URL: dubstub-1.1.1.tar.gz
- Upload date:
- Size: 40.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d0778347bf8db0fb98e48c6e357bdf4a20e944dc429f55a4c65e41492406ebec
|
|
| MD5 |
3b8f1e2df740cb0ec597d45b6631668f
|
|
| BLAKE2b-256 |
9b8e8b098ecf1f62b88b81ca604572ca1e4bf2db8552c0472189aabde5b15a50
|
File details
Details for the file dubstub-1.1.1-py3-none-any.whl.
File metadata
- Download URL: dubstub-1.1.1-py3-none-any.whl
- Upload date:
- Size: 41.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f421845e6f188ea79e575f0501244424c9e7d13f0c1a9f20341d0518c798096
|
|
| MD5 |
cbefdad046e04ec93e34e6cbeb117667
|
|
| BLAKE2b-256 |
2f2e4cec46ec8826821f5b157f79465ecb840f890aa2ea74ab338bededef5d0b
|