typeshed_stats 22.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typeshed_stats/__init__.py +18 -0
- typeshed_stats/__main__.py +13 -0
- typeshed_stats/_cli.py +312 -0
- typeshed_stats/gather.py +583 -0
- typeshed_stats/py.typed +0 -0
- typeshed_stats/serialize.py +138 -0
- typeshed_stats-22.11.0.dist-info/METADATA +81 -0
- typeshed_stats-22.11.0.dist-info/RECORD +11 -0
- typeshed_stats-22.11.0.dist-info/WHEEL +4 -0
- typeshed_stats-22.11.0.dist-info/entry_points.txt +2 -0
- typeshed_stats-22.11.0.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Library and command-line tool for getting stats on various typeshed packages."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"__title__",
|
|
5
|
+
"__summary__",
|
|
6
|
+
"__about__",
|
|
7
|
+
"__author__",
|
|
8
|
+
"__email__",
|
|
9
|
+
"__license__",
|
|
10
|
+
"__version__",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
__title__ = "typeshed-stats"
|
|
14
|
+
__summary__ = __about__ = __doc__
|
|
15
|
+
__author__ = "Alex Waygood"
|
|
16
|
+
__email__ = "alex.waygood@gmail.com"
|
|
17
|
+
__license__ = "MIT"
|
|
18
|
+
__version__ = "22.11.0"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Library and command-line tool for getting stats on various typeshed packages."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.version_info < (3, 10):
|
|
6
|
+
raise ImportError("Python 3.10+ is required!")
|
|
7
|
+
|
|
8
|
+
__all__: list[str] = []
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__": # pragma: no cover
|
|
11
|
+
from ._cli import main
|
|
12
|
+
|
|
13
|
+
main()
|
typeshed_stats/_cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from contextlib import ExitStack
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Literal, TypeAlias, cast, get_args
|
|
14
|
+
|
|
15
|
+
from .gather import PackageName, PackageStats, gather_stats, tmpdir_typeshed
|
|
16
|
+
from .serialize import stats_to_csv, stats_to_html, stats_to_json, stats_to_markdown
|
|
17
|
+
|
|
18
|
+
__all__ = ["OutputOption", "SUPPORTED_EXTENSIONS", "main"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _format_stats_for_pprinting(
|
|
22
|
+
stats: Sequence[PackageStats],
|
|
23
|
+
) -> dict[PackageName, PackageStats]:
|
|
24
|
+
# *Don't* stringify this one
|
|
25
|
+
# It makes it harder for pprint or rich to format it nicely
|
|
26
|
+
return {info_bundle.package_name: info_bundle for info_bundle in stats}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_CF: TypeAlias = Annotated[
|
|
30
|
+
Callable[[Sequence[PackageStats]], object],
|
|
31
|
+
"Function for converting a sequence of PackageStats into a certain format",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutputOption(Enum):
|
|
36
|
+
"""Enumeration of the different output options on the command line."""
|
|
37
|
+
|
|
38
|
+
PPRINT = ".txt", cast(_CF, _format_stats_for_pprinting)
|
|
39
|
+
JSON = ".json", cast(_CF, stats_to_json)
|
|
40
|
+
CSV = ".csv", cast(_CF, stats_to_csv)
|
|
41
|
+
MARKDOWN = ".md", cast(_CF, stats_to_markdown)
|
|
42
|
+
HTML = ".html", cast(_CF, stats_to_html)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def file_extension(self) -> str:
|
|
46
|
+
"""File extension associated with this file type."""
|
|
47
|
+
return self.value[0]
|
|
48
|
+
|
|
49
|
+
def convert(self, stats: Sequence[PackageStats]) -> object:
|
|
50
|
+
"""Convert a sequence of `PackageStats` objects into the specified format."""
|
|
51
|
+
converter_function = self.value[1]
|
|
52
|
+
return converter_function(stats)
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
"""repr(self)."""
|
|
56
|
+
return f"OutputOption.{self.name}(extension={self.file_extension!r})"
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_file_extension(cls, extension: str) -> OutputOption:
|
|
60
|
+
"""Return the enum member associated with a particular file extension."""
|
|
61
|
+
try:
|
|
62
|
+
return next(member for member in cls if member.file_extension == extension)
|
|
63
|
+
except StopIteration:
|
|
64
|
+
raise ValueError(f"Unsupported file extension {extension!r}") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
SUPPORTED_EXTENSIONS = {option.file_extension for option in OutputOption}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_stats(
|
|
71
|
+
formatted_stats: object, writefile: Path | None, logger: logging.Logger
|
|
72
|
+
) -> None:
|
|
73
|
+
if writefile is None:
|
|
74
|
+
pprint: Callable[[object], None]
|
|
75
|
+
try:
|
|
76
|
+
from rich import print as pprint # type: ignore[no-redef]
|
|
77
|
+
except ImportError:
|
|
78
|
+
if isinstance(formatted_stats, str):
|
|
79
|
+
pprint = print
|
|
80
|
+
else:
|
|
81
|
+
from pprint import pprint # type: ignore[no-redef]
|
|
82
|
+
|
|
83
|
+
pprint(formatted_stats)
|
|
84
|
+
else:
|
|
85
|
+
newline = "" if writefile.suffix == ".csv" else "\n"
|
|
86
|
+
if not isinstance(formatted_stats, str):
|
|
87
|
+
formatted_stats = str(formatted_stats)
|
|
88
|
+
with writefile.open("w", newline=newline, encoding="utf-8") as f:
|
|
89
|
+
f.write(formatted_stats)
|
|
90
|
+
logger.info(f'Output successfully written to "{writefile}"!')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_LoggingLevels: TypeAlias = Literal[
|
|
94
|
+
"NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_argument_parser() -> argparse.ArgumentParser:
|
|
99
|
+
"""Parse arguments and do basic argument validation.
|
|
100
|
+
|
|
101
|
+
*Don't* do any querying of whether paths actually exist, etc.
|
|
102
|
+
Leave that to _validate_options().
|
|
103
|
+
"""
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="typeshed-stats", description="Tool to gather stats on typeshed"
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"packages",
|
|
109
|
+
type=str,
|
|
110
|
+
nargs="*",
|
|
111
|
+
action="extend",
|
|
112
|
+
help=(
|
|
113
|
+
"Packages to gather stats on"
|
|
114
|
+
" (defaults to all third-party packages, plus the stdlib)"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--log",
|
|
119
|
+
choices=get_args(_LoggingLevels),
|
|
120
|
+
default="INFO",
|
|
121
|
+
help="Specify the level of logging (defaults to logging.INFO)",
|
|
122
|
+
dest="logging_level",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
output_options = parser.add_mutually_exclusive_group()
|
|
126
|
+
output_options.add_argument(
|
|
127
|
+
"--pprint",
|
|
128
|
+
action="store_const",
|
|
129
|
+
const=OutputOption.PPRINT,
|
|
130
|
+
dest="output_option",
|
|
131
|
+
help="Pretty-print Python representations of the data (default output)",
|
|
132
|
+
)
|
|
133
|
+
output_options.add_argument(
|
|
134
|
+
"--to-json",
|
|
135
|
+
action="store_const",
|
|
136
|
+
const=OutputOption.JSON,
|
|
137
|
+
dest="output_option",
|
|
138
|
+
help="Print output as JSON to the terminal",
|
|
139
|
+
)
|
|
140
|
+
output_options.add_argument(
|
|
141
|
+
"--to-csv",
|
|
142
|
+
action="store_const",
|
|
143
|
+
const=OutputOption.CSV,
|
|
144
|
+
dest="output_option",
|
|
145
|
+
help="Print output in csv format to the terminal",
|
|
146
|
+
)
|
|
147
|
+
output_options.add_argument(
|
|
148
|
+
"--to-markdown",
|
|
149
|
+
action="store_const",
|
|
150
|
+
const=OutputOption.MARKDOWN,
|
|
151
|
+
dest="output_option",
|
|
152
|
+
help="Print output as formatted MarkDown to the terminal",
|
|
153
|
+
)
|
|
154
|
+
output_options.add_argument(
|
|
155
|
+
"--to-html",
|
|
156
|
+
action="store_const",
|
|
157
|
+
const=OutputOption.HTML,
|
|
158
|
+
dest="output_option",
|
|
159
|
+
help="Print output as formatted HTML to the terminal",
|
|
160
|
+
)
|
|
161
|
+
output_options.add_argument(
|
|
162
|
+
"-f",
|
|
163
|
+
"--to-file",
|
|
164
|
+
type=Path,
|
|
165
|
+
help=(
|
|
166
|
+
"Write output to WRITEFILE instead of printing to the terminal."
|
|
167
|
+
" The file format will be inferred by the file extension."
|
|
168
|
+
f" The file extension must be one of {SUPPORTED_EXTENSIONS}."
|
|
169
|
+
),
|
|
170
|
+
dest="writefile",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"-o",
|
|
175
|
+
"--overwrite",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help=(
|
|
178
|
+
"Overwrite the path passed to `--file` if it already exists"
|
|
179
|
+
" (defaults to False)"
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
typeshed_options = parser.add_mutually_exclusive_group(required=True)
|
|
184
|
+
typeshed_options.add_argument(
|
|
185
|
+
"-t",
|
|
186
|
+
"--typeshed-dir",
|
|
187
|
+
type=Path,
|
|
188
|
+
help="Path to a local clone of typeshed, to be used as the basis for analysis",
|
|
189
|
+
)
|
|
190
|
+
typeshed_options.add_argument(
|
|
191
|
+
"-d",
|
|
192
|
+
"--download-typeshed",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help=(
|
|
195
|
+
"Download a fresh copy of typeshed into a temporary directory,"
|
|
196
|
+
" and use that as the basis for analysis"
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return parser
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Has to be a dataclass (attrs does class-level defaults differently)
|
|
204
|
+
@dataclass(init=False)
|
|
205
|
+
class _CmdArgs:
|
|
206
|
+
logging_level: _LoggingLevels
|
|
207
|
+
packages: list[str]
|
|
208
|
+
typeshed_dir: Path | None
|
|
209
|
+
download_typeshed: bool
|
|
210
|
+
overwrite: bool
|
|
211
|
+
writefile: Path | None
|
|
212
|
+
# OutputOption defaults to PPRINT if no argument was specified on the command line
|
|
213
|
+
output_option: OutputOption = OutputOption.PPRINT
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _determine_output_option(
|
|
217
|
+
args: _CmdArgs, *, parser: argparse.ArgumentParser
|
|
218
|
+
) -> OutputOption:
|
|
219
|
+
writefile = args.writefile
|
|
220
|
+
if not writefile:
|
|
221
|
+
return args.output_option
|
|
222
|
+
if not writefile.suffix:
|
|
223
|
+
parser.error(f"{writefile!r} has no file extension!")
|
|
224
|
+
if writefile.suffix not in SUPPORTED_EXTENSIONS:
|
|
225
|
+
parser.error(
|
|
226
|
+
f"Unrecognised file extension {writefile.suffix!r} passed to --file"
|
|
227
|
+
f" (choose from {SUPPORTED_EXTENSIONS})"
|
|
228
|
+
)
|
|
229
|
+
if not (writefile.parent.exists() and writefile.parent.is_dir()):
|
|
230
|
+
parser.error(
|
|
231
|
+
f'"{writefile}" is an invalid argument:'
|
|
232
|
+
f" {writefile.parent} does not exist as a directory!"
|
|
233
|
+
)
|
|
234
|
+
if writefile.exists() and not args.overwrite:
|
|
235
|
+
parser.error(
|
|
236
|
+
f'"{writefile}" already exists!'
|
|
237
|
+
"\n(Note: use --overwite"
|
|
238
|
+
" if your intention was to overwrite an existing file)"
|
|
239
|
+
)
|
|
240
|
+
return OutputOption.from_file_extension(writefile.suffix)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_packages(
|
|
244
|
+
package_names: list[str], typeshed_dir: Path, *, parser: argparse.ArgumentParser
|
|
245
|
+
) -> None:
|
|
246
|
+
stubs_dir = typeshed_dir / "stubs"
|
|
247
|
+
for package_name in package_names:
|
|
248
|
+
if package_name != "stdlib":
|
|
249
|
+
package_dir = stubs_dir / package_name
|
|
250
|
+
if not (package_dir.exists() and package_dir.is_dir()):
|
|
251
|
+
parser.error(f"{package_name!r} does not have stubs in typeshed!")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validate_typeshed_dir(
|
|
255
|
+
typeshed_dir: Path, *, parser: argparse.ArgumentParser
|
|
256
|
+
) -> None:
|
|
257
|
+
for folder in typeshed_dir, (typeshed_dir / "stdlib"), (typeshed_dir / "stubs"):
|
|
258
|
+
if not (folder.exists() and folder.is_dir()):
|
|
259
|
+
parser.error(f'"{typeshed_dir}" is not a valid typeshed directory')
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _setup_logger(str_level: _LoggingLevels) -> logging.Logger:
|
|
263
|
+
assert str_level in get_args(_LoggingLevels)
|
|
264
|
+
logger = logging.getLogger("typeshed_stats")
|
|
265
|
+
level = getattr(logging, str_level)
|
|
266
|
+
assert isinstance(level, int)
|
|
267
|
+
logger.setLevel(level)
|
|
268
|
+
handler = logging.StreamHandler()
|
|
269
|
+
handler.setLevel(level)
|
|
270
|
+
logger.addHandler(handler)
|
|
271
|
+
return logger
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _run(argv: Sequence[str] | None = None) -> None:
|
|
275
|
+
parser = _get_argument_parser()
|
|
276
|
+
args: _CmdArgs = parser.parse_args(argv, namespace=_CmdArgs())
|
|
277
|
+
logger = _setup_logger(args.logging_level)
|
|
278
|
+
|
|
279
|
+
with ExitStack() as stack:
|
|
280
|
+
if args.download_typeshed:
|
|
281
|
+
logger.info("Cloning typeshed into a temporary directory...")
|
|
282
|
+
typeshed_dir = stack.enter_context(tmpdir_typeshed())
|
|
283
|
+
else:
|
|
284
|
+
assert args.typeshed_dir is not None
|
|
285
|
+
typeshed_dir = args.typeshed_dir
|
|
286
|
+
_validate_typeshed_dir(typeshed_dir, parser=parser)
|
|
287
|
+
|
|
288
|
+
packages: list[str] | None = args.packages or None
|
|
289
|
+
if packages:
|
|
290
|
+
_validate_packages(packages, typeshed_dir, parser=parser)
|
|
291
|
+
|
|
292
|
+
output_option = _determine_output_option(args, parser=parser)
|
|
293
|
+
|
|
294
|
+
logger.info("Gathering stats...")
|
|
295
|
+
stats = gather_stats(packages, typeshed_dir=typeshed_dir)
|
|
296
|
+
|
|
297
|
+
logger.info("Formatting stats...")
|
|
298
|
+
formatted_stats = output_option.convert(stats)
|
|
299
|
+
logger.info("Writing stats...")
|
|
300
|
+
_write_stats(formatted_stats, args.writefile, logger)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
304
|
+
"""CLI entry point."""
|
|
305
|
+
try:
|
|
306
|
+
_run(argv)
|
|
307
|
+
except KeyboardInterrupt:
|
|
308
|
+
sys.stderr.write("Interrupted!")
|
|
309
|
+
code = 2
|
|
310
|
+
else:
|
|
311
|
+
code = 0
|
|
312
|
+
raise SystemExit(code)
|
typeshed_stats/gather.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""Tools for gathering stats about typeshed packages."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
12
|
+
from contextlib import AsyncExitStack, contextmanager
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from functools import cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Protocol, TypeAlias, TypeVar, final
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
import attrs
|
|
20
|
+
from packaging.specifiers import SpecifierSet
|
|
21
|
+
from packaging.version import Version
|
|
22
|
+
|
|
23
|
+
if sys.version_info < (3, 10):
|
|
24
|
+
raise ImportError("Python 3.10+ is required!")
|
|
25
|
+
|
|
26
|
+
if sys.version_info >= (3, 11):
|
|
27
|
+
import tomllib # pragma: no cover
|
|
28
|
+
else:
|
|
29
|
+
import tomli as tomllib # pragma: no cover
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AnnotationStats",
|
|
34
|
+
"PackageName",
|
|
35
|
+
"PackageStats",
|
|
36
|
+
"PackageStatus",
|
|
37
|
+
"PyrightSetting",
|
|
38
|
+
"StubtestSetting",
|
|
39
|
+
"gather_annotation_stats_on_file",
|
|
40
|
+
"gather_annotation_stats_on_package",
|
|
41
|
+
"gather_stats",
|
|
42
|
+
"gather_stats_on_package",
|
|
43
|
+
"get_package_size",
|
|
44
|
+
"get_package_status",
|
|
45
|
+
"get_pyright_strictness",
|
|
46
|
+
"get_stubtest_setting",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
PackageName: TypeAlias = str
|
|
50
|
+
_NiceReprEnumSelf = TypeVar("_NiceReprEnumSelf", bound="_NiceReprEnum")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _NiceReprEnum(Enum):
|
|
54
|
+
"""Base class for several public-API enums in this package."""
|
|
55
|
+
|
|
56
|
+
def __new__(cls: type[_NiceReprEnumSelf], doc: str) -> _NiceReprEnumSelf:
|
|
57
|
+
assert isinstance(doc, str)
|
|
58
|
+
member = object.__new__(cls)
|
|
59
|
+
member._value_ = member.__doc__ = doc
|
|
60
|
+
return member
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"{self.__class__.__name__}.{self.name}"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def formatted_name(self) -> str:
|
|
67
|
+
return " ".join(self.name.split("_")).lower()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@attrs.define
|
|
71
|
+
class _SingleAnnotationAnalyzer(ast.NodeVisitor):
|
|
72
|
+
Any_in_annotation: bool = False
|
|
73
|
+
Incomplete_in_annotation: bool = False
|
|
74
|
+
|
|
75
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
76
|
+
match node.id:
|
|
77
|
+
case "Any":
|
|
78
|
+
self.Any_in_annotation = True
|
|
79
|
+
case "Incomplete":
|
|
80
|
+
self.Incomplete_in_annotation = True
|
|
81
|
+
case _:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
85
|
+
match node.value, node.attr:
|
|
86
|
+
case [ast.Name("typing"), "Any"]:
|
|
87
|
+
self.Any_in_annotation = True
|
|
88
|
+
case [ast.Name("_typeshed"), "Incomplete"]:
|
|
89
|
+
self.Incomplete_in_annotation = True
|
|
90
|
+
case _:
|
|
91
|
+
pass
|
|
92
|
+
self.generic_visit(node)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _SingleAnnotationAnalysis(Protocol):
|
|
96
|
+
Any_in_annotation: bool
|
|
97
|
+
Incomplete_in_annotation: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _analyse_annotation(annotation: ast.AST) -> _SingleAnnotationAnalysis:
|
|
101
|
+
analyser = _SingleAnnotationAnalyzer()
|
|
102
|
+
analyser.visit(annotation)
|
|
103
|
+
return analyser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@final
|
|
107
|
+
@attrs.define
|
|
108
|
+
class AnnotationStats:
|
|
109
|
+
"""Stats on the annotations for a source file or a directory of source files."""
|
|
110
|
+
|
|
111
|
+
annotated_parameters: int = 0
|
|
112
|
+
unannotated_parameters: int = 0
|
|
113
|
+
annotated_returns: int = 0
|
|
114
|
+
unannotated_returns: int = 0
|
|
115
|
+
explicit_Incomplete_parameters: int = 0
|
|
116
|
+
explicit_Incomplete_returns: int = 0
|
|
117
|
+
explicit_Any_parameters: int = 0
|
|
118
|
+
explicit_Any_returns: int = 0
|
|
119
|
+
annotated_variables: int = 0
|
|
120
|
+
explicit_Any_variables: int = 0
|
|
121
|
+
explicit_Incomplete_variables: int = 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _AnnotationStatsCollector(ast.NodeVisitor):
|
|
125
|
+
"""AST Visitor for collecting stats on a single stub file."""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self.stats = AnnotationStats()
|
|
129
|
+
self._class_nesting = 0
|
|
130
|
+
self._function_decorators: frozenset[str] = frozenset()
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return f"{self.__class__.__name__}(stats={self.stats})"
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def in_class(self) -> bool:
|
|
137
|
+
"""Return `True` if we're currently visiting a class definition."""
|
|
138
|
+
return bool(self._class_nesting)
|
|
139
|
+
|
|
140
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
141
|
+
self._class_nesting += 1
|
|
142
|
+
self.generic_visit(node)
|
|
143
|
+
self._class_nesting -= 1
|
|
144
|
+
|
|
145
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
146
|
+
self.stats.annotated_variables += 1
|
|
147
|
+
analysis = _analyse_annotation(node.annotation)
|
|
148
|
+
if analysis.Any_in_annotation:
|
|
149
|
+
self.stats.explicit_Any_variables += 1
|
|
150
|
+
if analysis.Incomplete_in_annotation:
|
|
151
|
+
self.stats.explicit_Incomplete_variables += 1
|
|
152
|
+
self.generic_visit(node)
|
|
153
|
+
|
|
154
|
+
def visit_arg(self, node: ast.arg) -> None:
|
|
155
|
+
self.generic_visit(node)
|
|
156
|
+
|
|
157
|
+
# We don't want self/cls/metacls/mcls arguments to count towards the statistics
|
|
158
|
+
if self.in_class and "staticmethod" not in self._function_decorators:
|
|
159
|
+
if "classmethod" in self._function_decorators:
|
|
160
|
+
if node.arg in {"cls", "metacls", "mcls"}:
|
|
161
|
+
return
|
|
162
|
+
elif node.arg == "self":
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
annotation = node.annotation
|
|
166
|
+
if annotation is None:
|
|
167
|
+
self.stats.unannotated_parameters += 1
|
|
168
|
+
else:
|
|
169
|
+
self.stats.annotated_parameters += 1
|
|
170
|
+
analysis = _analyse_annotation(annotation)
|
|
171
|
+
if analysis.Any_in_annotation:
|
|
172
|
+
self.stats.explicit_Any_parameters += 1
|
|
173
|
+
if analysis.Incomplete_in_annotation:
|
|
174
|
+
self.stats.explicit_Incomplete_parameters += 1
|
|
175
|
+
|
|
176
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
177
|
+
returns = node.returns
|
|
178
|
+
if returns is None:
|
|
179
|
+
self.stats.unannotated_returns += 1
|
|
180
|
+
else:
|
|
181
|
+
self.stats.annotated_returns += 1
|
|
182
|
+
analysis = _analyse_annotation(returns)
|
|
183
|
+
if analysis.Any_in_annotation:
|
|
184
|
+
self.stats.explicit_Any_returns += 1
|
|
185
|
+
if analysis.Incomplete_in_annotation:
|
|
186
|
+
self.stats.explicit_Incomplete_returns += 1
|
|
187
|
+
|
|
188
|
+
old_function_decorators = self._function_decorators
|
|
189
|
+
self._function_decorators = frozenset(
|
|
190
|
+
decorator.id
|
|
191
|
+
for decorator in node.decorator_list
|
|
192
|
+
if isinstance(decorator, ast.Name)
|
|
193
|
+
)
|
|
194
|
+
self.generic_visit(node)
|
|
195
|
+
self._function_decorators = old_function_decorators
|
|
196
|
+
|
|
197
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
198
|
+
self._visit_function(node)
|
|
199
|
+
|
|
200
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
201
|
+
self._visit_function(node)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def gather_annotation_stats_on_file(path: Path | str) -> AnnotationStats:
|
|
205
|
+
"""Gather annotation stats on a single typeshed stub file.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
path: The location of the file to be analysed.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
An `AnnotationStats` object containing data
|
|
212
|
+
about the annotations in the file.
|
|
213
|
+
"""
|
|
214
|
+
visitor = _AnnotationStatsCollector()
|
|
215
|
+
with open(path, encoding="utf-8") as file:
|
|
216
|
+
visitor.visit(ast.parse(file.read()))
|
|
217
|
+
return visitor.stats
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@cache
|
|
221
|
+
def _get_package_directory(package_name: PackageName, typeshed_dir: Path | str) -> Path:
|
|
222
|
+
if package_name == "stdlib":
|
|
223
|
+
return Path(typeshed_dir, "stdlib")
|
|
224
|
+
return Path(typeshed_dir, "stubs", package_name)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def gather_annotation_stats_on_package(
|
|
228
|
+
package_name: PackageName, *, typeshed_dir: Path | str
|
|
229
|
+
) -> AnnotationStats:
|
|
230
|
+
"""Aggregate annotation stats on a typeshed stubs package.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
package_name: The name of the stubs package to analyze.
|
|
234
|
+
typeshed_dir: A path pointing to the location of a typeshed directory
|
|
235
|
+
in which to find the stubs package source.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
An `AnnotationStats` object containing data
|
|
239
|
+
about the annotations in the package.
|
|
240
|
+
"""
|
|
241
|
+
package_directory = _get_package_directory(package_name, typeshed_dir)
|
|
242
|
+
file_results = [
|
|
243
|
+
gather_annotation_stats_on_file(path)
|
|
244
|
+
for path in package_directory.rglob("*.pyi")
|
|
245
|
+
]
|
|
246
|
+
# Sum all the statistics together, to get the statistics for the package as a whole
|
|
247
|
+
#
|
|
248
|
+
# TODO: we're throwing away information here.
|
|
249
|
+
# It might be nice to have a way to get per-file stats, especially for the stdlib.
|
|
250
|
+
package_stats: Counter[str] = sum(
|
|
251
|
+
[Counter(attrs.asdict(result)) for result in file_results], start=Counter()
|
|
252
|
+
)
|
|
253
|
+
return AnnotationStats(**package_stats)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@cache
|
|
257
|
+
def _get_package_metadata(
|
|
258
|
+
package_name: PackageName, typeshed_dir: Path | str
|
|
259
|
+
) -> Mapping[str, Any]:
|
|
260
|
+
package_directory = _get_package_directory(package_name, typeshed_dir)
|
|
261
|
+
with open(package_directory / "METADATA.toml", "rb") as f:
|
|
262
|
+
return tomllib.load(f)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class StubtestSetting(_NiceReprEnum):
|
|
266
|
+
"""Enumeration of the various possible settings typeshed uses for stubtest in CI."""
|
|
267
|
+
|
|
268
|
+
SKIPPED = "Stubtest is skipped in CI for this package."
|
|
269
|
+
MISSING_STUBS_IGNORED = (
|
|
270
|
+
"The `--ignore-missing-stub` stubtest setting is used in CI."
|
|
271
|
+
)
|
|
272
|
+
ERROR_ON_MISSING_STUB = (
|
|
273
|
+
"Objects missing from the stub cause stubtest to emit an error in CI."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_stubtest_setting(
|
|
278
|
+
package_name: str, *, typeshed_dir: Path | str
|
|
279
|
+
) -> StubtestSetting:
|
|
280
|
+
"""Get the setting typeshed uses in CI when stubtest is run on a certain package.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
package_name: The name of the package to find the stubtest setting for.
|
|
284
|
+
typeshed_dir: A path pointing to a typeshed directory,
|
|
285
|
+
from which to retrieve the stubtest setting.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
A member of the `StubtestSetting` enumeration
|
|
289
|
+
(see the docs on `StubtestSetting` for details).
|
|
290
|
+
"""
|
|
291
|
+
if package_name == "stdlib":
|
|
292
|
+
return StubtestSetting.ERROR_ON_MISSING_STUB
|
|
293
|
+
metadata = _get_package_metadata(package_name, typeshed_dir)
|
|
294
|
+
stubtest_settings = metadata.get("tool", {}).get("stubtest", {})
|
|
295
|
+
if stubtest_settings.get("skip", False):
|
|
296
|
+
return StubtestSetting.SKIPPED
|
|
297
|
+
ignore_missing_stub_used = stubtest_settings.get("ignore_missing_stub", True)
|
|
298
|
+
return StubtestSetting[
|
|
299
|
+
"MISSING_STUBS_IGNORED" if ignore_missing_stub_used else "ERROR_ON_MISSING_STUB"
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class PackageStatus(_NiceReprEnum):
|
|
304
|
+
"""The various states of freshness/staleness a stubs package can be in."""
|
|
305
|
+
|
|
306
|
+
STDLIB = (
|
|
307
|
+
"These are the stdlib stubs. Typeshed's stdlib stubs are generally fairly"
|
|
308
|
+
" up to date, and tested against all currently supported Python versions"
|
|
309
|
+
" in CI."
|
|
310
|
+
)
|
|
311
|
+
NOT_ON_PYPI = (
|
|
312
|
+
"The upstream for this package doesn't exist on PyPI,"
|
|
313
|
+
" so whether or not these stubs are up to date or not is unknown."
|
|
314
|
+
)
|
|
315
|
+
OBSOLETE = "Upstream has added type hints; these typeshed stubs are now obsolete."
|
|
316
|
+
NO_LONGER_UPDATED = (
|
|
317
|
+
"Upstream has not added type hints,"
|
|
318
|
+
" but these stubs are no longer updated for some other reason."
|
|
319
|
+
)
|
|
320
|
+
OUT_OF_DATE = (
|
|
321
|
+
"These stubs are out of date. In CI, stubtest tests these stubs against an"
|
|
322
|
+
" older version of this package than the latest that's available."
|
|
323
|
+
)
|
|
324
|
+
UP_TO_DATE = (
|
|
325
|
+
"These stubs should be fairly up to date. In CI, stubtest tests these stubs"
|
|
326
|
+
" against the latest version of the package that's available."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def _get_pypi_data(
|
|
331
|
+
package_name: str, session: aiohttp.ClientSession | None
|
|
332
|
+
) -> dict[str, Any]:
|
|
333
|
+
pypi_data_url = f"https://pypi.org/pypi/{urllib.parse.quote(package_name)}/json"
|
|
334
|
+
async with AsyncExitStack() as stack:
|
|
335
|
+
if session is None:
|
|
336
|
+
# see https://github.com/python/mypy/issues/13936
|
|
337
|
+
# for why we need the tmp_var to keep mypy happy
|
|
338
|
+
tmp_var = await stack.enter_async_context(aiohttp.ClientSession())
|
|
339
|
+
session = tmp_var
|
|
340
|
+
async with session.get(pypi_data_url) as response:
|
|
341
|
+
response.raise_for_status()
|
|
342
|
+
response_json = await response.json()
|
|
343
|
+
assert isinstance(response_json, dict)
|
|
344
|
+
return response_json
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def get_package_status(
|
|
348
|
+
package_name: str,
|
|
349
|
+
*,
|
|
350
|
+
typeshed_dir: Path | str,
|
|
351
|
+
session: aiohttp.ClientSession | None = None,
|
|
352
|
+
) -> PackageStatus:
|
|
353
|
+
"""Retrieve information on how up to date a stubs package is.
|
|
354
|
+
|
|
355
|
+
If stubtest tests these stubs against the latest version of the runtime
|
|
356
|
+
in typeshed's CI, it's a fair bet that the stubs are relatively up to date.
|
|
357
|
+
If stubtest tests these stubs against an older version, however,
|
|
358
|
+
the stubs may be out of date.
|
|
359
|
+
|
|
360
|
+
This function makes network requests to PyPI in order to determine what the
|
|
361
|
+
latest version of the runtime is, and then compares this against
|
|
362
|
+
the metadata of the stubs package.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
package_name: The name of the stubs package to analyze.
|
|
366
|
+
typeshed_dir: A path pointing to a typeshed directory
|
|
367
|
+
in which to find the stubs package.
|
|
368
|
+
session (optional): An `aiohttp.ClientSession` instance, to be used
|
|
369
|
+
for making a network requests, or `None`. If `None` is provided
|
|
370
|
+
for this argument, a new `aiohttp.ClientSession` instance will be
|
|
371
|
+
created to make the network request.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A member of the `PackageStatus` enumeration
|
|
375
|
+
(see the docs on `PackageStatus` for details).
|
|
376
|
+
"""
|
|
377
|
+
if package_name == "stdlib":
|
|
378
|
+
# This function isn't really relevant for the stdlib stubs
|
|
379
|
+
return PackageStatus.STDLIB
|
|
380
|
+
|
|
381
|
+
if package_name == "gdb":
|
|
382
|
+
return PackageStatus.NOT_ON_PYPI
|
|
383
|
+
|
|
384
|
+
metadata = _get_package_metadata(package_name, typeshed_dir)
|
|
385
|
+
|
|
386
|
+
if "obsolete_since" in metadata:
|
|
387
|
+
return PackageStatus.OBSOLETE
|
|
388
|
+
|
|
389
|
+
if metadata.get("no_longer_updated", False):
|
|
390
|
+
return PackageStatus.NO_LONGER_UPDATED
|
|
391
|
+
|
|
392
|
+
typeshed_pinned_version = SpecifierSet(f"=={metadata['version']}")
|
|
393
|
+
pypi_data = await _get_pypi_data(package_name, session)
|
|
394
|
+
pypi_version = Version(pypi_data["info"]["version"])
|
|
395
|
+
status = "UP_TO_DATE" if pypi_version in typeshed_pinned_version else "OUT_OF_DATE"
|
|
396
|
+
return PackageStatus[status]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_package_size(package_name: str, *, typeshed_dir: Path | str) -> int:
|
|
400
|
+
"""Get the total number of lines of code for a stubs package in typeshed.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
package_name: The name of the stubs package to find the line number for.
|
|
404
|
+
typeshed_dir: A path pointing to a typeshed directory
|
|
405
|
+
in which to find the stubs package.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
The number of lines of code the stubs package contains.
|
|
409
|
+
"""
|
|
410
|
+
return sum(
|
|
411
|
+
len(stub.read_text(encoding="utf-8").splitlines())
|
|
412
|
+
for stub in _get_package_directory(package_name, typeshed_dir).rglob("*.pyi")
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@cache
|
|
417
|
+
def _get_pyright_strict_excludelist(typeshed_dir: Path | str) -> frozenset[Path]:
|
|
418
|
+
# Read pyrightconfig.stricter.json;
|
|
419
|
+
# do some pre-processing so that it can be passed to json.loads()
|
|
420
|
+
config_path = Path(typeshed_dir, "pyrightconfig.stricter.json")
|
|
421
|
+
with config_path.open(encoding="utf-8") as file:
|
|
422
|
+
# strip comments from the file
|
|
423
|
+
lines = [line for line in file if not line.strip().startswith("//")]
|
|
424
|
+
# strip trailing commas from the file
|
|
425
|
+
valid_json = re.sub(r",(\s*?[\}\]])", r"\1", "\n".join(lines))
|
|
426
|
+
pyright_config = json.loads(valid_json)
|
|
427
|
+
assert isinstance(pyright_config, dict)
|
|
428
|
+
excludelist = pyright_config.get("exclude", [])
|
|
429
|
+
return frozenset(Path(typeshed_dir, item) for item in excludelist)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class PyrightSetting(_NiceReprEnum):
|
|
433
|
+
"""The various possible pyright settings typeshed uses in CI."""
|
|
434
|
+
|
|
435
|
+
STRICT = "All files are tested with the stricter pyright settings in CI."
|
|
436
|
+
NOT_STRICT = "All files are excluded from the stricter pyright settings in CI."
|
|
437
|
+
STRICT_ON_SOME_FILES = (
|
|
438
|
+
"Some files are tested with the stricter pyright settings in CI;"
|
|
439
|
+
" some are excluded."
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def get_pyright_strictness(
|
|
444
|
+
package_name: PackageName, *, typeshed_dir: Path | str
|
|
445
|
+
) -> PyrightSetting:
|
|
446
|
+
"""Get the setting typeshed uses in CI when pyright is run on a certain package.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
package_name: The name of the package to find the stubtest setting for.
|
|
450
|
+
typeshed_dir: A path pointing to a typeshed directory,
|
|
451
|
+
from which to retrieve the stubtest setting.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
A member of the `PyrightSetting` enumeration
|
|
455
|
+
(see the docs on `PyrightSetting` for details).
|
|
456
|
+
"""
|
|
457
|
+
package_directory = _get_package_directory(package_name, typeshed_dir)
|
|
458
|
+
excluded_paths = _get_pyright_strict_excludelist(typeshed_dir)
|
|
459
|
+
if package_directory in excluded_paths:
|
|
460
|
+
return PyrightSetting.NOT_STRICT
|
|
461
|
+
if any(
|
|
462
|
+
excluded_path in package_directory.parents for excluded_path in excluded_paths
|
|
463
|
+
):
|
|
464
|
+
return PyrightSetting.NOT_STRICT
|
|
465
|
+
if any(
|
|
466
|
+
package_directory in excluded_path.parents for excluded_path in excluded_paths
|
|
467
|
+
):
|
|
468
|
+
return PyrightSetting.STRICT_ON_SOME_FILES
|
|
469
|
+
return PyrightSetting.STRICT
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@final
|
|
473
|
+
@attrs.define
|
|
474
|
+
class PackageStats:
|
|
475
|
+
"""Statistics about a single stubs package in typeshed."""
|
|
476
|
+
|
|
477
|
+
package_name: PackageName
|
|
478
|
+
number_of_lines: int
|
|
479
|
+
package_status: PackageStatus
|
|
480
|
+
stubtest_setting: StubtestSetting
|
|
481
|
+
pyright_setting: PyrightSetting
|
|
482
|
+
annotation_stats: AnnotationStats
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def gather_stats_on_package(
|
|
486
|
+
package_name: PackageName,
|
|
487
|
+
*,
|
|
488
|
+
typeshed_dir: Path | str,
|
|
489
|
+
session: aiohttp.ClientSession | None = None,
|
|
490
|
+
) -> PackageStats:
|
|
491
|
+
"""Gather miscellaneous statistics about a single stubs package in typeshed.
|
|
492
|
+
|
|
493
|
+
This function calls `get_package_status()`,
|
|
494
|
+
which makes network requests to PyPI.
|
|
495
|
+
See the docs on `get_package_status()` for details.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
package_name: The name of the package to gather statistics on.
|
|
499
|
+
typeshed_dir: A path pointing to a typeshed directory,
|
|
500
|
+
in which the source code for the stubs package can be found.
|
|
501
|
+
session (optional): An `aiohttp.ClientSession` instance, to be used
|
|
502
|
+
for making a network requests, or `None`. If `None` is provided
|
|
503
|
+
for this argument, a new `aiohttp.ClientSession` instance will be
|
|
504
|
+
created to make the network request.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
An instance of the `PackageStats` class.
|
|
508
|
+
"""
|
|
509
|
+
return PackageStats(
|
|
510
|
+
package_name=package_name,
|
|
511
|
+
number_of_lines=get_package_size(package_name, typeshed_dir=typeshed_dir),
|
|
512
|
+
package_status=await get_package_status(
|
|
513
|
+
package_name, typeshed_dir=typeshed_dir, session=session
|
|
514
|
+
),
|
|
515
|
+
stubtest_setting=get_stubtest_setting(package_name, typeshed_dir=typeshed_dir),
|
|
516
|
+
pyright_setting=get_pyright_strictness(package_name, typeshed_dir=typeshed_dir),
|
|
517
|
+
annotation_stats=gather_annotation_stats_on_package(
|
|
518
|
+
package_name, typeshed_dir=typeshed_dir
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _gather_stats(
|
|
524
|
+
packages: Iterable[str], *, typeshed_dir: Path | str
|
|
525
|
+
) -> Sequence[PackageStats]:
|
|
526
|
+
conn = aiohttp.TCPConnector(limit_per_host=10)
|
|
527
|
+
async with aiohttp.ClientSession(connector=conn) as session:
|
|
528
|
+
tasks = (
|
|
529
|
+
gather_stats_on_package(
|
|
530
|
+
package_name, typeshed_dir=typeshed_dir, session=session
|
|
531
|
+
)
|
|
532
|
+
for package_name in packages
|
|
533
|
+
)
|
|
534
|
+
return await asyncio.gather(*tasks, return_exceptions=True)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def gather_stats(
|
|
538
|
+
packages: Iterable[str] | None = None, *, typeshed_dir: Path | str
|
|
539
|
+
) -> Sequence[PackageStats]:
|
|
540
|
+
"""Concurrently gather statistics on multiple packages.
|
|
541
|
+
|
|
542
|
+
Note: this function calls `asyncio.run()` to start an asyncio event loop.
|
|
543
|
+
It is therefore not suitable to be called from inside functions
|
|
544
|
+
that are themselves called as part of an asyncio event loop.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
packages: An iterable of package names to be analysed, or None.
|
|
548
|
+
If `None`, defaults to all third-party stubs, plus the stubs for the stdlib.
|
|
549
|
+
typeshed_dir: The path to a local clone of typeshed.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
A sequence of `PackageStats` objects. Each `PackageStats` object
|
|
553
|
+
contains information representing an analysis of a certain stubs package
|
|
554
|
+
in typeshed.
|
|
555
|
+
"""
|
|
556
|
+
if packages is None:
|
|
557
|
+
packages = os.listdir(Path(typeshed_dir, "stubs")) + ["stdlib"]
|
|
558
|
+
results = asyncio.run(_gather_stats(packages, typeshed_dir=typeshed_dir))
|
|
559
|
+
for result in results:
|
|
560
|
+
if isinstance(result, BaseException):
|
|
561
|
+
raise result
|
|
562
|
+
return results
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@contextmanager
|
|
566
|
+
def tmpdir_typeshed() -> Iterator[Path]:
|
|
567
|
+
"""Context manager to clone typeshed into a tempdir, and then yield the tempdir."""
|
|
568
|
+
import subprocess
|
|
569
|
+
from tempfile import TemporaryDirectory
|
|
570
|
+
|
|
571
|
+
args = [
|
|
572
|
+
"git",
|
|
573
|
+
"clone",
|
|
574
|
+
"https://github.com/python/typeshed",
|
|
575
|
+
"--depth",
|
|
576
|
+
"1",
|
|
577
|
+
"--quiet",
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
with TemporaryDirectory() as td:
|
|
581
|
+
args.append(td)
|
|
582
|
+
subprocess.run(args, check=True)
|
|
583
|
+
yield Path(td)
|
typeshed_stats/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tools for serializing and deserializing `PackageStats` objects."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from functools import cache
|
|
5
|
+
from operator import attrgetter
|
|
6
|
+
|
|
7
|
+
import attrs
|
|
8
|
+
import cattrs
|
|
9
|
+
|
|
10
|
+
from typeshed_stats.gather import AnnotationStats, PackageStats, _NiceReprEnum
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"stats_from_csv",
|
|
14
|
+
"stats_from_json",
|
|
15
|
+
"stats_to_csv",
|
|
16
|
+
"stats_to_html",
|
|
17
|
+
"stats_to_json",
|
|
18
|
+
"stats_to_markdown",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_CATTRS_CONVERTER = cattrs.Converter()
|
|
22
|
+
_unstructure = _CATTRS_CONVERTER.unstructure
|
|
23
|
+
_structure = _CATTRS_CONVERTER.structure
|
|
24
|
+
|
|
25
|
+
_CATTRS_CONVERTER.register_unstructure_hook(_NiceReprEnum, attrgetter("name"))
|
|
26
|
+
_CATTRS_CONVERTER.register_structure_hook(_NiceReprEnum, lambda d, t: t[d]) # type: ignore[index,no-any-return]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stats_to_json(stats: Sequence[PackageStats]) -> str:
|
|
30
|
+
"""Convert stats on multiple stubs packages to JSON format."""
|
|
31
|
+
import json
|
|
32
|
+
|
|
33
|
+
return json.dumps(_unstructure(stats), indent=2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def stats_from_json(data: str) -> list[PackageStats]:
|
|
37
|
+
"""Load `PackageStats` objects from JSON format."""
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
return _structure(json.loads(data), list[PackageStats])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stats_to_csv(stats: Sequence[PackageStats]) -> str:
|
|
44
|
+
"""Convert stats on multiple stubs packages to csv format."""
|
|
45
|
+
import csv
|
|
46
|
+
import io
|
|
47
|
+
|
|
48
|
+
converted_stats = _unstructure(stats)
|
|
49
|
+
for info in converted_stats:
|
|
50
|
+
info |= info["annotation_stats"]
|
|
51
|
+
del info["annotation_stats"]
|
|
52
|
+
fieldnames = converted_stats[0].keys()
|
|
53
|
+
csvfile = io.StringIO(newline="")
|
|
54
|
+
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
|
55
|
+
writer.writeheader()
|
|
56
|
+
for info in converted_stats:
|
|
57
|
+
writer.writerow(info)
|
|
58
|
+
return csvfile.getvalue()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cache
|
|
62
|
+
def _annotation_stats_fields() -> tuple[str, ...]:
|
|
63
|
+
return tuple(f.name for f in attrs.fields(AnnotationStats))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stats_from_csv(data: str) -> list[PackageStats]:
|
|
67
|
+
"""Load `PackageStats` objects from csv format."""
|
|
68
|
+
import csv
|
|
69
|
+
import io
|
|
70
|
+
|
|
71
|
+
csvfile = io.StringIO(data, newline="")
|
|
72
|
+
stats = list(csv.DictReader(csvfile))
|
|
73
|
+
annotation_stats_fields = _annotation_stats_fields()
|
|
74
|
+
converted_stats = []
|
|
75
|
+
for stat in stats:
|
|
76
|
+
converted_stat, annotation_stats = {}, {}
|
|
77
|
+
for key, val in stat.items():
|
|
78
|
+
if key in annotation_stats_fields:
|
|
79
|
+
annotation_stats[key] = val
|
|
80
|
+
else:
|
|
81
|
+
converted_stat[key] = val
|
|
82
|
+
converted_stat["annotation_stats"] = annotation_stats
|
|
83
|
+
converted_stats.append(converted_stat)
|
|
84
|
+
return _structure(converted_stats, list[PackageStats])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def stats_to_markdown(stats: Sequence[PackageStats]) -> str:
|
|
88
|
+
"""Generate MarkDown describing statistics on multiple stubs packages."""
|
|
89
|
+
import textwrap
|
|
90
|
+
|
|
91
|
+
template = textwrap.dedent(
|
|
92
|
+
"""
|
|
93
|
+
## Stats on typeshed's stubs for `{package_name}`
|
|
94
|
+
|
|
95
|
+
### Number of lines
|
|
96
|
+
{number_of_lines}
|
|
97
|
+
|
|
98
|
+
### Package status: *{package_status.formatted_name}*
|
|
99
|
+
{package_status.value}
|
|
100
|
+
|
|
101
|
+
### Stubtest settings in CI: *{stubtest_setting.formatted_name}*
|
|
102
|
+
{stubtest_setting.value}
|
|
103
|
+
|
|
104
|
+
### Pyright settings in CI: *{pyright_setting.formatted_name}*
|
|
105
|
+
{pyright_setting.value}
|
|
106
|
+
|
|
107
|
+
### Statistics on the annotations in typeshed's stubs for `{package_name}`
|
|
108
|
+
- Parameters (excluding `self`, `cls`, `metacls` and `mcls`):
|
|
109
|
+
- Annotated parameters: {annotated_parameters}
|
|
110
|
+
- Unannotated parameters: {unannotated_parameters}
|
|
111
|
+
- Explicit `Any` parameters: {explicit_Any_parameters}
|
|
112
|
+
- Explicitly `Incomplete` (or partially `Incomplete`) parameters: {explicit_Incomplete_parameters}
|
|
113
|
+
- Returns:
|
|
114
|
+
- Annotated returns: {annotated_returns}
|
|
115
|
+
- Unannotated returns: {unannotated_returns}
|
|
116
|
+
- Explicit `Any` returns: {explicit_Any_returns}
|
|
117
|
+
- Explicitly `Incomplete` (or partially `Incomplete`) returns: {explicit_Incomplete_returns}
|
|
118
|
+
- Variables:
|
|
119
|
+
- Annotated variables: {annotated_variables}
|
|
120
|
+
- Explicit `Any` variables: {explicit_Any_variables}
|
|
121
|
+
- Explicitly `Incomplete` (or partially `Incomplete`) variables: {explicit_Incomplete_variables}
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def format_package(package_stats: PackageStats) -> str:
|
|
126
|
+
package_as_dict = attrs.asdict(package_stats)
|
|
127
|
+
kwargs = package_as_dict | package_as_dict["annotation_stats"]
|
|
128
|
+
del kwargs["annotation_stats"]
|
|
129
|
+
return template.format(**kwargs)
|
|
130
|
+
|
|
131
|
+
return "\n<hr>\n".join(format_package(info) for info in stats)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def stats_to_html(stats: Sequence[PackageStats]) -> str:
|
|
135
|
+
"""Generate HTML describing statistics on multiple stubs packages."""
|
|
136
|
+
import markdown
|
|
137
|
+
|
|
138
|
+
return markdown.markdown(stats_to_markdown(stats))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: typeshed_stats
|
|
3
|
+
Version: 22.11.0
|
|
4
|
+
Summary: Script to gather stats on the status of various typeshed packages
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlexWaygood/typeshed-stats
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/AlexWaygood/typeshed-stats/issues
|
|
7
|
+
Author-email: Alex Waygood <alex.waygood@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: stubs,typeshed,typing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Topic :: Software Development
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: aiohttp[speedups]
|
|
23
|
+
Requires-Dist: attrs>=21.3.0
|
|
24
|
+
Requires-Dist: cattrs
|
|
25
|
+
Requires-Dist: packaging
|
|
26
|
+
Requires-Dist: tomli; python_version < '3.11'
|
|
27
|
+
Provides-Extra: everything
|
|
28
|
+
Requires-Dist: markdown; extra == 'everything'
|
|
29
|
+
Requires-Dist: rich; extra == 'everything'
|
|
30
|
+
Provides-Extra: html
|
|
31
|
+
Requires-Dist: markdown; extra == 'html'
|
|
32
|
+
Provides-Extra: rich
|
|
33
|
+
Requires-Dist: rich; extra == 'rich'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
<div align=center>
|
|
37
|
+
|
|
38
|
+
# typeshed-stats
|
|
39
|
+
|
|
40
|
+
A CLI tool and library to gather stats on typeshed
|
|
41
|
+
|
|
42
|
+
[](https://alexwaygood.github.io/typeshed-stats/)[](https://github.com/AlexWaygood/typeshed-stats/actions/workflows/check.yml)[](https://coveralls.io/github/AlexWaygood/typeshed-stats)
|
|
43
|
+
<br>
|
|
44
|
+
[](http://mypy-lang.org/)[](https://github.com/psf/black)[](https://pycqa.github.io/isort/)[](https://opensource.org/licenses/MIT)
|
|
45
|
+
|
|
46
|
+
<hr>
|
|
47
|
+
<br>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
## What's this project for?
|
|
51
|
+
|
|
52
|
+
This project is for easy gathering of statistics relating to typeshed's stubs.
|
|
53
|
+
|
|
54
|
+
Some examples of things you can do from the command line:
|
|
55
|
+
- Create a `.csv` file with stats on all typeshed stubs: `typeshed-stats --typeshed-dir <PATH_TO_TYPESHED_CLONE> --to-file stats.csv` (the `.csv` file extension will be automatically detected by the script to identify the format required).
|
|
56
|
+
- Pretty-print stats on typeshed stubs for emoji and redis to the terminal, in JSON format: `typeshed-stats --typeshed-dir <PATH_TO_TYPESHED_CLONE> --to-json emoji redis`
|
|
57
|
+
- Generate a MarkDown file detailing stats on typeshed's stubs for protobuf and the stdlib: `typeshed-stats --typeshed-dir <PATH_TO_TYPESHED_CLONE> --to-file stats.md stdlib protobuf`
|
|
58
|
+
|
|
59
|
+
Example usage of the Python-level API:
|
|
60
|
+
```python
|
|
61
|
+
from typeshed_stats.gather import tmpdir_typeshed, gather_stats
|
|
62
|
+
|
|
63
|
+
with tmpdir_typeshed() as typeshed:
|
|
64
|
+
stats = gather_stats(typeshed_dir=typeshed)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How can I use this?
|
|
68
|
+
|
|
69
|
+
1. Run `pip install git+https://github.com/AlexWaygood/typeshed-stats.git#egg=typeshed-stats[everything]` to install the package
|
|
70
|
+
2. Run `typeshed-stats --help` for information about various options
|
|
71
|
+
|
|
72
|
+
## Are there any examples of things this script can produce?
|
|
73
|
+
I'm glad you asked! They're in the `examples/` folder in this repo.
|
|
74
|
+
(These examples are generated using the `regenerate_examples.py` script in the repository root.)
|
|
75
|
+
|
|
76
|
+
## How do I run tests/linters?
|
|
77
|
+
1. Clone the repo and `cd` into it
|
|
78
|
+
2. Create and activate a virtual environment
|
|
79
|
+
3. Run `pip install -r requirements-dev.txt`
|
|
80
|
+
4. Run `pip install -e .[everything]`
|
|
81
|
+
5. Either run the linters/tests individually (see the `.github/workflows` directory for details about what's run in CI) or use the `runtests.py` convenience script to run them all in succession.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
typeshed_stats/__init__.py,sha256=fqeGnjOfi2-Es1ELyM48ionTOBYX2dGySLyaOI5-XEc,398
|
|
2
|
+
typeshed_stats/__main__.py,sha256=4EiLdxGt9zGsjMqqD0fu33kC4DtkGmH7wqQu8HMKwYE,291
|
|
3
|
+
typeshed_stats/_cli.py,sha256=iiNYpQzZiE5d-hSYnnawI0ST_dJOeCcA136kSGuUhN8,10305
|
|
4
|
+
typeshed_stats/gather.py,sha256=niv6E14BjWDO4MjIBEGn4aMpMj0395fT8K1BmgzYqRU,20599
|
|
5
|
+
typeshed_stats/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
typeshed_stats/serialize.py,sha256=FYSVamxt2PoiMjnOgEeMq336bM3bQqBKWjeCY6rvop8,4741
|
|
7
|
+
typeshed_stats-22.11.0.dist-info/METADATA,sha256=3r1WAZKYpdZO7T1rBfH9Xy5l7YPpevhk4OmBAHc-dlY,4371
|
|
8
|
+
typeshed_stats-22.11.0.dist-info/WHEEL,sha256=NaLmgHHW_f9jTvv_wRh9vcK7c7EK9o5fwsIXMOzoGgM,87
|
|
9
|
+
typeshed_stats-22.11.0.dist-info/entry_points.txt,sha256=wzOEKjVv36kiGkT1qzSjE_RV0F9pn7MT33KwCoM7Pvo,60
|
|
10
|
+
typeshed_stats-22.11.0.dist-info/licenses/LICENSE,sha256=2bm9uFabQZ3Ykb_SaSU_uUbAj2-htc6WJQmS_65qD00,1073
|
|
11
|
+
typeshed_stats-22.11.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2018 The Python Packaging Authority
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|