Skip to content

Conversation

@jonathanberthias
Copy link
Contributor

Following #1141, this PR fixes all the errors related to missing or extra function parameters in the stubs! It also fixes some messages related to disjoint_base since stubtest reports them when missing.

Using a script, all the *args, **kwargs parameters introduced by stubgen were replaced with the real parameter names. Parameters with default values are also marked with a default of ....

This makes for a very large PR, but there shouldn't be much to check other than the output of stubtest. To test it out, you can install the version from this branch and any Python Language Server should be able to pick up the names, and provide suggestions when filling in the arguments to a function call:
image

For now, all the parameters and return types are annotated with Incomplete which is equivalent to typing.Any. We just have to replace all of those with the appropriate types - only 2010 to go!

@Joao-Dionisio
Copy link
Member

Ohh my god, @jonathanberthias, how much work was this? Thank you! I'll take a look

@Joao-Dionisio
Copy link
Member

can you please add a changelog entry?

@jonathanberthias
Copy link
Contributor Author

Ohh my god, @jonathanberthias, how much work was this? Thank you! I'll take a look

Not that much in the end, the errors from stubtest are very clear so this script was enough for 95% of changes.

Details import sys from dataclasses import dataclass

import subprocess
import re
from collections import defaultdict

STUBS_FILE = "src/pyscipopt/scip.pyi"

"""
Example error lines from stubtest:
pyscipopt.Benders.bendersfreesub is inconsistent, stub does not have parameter "probnumber"
pyscipopt.Benders.bendersfreesub is inconsistent, runtime does not have *args parameter "args"
pyscipopt.Benders.bendersfreesub is inconsistent, runtime does not have **kwargs parameter "kwargs"
pyscipopt.scip.Prop.propresprop is inconsistent, stub parameter "confvar" has a default value but runtime parameter does not
"""

@DataClass
class ArgInfo:
name: str
annot: str | None = None
prefix: str = '' # '', '*', or '**'
added: bool = False

def get_stubtest_errors() -> list[str]:
"""Run stubtest and return its output lines as a list of strings."""
subprocess.run(["uv", "pip", "install", "."], check=True)
stubtest_cmd = [
sys.executable, "-m", "mypy.stubtest",
"--allowlist", "stubs/allowlist",
"pyscipopt", "--concise"
]
proc = subprocess.run(stubtest_cmd, capture_output=True, text=True)
return proc.stdout.splitlines()

def parse_errors(errors: list[str]):
"""
Parse stubtest errors and collect required parameters for each class.method.
Returns: dict {(class, method): {'missing': list, 'extra': set}}
"""
corrections = defaultdict(lambda: {'missing': [], 'extra': set()})
for line in errors:
try:
if "is inconsistent" not in line:
continue
parts = line.split()
full_name = parts[0]
func_name = full_name.split('.')[-1]
class_name = full_name.split('.')[-2]
# Only process class methods
if not class_name or not class_name[0].isupper():
continue
param = parts[-1].strip('"')
key = (class_name, func_name)
if 'stub does not have parameter' in line:
# Only add if not already present to preserve order
if param not in corrections[key]['missing']:
corrections[key]['missing'].append(param)
elif 'runtime does not have' in line:
corrections[key]['extra'].add(param)
except Exception:
print(f"Warning: Could not parse line: {line}")
raise
return corrections

def update_stub_file(stub_file, corrections):
"""
Update the stub file, replacing method arguments with the correct names and 'Incomplete' type.
"""
with open(stub_file, 'r') as f:
lines = f.readlines()

class_regex = re.compile(r'^class (\w+)\b')
# Match any method, including dunder methods, with any indentation
method_any_args = re.compile(r'^(\s*)def (\w+)\(([^)]*)\)')

