Skip to content

Commit 34e41f6

Browse files
authored
Track generated stubs for docstub itself (#96)
* Track generated inplace stubs for docstub itself I thought about not doing this but then it becomes more complicated to include the stubs into the package in the release workflow (cd.yml). I want to keep the release workflow as minimal as possible to reduce the risk of a supply chain attack. This approach sidesteps that problem, because the stubs are already there and tracked. However, we need to assert that the tracked stubs are identical with the docstub generated ones. That is a bit tricky, because `git diff` actually ignores untracked files! So I came up with `assert-unchanged.sh`. I really struggled with this becaue I initally tried to use git ls-files with the "-z" option which returns null-delimited paths. I couldn't figure out how to pass them to `git add` and `git unstage` later on. I'm worried that this approach is a bit brittle (due to `assert-unchanged.sh`) and increases the diff in PRs. On the other hand being able to review the stubs is probably not a bad thing. We'll see how that goes. * Fix now failing doctest that assumed non-existing stub file * Revert "Fix now failing doctest that assumed non-existing stub file" This reverts commit b09317d. * Move stubs into src/docstub-stubs Apparently this works fine with mypy and basedpyright! * Refactor assert-unchanged.sh to not use `git add` I think that triggered a permission denied error in the GitHub CI. This solution feels a lot cleaner too! * Enable tracing in assert-unchanged.sh to debug permission error * Set execution bit for assert-unchanged.sh *facepalm* * Exclude docstub-stubs from pre-commit and undo changes * Tweak step name and add comment * Add success message to assert-unchanged.sh
1 parent d2ba71d commit 34e41f6

19 files changed

Lines changed: 651 additions & 6 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
3+
# Assert that there are no changes in a given directory compared to HEAD.
4+
# Expects a relative directory as the one and only argument.
5+
6+
set -e
7+
8+
CHECK_DIR=$1
9+
10+
# Find untracked files
11+
UNTRACKED=$(git ls-files --others --exclude-standard "$CHECK_DIR")
12+
# and display their content by comparing with '/dev/null'
13+
echo "$UNTRACKED" | xargs -I _ git --no-pager diff --no-index /dev/null _
14+
15+
# Display changes in tracked files and capture non-zero exit code if so
16+
set +e
17+
git diff --exit-code HEAD "$CHECK_DIR"
18+
GIT_DIFF_HEAD_EXIT_CODE=$?
19+
set -e
20+
21+
# Display changes in tracked files and capture exit status
22+
if [ $GIT_DIFF_HEAD_EXIT_CODE -ne 0 ] || [ -n "$UNTRACKED" ]; then
23+
echo "::error::Uncommited changes in directory '$CHECK_DIR'"
24+
exit 1
25+
else
26+
echo "::notice::No Uncommited changes, directory '$CHECK_DIR' is clean"
27+
fi
28+
29+
set +e

.github/workflows/ci.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ env:
1717
# Many color libraries just need this to be set to any value, but at least
1818
# one distinguishes color depth, where "3" -> "256-bit color".
1919
FORCE_COLOR: 3
20-
MYPYPATH: ${{ github.workspace }}/stubs
2120

2221
defaults:
2322
run:
@@ -78,17 +77,22 @@ jobs:
7877
7978
# TODO upload coverage statistics, and fail on decrease?
8079

81-
- name: Compare example stubs
80+
- name: Check example_pkg-stubs
81+
# Check that stubs for example_pkg are up-to-date by regenerating them
82+
# with docstub and looking for differences.
8283
run: |
8384
python -m docstub run -v \
8485
--config=examples/docstub.toml \
8586
--out-dir=examples/example_pkg-stubs \
8687
examples/example_pkg
87-
git diff --exit-code examples/ && echo "Stubs for example_pkg did not change"
88+
.github/scripts/assert-unchanged.sh examples/
8889
89-
- name: Generate stubs for docstub
90+
- name: Check docstub-stubs
91+
# Check that stubs for docstub are up-to-date by regenerating them
92+
# with docstub and looking for differences.
9093
run: |
91-
python -m docstub run -v src/docstub -o ${MYPYPATH}/docstub
94+
python -m docstub run -v src/docstub -o src/docstub-stubs
95+
.github/scripts/assert-unchanged.sh src/docstub-stubs/
9296
9397
- name: Check with mypy.stubtest
9498
run: |

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
exclude: |
44
(?x)^(
55
examples/.*-stubs/.*|
6+
src/docstub-stubs/.*|
67
)$
78
89
repos:

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,5 +163,4 @@ ignore_missing_imports = true
163163

164164

165165
[tool.basedpyright]
166-
stubPath = "stubs/"
167166
typeCheckingMode = "standard"

src/docstub-stubs/__init__.pyi

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# File generated with docstub
2+
3+
from ._version import __version__
4+
5+
__all__ = ["__version__"]

src/docstub-stubs/__main__.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# File generated with docstub
2+
3+
from ._cli import cli
4+
5+
if __name__ == "__main__":
6+
pass

src/docstub-stubs/_analysis.pyi

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# File generated with docstub
2+
3+
import builtins
4+
import importlib
5+
import json
6+
import logging
7+
import re
8+
from collections.abc import Iterable
9+
from dataclasses import asdict, dataclass
10+
from functools import cache
11+
from pathlib import Path
12+
from typing import Any, ClassVar
13+
14+
import libcst as cst
15+
import libcst.matchers as cstm
16+
17+
from ._utils import accumulate_qualname, module_name_from_path, pyfile_checksum
18+
19+
logger: logging.Logger
20+
21+
def _shared_leading_qualname(*qualnames: tuple[str]) -> str: ...
22+
@dataclass(slots=True, frozen=True)
23+
class PyImport:
24+
25+
import_: str | None = ...
26+
from_: str | None = ...
27+
as_: str | None = ...
28+
implicit: str | None = ...
29+
30+
@classmethod
31+
def typeshed_Incomplete(cls) -> PyImport: ...
32+
def format_import(self, relative_to: str | None = ...) -> str: ...
33+
@property
34+
def target(self) -> str: ...
35+
@property
36+
def has_import(self) -> None: ...
37+
def __post_init__(self) -> None: ...
38+
def __repr__(self) -> str: ...
39+
def __str__(self) -> str: ...
40+
41+
def _is_type(value: Any) -> bool: ...
42+
def _builtin_types() -> dict[str, PyImport]: ...
43+
def _runtime_types_in_module(module_name: str) -> dict[str, PyImport]: ...
44+
def common_known_types() -> dict[str, PyImport]: ...
45+
46+
class TypeCollector(cst.CSTVisitor):
47+
class ImportSerializer:
48+
49+
suffix: ClassVar[str]
50+
encoding: ClassVar[str]
51+
52+
def hash_args(self, path: Path) -> str: ...
53+
def serialize(
54+
self, data: tuple[dict[str, PyImport], dict[str, PyImport]]
55+
) -> bytes: ...
56+
def deserialize(
57+
self, raw: bytes
58+
) -> tuple[dict[str, PyImport], dict[str, PyImport]]: ...
59+
60+
@classmethod
61+
def collect(cls, file: Path) -> tuple[dict[str, PyImport], dict[str, PyImport]]: ...
62+
def __init__(self, *, module_name: str) -> None: ...
63+
def visit_ClassDef(self, node: cst.ClassDef) -> bool: ...
64+
def leave_ClassDef(self, original_node: cst.ClassDef) -> None: ...
65+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool: ...
66+
def visit_TypeAlias(self, node: cst.TypeAlias) -> bool: ...
67+
def visit_AnnAssign(self, node: cst.AnnAssign) -> bool: ...
68+
def visit_ImportFrom(self, node: cst.ImportFrom) -> bool: ...
69+
def visit_Import(self, node: cst.Import) -> bool: ...
70+
def _collect_type_annotation(self, stack: Iterable[str]) -> None: ...
71+
72+
class TypeMatcher:
73+
types: dict[str, PyImport]
74+
type_prefixes: dict[str, PyImport]
75+
type_nicknames: dict[str, str]
76+
successful_queries: int
77+
unknown_qualnames: list
78+
current_file: Path | None
79+
80+
def __init__(
81+
self,
82+
*,
83+
types: dict[str, PyImport] | None = ...,
84+
type_prefixes: dict[str, PyImport] | None = ...,
85+
type_nicknames: dict[str, str] | None = ...,
86+
) -> None: ...
87+
def _resolve_nickname(self, name: str) -> str: ...
88+
def match(self, search: str) -> tuple[str | None, PyImport | None]: ...

src/docstub-stubs/_cache.pyi

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# File generated with docstub
2+
3+
import logging
4+
from collections.abc import Callable
5+
from functools import cached_property
6+
from pathlib import Path
7+
from typing import Any, Protocol
8+
9+
logger: logging.Logger
10+
11+
CACHE_DIR_NAME: str
12+
13+
CACHEDIR_TAG_CONTENT: str
14+
15+
GITHUB_IGNORE_CONTENT: str
16+
17+
def _directory_size(path: Path) -> int: ...
18+
def create_cache(path: Path) -> None: ...
19+
def validate_cache(path: Path) -> None: ...
20+
21+
class FuncSerializer[T](Protocol):
22+
23+
suffix: str
24+
25+
def hash_args(self, *args: Any, **kwargs: Any) -> str: ...
26+
def serialize(self, data: T) -> bytes: ...
27+
def deserialize(self, raw: bytes) -> T: ...
28+
29+
class FileCache:
30+
func: Callable
31+
serializer: FuncSerializer
32+
sub_dir: str
33+
cache_hits: int
34+
cache_misses: int
35+
cached_last_call: bool | None
36+
37+
def __init__(
38+
self,
39+
*,
40+
func: Callable,
41+
serializer: FuncSerializer,
42+
cache_dir: Path,
43+
sub_dir: str | None = ...,
44+
) -> None: ...
45+
@cached_property
46+
def cache_dir(self) -> Path: ...
47+
@property
48+
def cache_sub_dir(self) -> None: ...
49+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...

src/docstub-stubs/_cli.pyi

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# File generated with docstub
2+
3+
import logging
4+
import shutil
5+
import sys
6+
import time
7+
from collections import Counter
8+
from collections.abc import Iterable, Sequence
9+
from contextlib import contextmanager
10+
from pathlib import Path
11+
from typing import Literal
12+
13+
import click
14+
15+
from ._analysis import PyImport, TypeCollector, TypeMatcher, common_known_types
16+
from ._cache import CACHE_DIR_NAME, FileCache, validate_cache
17+
from ._config import Config
18+
from ._path_utils import (
19+
STUB_HEADER_COMMENT,
20+
find_package_root,
21+
walk_source_and_targets,
22+
walk_source_package,
23+
)
24+
from ._report import setup_logging
25+
from ._stubs import Py2StubTransformer, try_format_stub
26+
from ._version import __version__
27+
28+
logger: logging.Logger
29+
30+
def _cache_dir_in_cwd() -> Path: ...
31+
def _load_configuration(config_paths: list[Path] | None = ...) -> Config: ...
32+
def _calc_verbosity(
33+
*, verbose: Literal[0, 1, 2], quiet: Literal[0, 1, 2]
34+
) -> Literal[-2, -1, 0, 1, 2]: ...
35+
def _collect_type_info(
36+
root_path: Path, *, ignore: Sequence[str] = ..., cache: bool = ...
37+
) -> tuple[dict[str, PyImport], dict[str, PyImport]]: ...
38+
def _format_unknown_names(unknown_names: Iterable[str]) -> str: ...
39+
def log_execution_time() -> None: ...
40+
@click.group()
41+
def cli() -> None: ...
42+
@cli.command()
43+
def run(
44+
*,
45+
root_path: Path,
46+
out_dir: Path,
47+
config_paths: Sequence[Path],
48+
ignore: Sequence[str],
49+
group_errors: bool,
50+
allow_errors: int,
51+
fail_on_warning: bool,
52+
no_cache: bool,
53+
verbose: int,
54+
quiet: int,
55+
) -> None: ...
56+
@cli.command()
57+
def clean(verbose: int, quiet: int) -> None: ...

src/docstub-stubs/_config.pyi

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# File generated with docstub
2+
3+
import dataclasses
4+
import logging
5+
import tomllib
6+
from collections.abc import Mapping
7+
from pathlib import Path
8+
from typing import ClassVar, Self
9+
10+
logger: logging.Logger
11+
12+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
13+
class Config:
14+
TEMPLATE_PATH: ClassVar[Path]
15+
NUMPY_PATH: ClassVar[Path]
16+
17+
types: dict[str, str] = ...
18+
type_prefixes: dict[str, str] = ...
19+
type_nicknames: dict[str, str] = ...
20+
ignore_files: list[str] = ...
21+
22+
config_paths: tuple[Path, ...] = ...
23+
24+
@classmethod
25+
def from_toml(cls, path: Path | str) -> Self: ...
26+
def merge(self, other: Self) -> Self: ...
27+
def to_dict(self) -> None: ...
28+
def __post_init__(self) -> None: ...
29+
def __repr__(self) -> str: ...
30+
@staticmethod
31+
def validate(mapping: Mapping) -> None: ...

0 commit comments

Comments
 (0)