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.
@@ -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)
@@ -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)
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
+ [![website](https://img.shields.io/website?down_color=red&down_message=Offline&style=for-the-badge&up_color=green&up_message=Running&url=https%3A%2F%2Falexwaygood.github.io%2Ftypeshed-stats%2F)](https://alexwaygood.github.io/typeshed-stats/)[![build status](https://img.shields.io/github/workflow/status/AlexWaygood/typeshed-stats/tests/main?label=Tests&style=for-the-badge)](https://github.com/AlexWaygood/typeshed-stats/actions/workflows/check.yml)[![Coveralls](https://img.shields.io/coveralls/github/AlexWaygood/typeshed-stats?style=for-the-badge)](https://coveralls.io/github/AlexWaygood/typeshed-stats)
43
+ <br>
44
+ [![Checked with mypy](https://img.shields.io/badge/mypy-checked-blue?style=for-the-badge)](http://mypy-lang.org/)[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge)](https://github.com/psf/black)[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=for-the-badge&labelColor=ef8336)](https://pycqa.github.io/isort/)[![license](https://img.shields.io/github/license/AlexWaygood/typeshed-stats?style=for-the-badge)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.11.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ typeshed-stats = typeshed_stats._cli:main
@@ -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.