Skip to content

Commit cbef45c

Browse files
committed
feat(typeshed): private protocol X for module-internal protocols
1 parent a4edec4 commit cbef45c

67 files changed

Lines changed: 976 additions & 583 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/by_typeshed_patch/src/lib.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ pub fn all_patches() -> Vec<Box<dyn Patch>> {
6363
/// each post-patch is applied on its own re-parse so two patches never have to
6464
/// coordinate disjoint edits; they run in declared order
6565
///
66-
/// `root` is the typeshed `stdlib/` directory; a patch whose decision depends on
67-
/// the rest of the tree (currently only `private_type_aliases`) scans it here,
68-
/// once, before any file is rewritten
66+
/// `root` is the typeshed `stdlib/` directory; the patches whose decision
67+
/// depends on the rest of the tree (`private_type_aliases` and
68+
/// `private_protocols`) share one scan of it, taken here before any file is
69+
/// rewritten
6970
pub fn all_post_patches(root: &Path) -> Vec<Box<dyn Patch>> {
71+
let private_names = patches::private_names::scan(root);
7072
vec![
7173
// widens invariant list/set/dict output typevars over the explicit-variance
7274
// form; runs first so later idiom patches (e.g. `any_to_dynamic`) still see
@@ -93,8 +95,13 @@ pub fn all_post_patches(root: &Path) -> Vec<Box<dyn Patch>> {
9395
Box::new(patches::type_aliases::TypeAliasStatements),
9496
// runs after the alias statements exist, so an alias promoted from
9597
// `_X: TypeAlias = …` this pass is a candidate on the next sync
96-
Box::new(patches::private_type_aliases::PrivateTypeAliases::scan(
97-
root,
98+
Box::new(patches::private_type_aliases::PrivateTypeAliases::new(
99+
private_names.aliases,
100+
)),
101+
// `protocol_keyword` above turned `class _X(Protocol)` into
102+
// `protocol _X`, which is the form the scan looked for
103+
Box::new(patches::private_protocols::PrivateProtocols::new(
104+
private_names.protocols,
98105
)),
99106
Box::new(patches::homogeneous_tuple::HomogeneousTuple),
100107
Box::new(patches::any_to_dynamic::AnyToDynamic),

crates/by_typeshed_patch/src/patches/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub mod init_shorthand;
1818
pub mod literal_unwrap;
1919
pub mod mapping;
2020
pub mod output_widening;
21+
pub(crate) mod private_names;
22+
pub mod private_protocols;
2123
pub mod private_type_aliases;
2224
pub mod property_to_let;
2325
pub mod protocol_keyword;
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
//! whole-tree scan shared by the `_X` → `private X` conversions
2+
//!
3+
//! typeshed spells a module-internal declaration with a leading underscore.
4+
//! basedpython has a keyword for that, and it reads better: the underscore is
5+
//! applied by the lowering rather than written by hand, and importing the name
6+
//! from another module becomes a `private-import` error instead of merely a
7+
//! convention someone can ignore.
8+
//!
9+
//! because `private X` *binds* `X`, converting a declaration renames every
10+
//! reference to it. that is only safe when every reference lives in the
11+
//! declaring file, which the per-file [`Patch`](crate::Patch) contract cannot
12+
//! see, so the decision is taken here, once, over the whole tree. a declaration
13+
//! converts only when
14+
//!
15+
//! - every other stub mentioning the identifier `_X` declares its own `_X` (so
16+
//! the mention resolves locally there and is not an import of this one), and
17+
//! - the stripped name `X` occurs nowhere in the declaring stub (so the rename
18+
//! cannot capture an unrelated symbol)
19+
//!
20+
//! declarations imported across modules are therefore left alone: rewriting one
21+
//! would need a coordinated edit in the importing stub, which a per-file patch
22+
//! cannot express
23+
24+
use std::collections::{BTreeMap, BTreeSet};
25+
use std::path::{Path, PathBuf};
26+
27+
use ruff_python_ast::{Decorator, Expr, ModModule, PySourceType, Stmt, StmtClassDef};
28+
use ruff_python_parser::{Parsed, parse_unchecked_source};
29+
use ruff_text_size::Ranged;
30+
use walkdir::WalkDir;
31+
32+
use crate::Edit;
33+
34+
/// stub path (relative to the stdlib root) → the names in it that are safe to
35+
/// convert
36+
pub(crate) type Convertible = BTreeMap<PathBuf, BTreeSet<String>>;
37+
38+
/// the convertible declarations of each kind, keyed by stub
39+
pub(crate) struct PrivateNames {
40+
pub(crate) aliases: Convertible,
41+
pub(crate) protocols: Convertible,
42+
}
43+
44+
/// scan the whole stub tree to decide which underscore-prefixed declarations
45+
/// are referenced only by their own module
46+
pub(crate) fn scan(root: &Path) -> PrivateNames {
47+
let mut sources: Vec<(PathBuf, String)> = Vec::new();
48+
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
49+
let path = entry.path();
50+
if path.extension().is_none_or(|e| e != "byi") {
51+
continue;
52+
}
53+
let Ok(source) = std::fs::read_to_string(path) else {
54+
continue;
55+
};
56+
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
57+
sources.push((rel, source));
58+
}
59+
60+
// identifier → the stubs mentioning it, and stub → what it declares. the
61+
// leak check only asks whether a mention binds locally, so `declared` pools
62+
// both kinds while `candidates` keeps them apart for the two patches
63+
let mut mentions: BTreeMap<String, BTreeSet<PathBuf>> = BTreeMap::new();
64+
let mut declared: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
65+
let mut candidates: BTreeMap<PathBuf, [BTreeSet<String>; 2]> = BTreeMap::new();
66+
for (rel, source) in &sources {
67+
for name in identifiers(source) {
68+
mentions.entry(name).or_default().insert(rel.clone());
69+
}
70+
let parsed = parse_unchecked_source(source, PySourceType::BasedPythonStub);
71+
let kinds = [alias_names(&parsed), protocol_names(&parsed, source)];
72+
declared.insert(rel.clone(), kinds.iter().flatten().cloned().collect());
73+
candidates.insert(rel.clone(), kinds);
74+
}
75+
76+
let mut out = PrivateNames {
77+
aliases: Convertible::new(),
78+
protocols: Convertible::new(),
79+
};
80+
for (rel, source) in &sources {
81+
let idents = identifiers(source);
82+
let Some(kinds) = candidates.get(rel) else {
83+
continue;
84+
};
85+
for (names, dest) in kinds.iter().zip([&mut out.aliases, &mut out.protocols]) {
86+
let safe: BTreeSet<String> = names
87+
.iter()
88+
.filter(|name| {
89+
// a mention in a stub that declares its own name of the
90+
// same spelling resolves there, so it is not a reference to
91+
// this one
92+
let leaks = mentions.get(*name).is_some_and(|files| {
93+
files.iter().any(|other| {
94+
other != rel
95+
&& !declared
96+
.get(other)
97+
.is_some_and(|names| names.contains(*name))
98+
})
99+
});
100+
// the rename must not collide with a name the stub already uses
101+
!leaks && !idents.contains(&name[1..])
102+
})
103+
.cloned()
104+
.collect();
105+
if !safe.is_empty() {
106+
dest.insert(rel.clone(), safe);
107+
}
108+
}
109+
}
110+
111+
out
112+
}
113+
114+
/// an identifier typeshed hides by convention: exactly one leading underscore,
115+
/// and something after it
116+
pub(crate) fn is_underscore_private(name: &str) -> bool {
117+
name.len() > 1 && name.starts_with('_') && !name.starts_with("__")
118+
}
119+
120+
/// module-level `type _X = …` alias names that are not already `private`
121+
fn alias_names(parsed: &Parsed<ModModule>) -> BTreeSet<String> {
122+
let mut names = BTreeSet::new();
123+
for stmt in &parsed.syntax().body {
124+
if let Stmt::TypeAlias(alias) = stmt
125+
&& !alias.is_private
126+
&& let Expr::Name(name) = alias.name.as_ref()
127+
&& is_underscore_private(&name.id)
128+
{
129+
names.insert(name.id.to_string());
130+
}
131+
}
132+
names
133+
}
134+
135+
/// `protocol _X` declaration names that are not already `private`
136+
fn protocol_names(parsed: &Parsed<ModModule>, source: &str) -> BTreeSet<String> {
137+
let mut names = BTreeSet::new();
138+
each_private_protocol(&parsed.syntax().body, source, &mut |class, _| {
139+
names.insert(class.name.to_string());
140+
});
141+
names
142+
}
143+
144+
/// visit every `protocol _X` declaration that is not already `private`, along
145+
/// with the offset of its `protocol` keyword.
146+
///
147+
/// a class body is not entered: `private` on a nested class name-mangles rather
148+
/// than renames, so only module-level protocols are candidates. a version guard
149+
/// or a `try` block is entered, since a protocol declared there still binds at
150+
/// module level
151+
pub(crate) fn each_private_protocol<'a>(
152+
body: &'a [Stmt],
153+
source: &str,
154+
f: &mut impl FnMut(&'a StmtClassDef, usize),
155+
) {
156+
for stmt in body {
157+
match stmt {
158+
Stmt::ClassDef(class) => {
159+
if is_underscore_private(&class.name)
160+
&& !has_marker(class, source, "private")
161+
&& let Some(marker) = marker(class, source, "protocol_class")
162+
{
163+
f(class, marker.range().start().to_usize());
164+
}
165+
}
166+
Stmt::If(node) => {
167+
each_private_protocol(&node.body, source, f);
168+
for clause in &node.elif_else_clauses {
169+
each_private_protocol(&clause.body, source, f);
170+
}
171+
}
172+
Stmt::Try(node) => {
173+
each_private_protocol(&node.body, source, f);
174+
for handler in &node.handlers {
175+
let ruff_python_ast::ExceptHandler::ExceptHandler(handler) = handler;
176+
each_private_protocol(&handler.body, source, f);
177+
}
178+
each_private_protocol(&node.orelse, source, f);
179+
each_private_protocol(&node.finalbody, source, f);
180+
}
181+
Stmt::With(node) => each_private_protocol(&node.body, source, f),
182+
_ => {}
183+
}
184+
}
185+
}
186+
187+
/// the synthetic modifier decorator named `name`, if the class carries it.
188+
///
189+
/// the parser models a modifier keyword as a decorator whose source range does
190+
/// not start with `@`; a real `@protocol_class` decorator is an ordinary
191+
/// decorator and must not be mistaken for the modifier
192+
fn marker<'a>(class: &'a StmtClassDef, source: &str, name: &str) -> Option<&'a Decorator> {
193+
class.decorator_list.iter().find(|decorator| {
194+
matches!(&decorator.expression, Expr::Name(id) if id.id.as_str() == name)
195+
&& source
196+
.as_bytes()
197+
.get(decorator.range().start().to_usize())
198+
.copied()
199+
!= Some(b'@')
200+
})
201+
}
202+
203+
fn has_marker(class: &StmtClassDef, source: &str, name: &str) -> bool {
204+
marker(class, source, name).is_some()
205+
}
206+
207+
/// every identifier-shaped token in `source`, including ones inside strings and
208+
/// comments — a rename has to consider forward references and `__all__` entries
209+
pub(crate) fn identifiers(source: &str) -> BTreeSet<String> {
210+
identifier_spans(source)
211+
.into_iter()
212+
.map(|(_, ident)| ident.to_string())
213+
.collect()
214+
}
215+
216+
/// `(offset, text)` for every identifier-shaped token in `source`
217+
pub(crate) fn identifier_spans(source: &str) -> Vec<(usize, &str)> {
218+
let mut out = Vec::new();
219+
let bytes = source.as_bytes();
220+
let mut start = None;
221+
for (i, &b) in bytes.iter().enumerate() {
222+
let is_start = b.is_ascii_alphabetic() || b == b'_';
223+
let is_continue = is_start || b.is_ascii_digit();
224+
match (start, is_continue) {
225+
(None, _) if is_start => start = Some(i),
226+
(Some(s), false) => {
227+
out.push((s, &source[s..i]));
228+
start = None;
229+
}
230+
_ => {}
231+
}
232+
}
233+
if let Some(s) = start {
234+
out.push((s, &source[s..]));
235+
}
236+
out
237+
}
238+
239+
/// deletion edits stripping the leading underscore from every occurrence of a
240+
/// converted name, wherever it sits — a string annotation and a doc comment are
241+
/// as much a reference as an expression
242+
pub(crate) fn strip_underscore_edits(source: &str, converted: &BTreeSet<&str>) -> Vec<Edit> {
243+
identifier_spans(source)
244+
.into_iter()
245+
.filter(|(_, ident)| converted.contains(ident))
246+
.map(|(start, _)| Edit {
247+
start,
248+
end: start + 1,
249+
replacement: String::new(),
250+
})
251+
.collect()
252+
}
253+
254+
#[cfg(test)]
255+
mod tests {
256+
use super::*;
257+
258+
fn write(dir: &Path, name: &str, source: &str) {
259+
std::fs::write(dir.join(name), source).expect("write stub");
260+
}
261+
262+
#[test]
263+
fn skips_names_referenced_by_another_stub() {
264+
let dir = tempfile::tempdir().expect("temp dir");
265+
write(
266+
dir.path(),
267+
"a.byi",
268+
"type _Shared = str\ntype _Local = int\nprotocol _Reader:\n def read(self) -> str: ...\nprotocol _Writer:\n def write(self) -> None: ...\n",
269+
);
270+
write(
271+
dir.path(),
272+
"b.byi",
273+
"from a import _Shared, _Reader\n\ndef f(x: _Shared, y: _Reader) -> None: ...\n",
274+
);
275+
276+
let scan = scan(dir.path());
277+
let aliases = scan.aliases.get(Path::new("a.byi")).expect("aliases");
278+
assert!(aliases.contains("_Local"));
279+
assert!(!aliases.contains("_Shared"));
280+
let protocols = scan.protocols.get(Path::new("a.byi")).expect("protocols");
281+
assert!(protocols.contains("_Writer"));
282+
assert!(!protocols.contains("_Reader"));
283+
}
284+
285+
#[test]
286+
fn allows_the_same_name_declared_in_two_stubs() {
287+
// each stub binds its own `_Address`, so neither mention is a reference
288+
// to the other and both convert independently
289+
let dir = tempfile::tempdir().expect("temp dir");
290+
write(dir.path(), "a.byi", "type _Address = str\n");
291+
write(dir.path(), "b.byi", "protocol _Address: ...\n");
292+
293+
let scan = scan(dir.path());
294+
assert!(
295+
scan.aliases
296+
.get(Path::new("a.byi"))
297+
.is_some_and(|names| names.contains("_Address"))
298+
);
299+
assert!(
300+
scan.protocols
301+
.get(Path::new("b.byi"))
302+
.is_some_and(|names| names.contains("_Address"))
303+
);
304+
}
305+
306+
#[test]
307+
fn skips_names_whose_stripped_spelling_is_taken() {
308+
let dir = tempfile::tempdir().expect("temp dir");
309+
write(
310+
dir.path(),
311+
"a.byi",
312+
"type _Socket = int\nprotocol _Reader: ...\n\nclass Socket: ...\nclass Reader: ...\n",
313+
);
314+
315+
let scan = scan(dir.path());
316+
assert!(!scan.aliases.contains_key(Path::new("a.byi")));
317+
assert!(!scan.protocols.contains_key(Path::new("a.byi")));
318+
}
319+
320+
#[test]
321+
fn skips_protocols_nested_in_a_class_and_already_private_ones() {
322+
let dir = tempfile::tempdir().expect("temp dir");
323+
write(
324+
dir.path(),
325+
"a.byi",
326+
"private protocol _Done: ...\n\nclass Outer:\n protocol _Inner: ...\n",
327+
);
328+
329+
let scan = scan(dir.path());
330+
assert!(!scan.protocols.contains_key(Path::new("a.byi")));
331+
}
332+
333+
#[test]
334+
fn finds_protocols_under_a_version_guard() {
335+
let dir = tempfile::tempdir().expect("temp dir");
336+
write(
337+
dir.path(),
338+
"a.byi",
339+
"import sys\n\nif sys.version_info >= (3, 12):\n protocol _Guarded: ...\n",
340+
);
341+
342+
let scan = scan(dir.path());
343+
assert!(
344+
scan.protocols
345+
.get(Path::new("a.byi"))
346+
.is_some_and(|names| names.contains("_Guarded"))
347+
);
348+
}
349+
350+
#[test]
351+
fn dunder_names_are_not_candidates() {
352+
assert!(is_underscore_private("_X"));
353+
assert!(!is_underscore_private("_"));
354+
assert!(!is_underscore_private("__X"));
355+
assert!(!is_underscore_private("X"));
356+
}
357+
}

0 commit comments

Comments
 (0)