current_class = None
for i, line in enumerate(lines):
    class_match = class_regex.match(line)
    if class_match:
        current_class = class_match.group(1)
    method_match = method_any_args.match(line)
    if method_match and current_class:
        indent = method_match.group(1)
        method_name = method_match.group(2)
        arg_str = method_match.group(3)
        key = (current_class, method_name)
        # Exception: do not touch MatrixExpr.sum in the first pass
        if key in corrections and not (current_class == "MatrixExpr" and method_name == "sum"):
            current_args = []
            for a in arg_str.split(','):
                a = a.strip()
                if not a or a == 'self':
                    continue
                # Handle *args and **kwargs
                if a.startswith('**'):
                    name = a[2:].split(':')[0].strip()
                    prefix = '**'
                elif a.startswith('*'):
                    name = a[1:].split(':')[0].strip()
                    prefix = '*'
                else:
                    name = a.split(':')[0].strip()
                    prefix = ''
                if name in corrections[key]['extra']:
                    continue  # Remove if marked as extra
                if ':' in a:
                    annot = a.split(':', 1)[1].strip()
                    current_args.append(ArgInfo(prefix + name, annot, prefix, False))
                else:
                    current_args.append(ArgInfo(prefix + name, None, prefix, False))
            existing_arg_names = [arg.name.lstrip('*') for arg in current_args]
            for p in corrections[key]['missing']:
                if p not in existing_arg_names:
                    current_args.append(ArgInfo(p, 'Incomplete', '', True))
            # Partition arguments
            normal_args = [a for a in current_args if a.prefix == '']
            star_args = [a for a in current_args if a.prefix == '*']
            dstar_kwargs = [a for a in current_args if a.prefix == '**']
            ordered_args = normal_args + star_args + dstar_kwargs

            def format_arg(arg: ArgInfo) -> str:
                if arg.prefix:
                    base = arg.prefix + arg.name.lstrip('*')
                    # Never add default for *args/**kwargs
                    return f"{base}: {arg.annot}" if arg.annot else f"{base}: Incomplete"
                else:
                    if arg.added:
                        return f"{arg.name}: {arg.annot} = ..." if arg.annot else f"{arg.name}: Incomplete = ..."
                    else:
                        return f"{arg.name}: {arg.annot}" if arg.annot else f"{arg.name}: Incomplete"
            param_str = ', '.join(['self'] + [format_arg(arg) for arg in ordered_args])
            return_type = "Incomplete" if method_name != "__init__" else "None"
            new_line = f"{indent}def {method_name}({param_str}) -> {return_type}: ...\n"
            lines[i] = new_line
with open(stub_file, 'w') as f:
    f.writelines(lines)

def remove_unwanted_defaults(stub_file: str, errors: list[str]):
"""
For each error about a stub parameter having a default value but runtime does not, remove the default from the stub.
"""
# Collect (class, method, param) to fix
to_fix = set()
for line in errors:
if 'stub parameter' in line and 'has a default value but runtime parameter does not' in line:
parts = line.split()
# Example: pyscipopt.scip.Prop.propresprop is inconsistent, stub parameter "confvar" has a default value but runtime parameter does not
full_name = parts[0]
func_name = full_name.split('.')[-1]
class_name = full_name.split('.')[-2]
param = parts[5].strip('"')
to_fix.add((class_name, func_name, param))

print(to_fix)

with open(stub_file, 'r') as f:
    lines = f.readlines()

class_regex = re.compile(r'^class (\w+)\b')
method_any_args = re.compile(r'^(\s*)def (\w+)\(([^)]*)\)')

current_class = None
for i, line in enumerate(lines):
    class_match = class_regex.match(line)
    if class_match:
        current_class = class_match.group(1)
    method_match = method_any_args.match(line)
    if method_match and current_class:
        method_name = method_match.group(2)
        arg_str = method_match.group(3)
        # Only process if any param in to_fix matches this method
        fixes = [param for (cls, meth, param) in to_fix if cls == current_class and meth == method_name]
        if fixes:
            print("Fixing defaults in", current_class, method_name, fixes)
            args = []
            for a in arg_str.split(','):
                a = a.strip()
                if not a or a == 'self':
                    args.append(a)
                    continue
                # Remove default for flagged params robustly
                name = a.split(':')[0].split('=')[0].strip()
                if name in fixes:
                    # Remove ' = ...' (with any whitespace), preserving annotation
                    # Match patterns like: name: type = ..., name = ...
                    a = re.sub(r'\s*=\s*\.\.\.$', '', a)
                    args.append(a.strip())
                else:
                    args.append(a)
            param_str = ', '.join(args)
            return_type = "Incomplete" if method_name != "__init__" else "None"
            indent = method_match.group(1)
            new_line = f"{indent}def {method_name}({param_str}) -> {return_type}: ...\n"
            lines[i] = new_line
with open(stub_file, 'w') as f:
    f.writelines(lines)

if name == "main":
errors = get_stubtest_errors()
print(f"Found {len(errors)} stubtest errors.")
corrections = parse_errors(errors)
update_stub_file(STUBS_FILE, corrections)
# Second pass: rerun stubtest and remove unwanted defaults
errors2 = get_stubtest_errors()
print(f"Found {len(errors2)} errors in the second pass")
remove_unwanted_defaults(STUBS_FILE, errors2)

can you please add a changelog entry?

Done! Sorry for forgetting each time 😅

@codecov
Copy link

codecov bot commented Jan 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.11%. Comparing base (5ceac82) to head (b4d9ea5).
⚠️ Report is 5 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1145      +/-   ##
==========================================
+ Coverage   55.07%   55.11%   +0.03%     
==========================================
  Files          24       24              
  Lines        5420     5438      +18     
==========================================
+ Hits         2985     2997      +12     
- Misses       2435     2441       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Joao-Dionisio Joao-Dionisio merged commit 2645c4d into scipopt:master Jan 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants