• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 22740642519

05 Mar 2026 11:00PM UTC coverage: 52.677% (-40.3%) from 92.931%
22740642519

Pull #23157

github

web-flow
Merge 2aa18e6d4 into f0030f5e7
Pull Request #23157: [pants ng] Partition source files by config.

31678 of 60136 relevant lines covered (52.68%)

0.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

32.61
/src/python/pants/backend/python/typecheck/mypy/rules_integration_test.py
1
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import os.path
1✔
7
import re
1✔
8
from hashlib import sha256
1✔
9
from pathlib import Path
1✔
10
from textwrap import dedent
1✔
11

12
import pytest
1✔
13

14
from pants.backend.codegen.protobuf.python.python_protobuf_subsystem import (
1✔
15
    rules as protobuf_subsystem_rules,
16
)
17
from pants.backend.codegen.protobuf.python.rules import rules as protobuf_rules
1✔
18
from pants.backend.codegen.protobuf.target_types import ProtobufSourceTarget
1✔
19
from pants.backend.python import target_types_rules
1✔
20
from pants.backend.python.dependency_inference import rules as dependency_inference_rules
1✔
21
from pants.backend.python.target_types import (
1✔
22
    PythonRequirementTarget,
23
    PythonSourcesGeneratorTarget,
24
    PythonSourceTarget,
25
)
26
from pants.backend.python.typecheck.mypy.rules import (
1✔
27
    MyPyPartition,
28
    MyPyPartitions,
29
    MyPyRequest,
30
)
31
from pants.backend.python.typecheck.mypy.rules import rules as mypy_rules
1✔
32
from pants.backend.python.typecheck.mypy.subsystem import MyPy, MyPyFieldSet
1✔
33
from pants.backend.python.typecheck.mypy.subsystem import rules as mypy_subystem_rules
1✔
34
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
1✔
35
from pants.core.goals.check import CheckResult, CheckResults
1✔
36
from pants.core.util_rules import config_files
1✔
37
from pants.engine.addresses import Address
1✔
38
from pants.engine.fs import EMPTY_DIGEST, DigestContents
1✔
39
from pants.engine.rules import QueryRule
1✔
40
from pants.engine.target import Target
1✔
41
from pants.testutil.python_interpreter_selection import (
1✔
42
    all_major_minor_python_versions,
43
    skip_unless_all_pythons_present,
44
    skip_unless_python38_present,
45
    skip_unless_python39_present,
46
)
47
from pants.testutil.python_rule_runner import PythonRuleRunner
1✔
48
from pants.util.resources import read_sibling_resource
1✔
49

50

51
@pytest.fixture
1✔
52
def rule_runner() -> PythonRuleRunner:
1✔
53
    return PythonRuleRunner(
1✔
54
        rules=[
55
            *mypy_rules(),
56
            *mypy_subystem_rules(),
57
            *dependency_inference_rules.rules(),  # Used for import inference.
58
            *config_files.rules(),
59
            *target_types_rules.rules(),
60
            QueryRule(CheckResults, (MyPyRequest,)),
61
            QueryRule(MyPyPartitions, (MyPyRequest,)),
62
        ],
63
        target_types=[PythonSourcesGeneratorTarget, PythonRequirementTarget, PythonSourceTarget],
64
    )
65

66

67
PACKAGE = "src/py/project"
1✔
68
GOOD_FILE = dedent(
1✔
69
    """\
70
    def add(x: int, y: int) -> int:
71
        return x + y
72

73
    result = add(3, 3)
74
    """
75
)
76
BAD_FILE = dedent(
1✔
77
    """\
78
    def add(x: int, y: int) -> int:
79
        return x + y
80

81
    result = add(2.0, 3.0)
82
    """
83
)
84
# This will fail if `--disallow-any-expr` is configured.
85
NEEDS_CONFIG_FILE = dedent(
1✔
86
    """\
87
    from typing import Any, cast
88

89
    x = cast(Any, "hello")
90
    """
91
)
92

93

94
def run_mypy(
1✔
95
    rule_runner: PythonRuleRunner, targets: list[Target], *, extra_args: list[str] | None = None
96
) -> tuple[CheckResult, ...]:
97
    rule_runner.set_options(extra_args or (), env_inherit={"PATH", "PYENV_ROOT", "HOME"})
1✔
98
    result = rule_runner.request(
1✔
99
        CheckResults, [MyPyRequest(MyPyFieldSet.create(tgt) for tgt in targets)]
100
    )
101
    return result.results
1✔
102

103

104
def assert_success(
1✔
105
    rule_runner: PythonRuleRunner, target: Target, *, extra_args: list[str] | None = None
106
) -> None:
107
    result = run_mypy(rule_runner, [target], extra_args=extra_args)
1✔
108
    assert len(result) == 1
1✔
109
    assert result[0].exit_code == 0
1✔
110
    assert "Success: no issues found" in result[0].stdout.strip()
1✔
111
    assert result[0].report == EMPTY_DIGEST
1✔
112

113

114
@pytest.mark.platform_specific_behavior
1✔
115
@pytest.mark.parametrize(
1✔
116
    "major_minor_interpreter",
117
    all_major_minor_python_versions(MyPy.default_interpreter_constraints),
118
)
119
def test_passing(rule_runner: PythonRuleRunner, major_minor_interpreter: str) -> None:
1✔
120
    rule_runner.write_files({f"{PACKAGE}/f.py": GOOD_FILE, f"{PACKAGE}/BUILD": "python_sources()"})
1✔
121
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
1✔
122
    assert_success(
1✔
123
        rule_runner,
124
        tgt,
125
        extra_args=[f"--mypy-interpreter-constraints=['=={major_minor_interpreter}.*']"],
126
    )
127

128

129
def test_failing(rule_runner: PythonRuleRunner) -> None:
1✔
130
    rule_runner.write_files({f"{PACKAGE}/f.py": BAD_FILE, f"{PACKAGE}/BUILD": "python_sources()"})
×
131
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
132
    result = run_mypy(rule_runner, [tgt])
×
133
    assert len(result) == 1
×
134
    assert result[0].exit_code == 1
×
135
    assert f"{PACKAGE}/f.py:4" in result[0].stdout
×
136
    assert result[0].report == EMPTY_DIGEST
×
137

138

139
def test_multiple_targets(rule_runner: PythonRuleRunner) -> None:
1✔
140
    rule_runner.write_files(
×
141
        {
142
            f"{PACKAGE}/good.py": GOOD_FILE,
143
            f"{PACKAGE}/bad.py": BAD_FILE,
144
            f"{PACKAGE}/BUILD": "python_sources()",
145
        }
146
    )
147
    tgts = [
×
148
        rule_runner.get_target(Address(PACKAGE, relative_file_path="good.py")),
149
        rule_runner.get_target(Address(PACKAGE, relative_file_path="bad.py")),
150
    ]
151
    result = run_mypy(rule_runner, tgts)
×
152
    assert len(result) == 1
×
153
    assert result[0].exit_code == 1
×
154
    assert f"{PACKAGE}/good.py" not in result[0].stdout
×
155
    assert f"{PACKAGE}/bad.py:4" in result[0].stdout
×
156
    assert "checked 2 source files" in result[0].stdout
×
157
    assert result[0].report == EMPTY_DIGEST
×
158

159

160
@pytest.mark.parametrize(
1✔
161
    "config_path,extra_args",
162
    ([".mypy.ini", []], ["custom_config.ini", ["--mypy-config=custom_config.ini"]]),
163
)
164
def test_config_file(
1✔
165
    rule_runner: PythonRuleRunner, config_path: str, extra_args: list[str]
166
) -> None:
167
    rule_runner.write_files(
×
168
        {
169
            f"{PACKAGE}/f.py": NEEDS_CONFIG_FILE,
170
            f"{PACKAGE}/BUILD": "python_sources()",
171
            config_path: "[mypy]\ndisallow_any_expr = True\n",
172
        }
173
    )
174
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
175
    result = run_mypy(rule_runner, [tgt], extra_args=extra_args)
×
176
    assert len(result) == 1
×
177
    assert result[0].exit_code == 1
×
178
    assert f"{PACKAGE}/f.py:3" in result[0].stdout
×
179

180

181
def test_passthrough_args(rule_runner: PythonRuleRunner) -> None:
1✔
182
    rule_runner.write_files(
×
183
        {f"{PACKAGE}/f.py": NEEDS_CONFIG_FILE, f"{PACKAGE}/BUILD": "python_sources()"}
184
    )
185
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
186
    result = run_mypy(rule_runner, [tgt], extra_args=["--mypy-args='--disallow-any-expr'"])
×
187
    assert len(result) == 1
×
188
    assert result[0].exit_code == 1
×
189
    assert f"{PACKAGE}/f.py:3" in result[0].stdout
×
190

191

192
def test_skip(rule_runner: PythonRuleRunner) -> None:
1✔
193
    rule_runner.write_files({f"{PACKAGE}/f.py": BAD_FILE, f"{PACKAGE}/BUILD": "python_sources()"})
×
194
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
195
    result = run_mypy(rule_runner, [tgt], extra_args=["--mypy-skip"])
×
196
    assert not result
×
197

198

199
def test_report_file(rule_runner: PythonRuleRunner) -> None:
1✔
200
    rule_runner.write_files({f"{PACKAGE}/f.py": GOOD_FILE, f"{PACKAGE}/BUILD": "python_sources()"})
×
201
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
202
    result = run_mypy(rule_runner, [tgt], extra_args=["--mypy-args='--linecount-report=reports'"])
×
203
    assert len(result) == 1
×
204
    assert result[0].exit_code == 0
×
205
    assert "Success: no issues found" in result[0].stdout.strip()
×
206
    report_files = rule_runner.request(DigestContents, [result[0].report])
×
207
    assert len(report_files) == 1
×
208
    assert "4       4      1      1 f" in report_files[0].content.decode()
×
209

210

211
def test_thirdparty_dependency(rule_runner: PythonRuleRunner) -> None:
1✔
212
    rule_runner.write_files(
×
213
        {
214
            "BUILD": (
215
                "python_requirement(name='more-itertools', requirements=['more-itertools==8.4.0'])"
216
            ),
217
            f"{PACKAGE}/f.py": dedent(
218
                """\
219
                from more_itertools import flatten
220

221
                assert flatten(42) == [4, 2]
222
                """
223
            ),
224
            f"{PACKAGE}/BUILD": "python_sources()",
225
        }
226
    )
227
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
228
    result = run_mypy(rule_runner, [tgt])
×
229
    assert len(result) == 1
×
230
    assert result[0].exit_code == 1
×
231
    assert f"{PACKAGE}/f.py:3" in result[0].stdout
×
232

233

234
def test_thirdparty_plugin(rule_runner: PythonRuleRunner) -> None:
1✔
235
    rule_runner.write_files(
×
236
        {
237
            "mypy.lock": read_sibling_resource(__name__, "mypy_with_django_stubs.lock"),
238
            f"{PACKAGE}/__init__.py": "",
239
            f"{PACKAGE}/settings.py": dedent(
240
                """\
241
                from django.urls import URLPattern
242

243
                DEBUG = True
244
                DEFAULT_FROM_EMAIL = "webmaster@example.com"
245
                SECRET_KEY = "not so secret"
246
                MY_SETTING = URLPattern(pattern="foo", callback=lambda: None)
247
                """
248
            ),
249
            f"{PACKAGE}/app.py": dedent(
250
                """\
251
                from django.utils import text
252

253
                assert "forty-two" == text.slugify("forty two")
254
                assert "42" == text.slugify(42)
255
                """
256
            ),
257
            f"{PACKAGE}/BUILD": dedent(
258
                """\
259
                python_sources(interpreter_constraints=['>=3.11'])
260

261
                python_requirement(
262
                    name="reqs", requirements=["django==3.2.19", "django-stubs==1.8.0"]
263
                )
264
                """
265
            ),
266
            "mypy.ini": dedent(
267
                """\
268
                [mypy]
269
                plugins =
270
                    mypy_django_plugin.main
271

272
                [mypy.plugins.django-stubs]
273
                django_settings_module = project.settings
274
                """
275
            ),
276
        }
277
    )
278
    result = run_mypy(
×
279
        rule_runner,
280
        [
281
            rule_runner.get_target(Address(PACKAGE, relative_file_path="app.py")),
282
            rule_runner.get_target(Address(PACKAGE, relative_file_path="settings.py")),
283
        ],
284
        extra_args=[
285
            "--source-root-patterns=['src/py']",
286
            "--python-resolves={'mypy':'mypy.lock'}",
287
            "--mypy-install-from-resolve=mypy",
288
        ],
289
    )
290
    assert len(result) == 1
×
291
    assert result[0].exit_code == 1
×
292
    assert f"{PACKAGE}/app.py:4" in result[0].stdout
×
293

294

295
def test_transitive_dependencies(rule_runner: PythonRuleRunner) -> None:
1✔
296
    rule_runner.write_files(
×
297
        {
298
            f"{PACKAGE}/util/__init__.py": "",
299
            f"{PACKAGE}/util/lib.py": dedent(
300
                """\
301
                def capitalize(v: str) -> str:
302
                    return v.capitalize()
303
                """
304
            ),
305
            f"{PACKAGE}/util/BUILD": "python_sources()",
306
            f"{PACKAGE}/math/__init__.py": "",
307
            f"{PACKAGE}/math/add.py": dedent(
308
                """\
309
                from project.util.lib import capitalize
310

311
                def add(x: int, y: int) -> str:
312
                    sum = x + y
313
                    return capitalize(sum)  # This is the wrong type.
314
                """
315
            ),
316
            f"{PACKAGE}/math/BUILD": "python_sources()",
317
            f"{PACKAGE}/__init__.py": "",
318
            f"{PACKAGE}/app.py": dedent(
319
                """\
320
                from project.math.add import add
321

322
                print(add(2, 4))
323
                """
324
            ),
325
            f"{PACKAGE}/BUILD": "python_sources()",
326
        }
327
    )
328
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="app.py"))
×
329
    result = run_mypy(rule_runner, [tgt])
×
330
    assert len(result) == 1
×
331
    assert result[0].exit_code == 1
×
332
    assert f"{PACKAGE}/math/add.py:5" in result[0].stdout
×
333

334

335
@skip_unless_python38_present
1✔
336
def test_works_with_python38(rule_runner: PythonRuleRunner) -> None:
1✔
337
    """MyPy's typed-ast dependency does not understand Python 3.8, so we must instead run MyPy with
338
    Python 3.8 when relevant."""
339
    rule_runner.write_files(
×
340
        {
341
            f"{PACKAGE}/f.py": dedent(
342
                """\
343
                x = 0
344
                if y := x:
345
                    print("x is truthy and now assigned to y")
346
                """
347
            ),
348
            f"{PACKAGE}/BUILD": "python_sources(interpreter_constraints=['>=3.8'])",
349
            "mypy.lock": read_sibling_resource(__name__, "mypy_py38.lock"),
350
        }
351
    )
352
    extra_args = [
×
353
        "--python-resolves={'mypy':'mypy.lock'}",
354
        "--mypy-install-from-resolve=mypy",
355
    ]
356
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
357
    assert_success(rule_runner, tgt, extra_args=extra_args)
×
358

359

360
@skip_unless_python39_present
1✔
361
def test_works_with_python39(rule_runner: PythonRuleRunner) -> None:
1✔
362
    """MyPy's typed-ast dependency does not understand Python 3.9, so we must instead run MyPy with
363
    Python 3.9 when relevant."""
364
    rule_runner.write_files(
×
365
        {
366
            f"{PACKAGE}/f.py": dedent(
367
                """\
368
                @lambda _: int
369
                def replaced(x: bool) -> str:
370
                    return "42" if x is True else "1/137"
371
                """
372
            ),
373
            f"{PACKAGE}/BUILD": "python_sources(interpreter_constraints=['>=3.9'])",
374
        }
375
    )
376
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
377
    assert_success(rule_runner, tgt)
×
378

379

380
def test_run_only_on_specified_files(rule_runner: PythonRuleRunner) -> None:
1✔
381
    rule_runner.write_files(
×
382
        {
383
            f"{PACKAGE}/good.py": GOOD_FILE,
384
            f"{PACKAGE}/bad.py": BAD_FILE,
385
            f"{PACKAGE}/BUILD": dedent(
386
                """\
387
                python_sources(name='good', sources=['good.py'], dependencies=[':bad'])
388
                python_sources(name='bad', sources=['bad.py'])
389
                """
390
            ),
391
        }
392
    )
393
    tgt = rule_runner.get_target(Address(PACKAGE, target_name="good", relative_file_path="good.py"))
×
394
    assert_success(rule_runner, tgt)
×
395

396

397
def test_type_stubs(rule_runner: PythonRuleRunner) -> None:
1✔
398
    """Test that first-party type stubs work for both first-party and third-party code."""
399
    rule_runner.write_files(
×
400
        {
401
            "BUILD": "python_requirement(name='colors', requirements=['ansicolors'])",
402
            "mypy_stubs/__init__.py": "",
403
            "mypy_stubs/colors.pyi": "def red(s: str) -> str: ...",
404
            "mypy_stubs/BUILD": "python_sources()",
405
            f"{PACKAGE}/util/__init__.py": "",
406
            f"{PACKAGE}/util/untyped.py": "def add(x, y):\n    return x + y",
407
            f"{PACKAGE}/util/untyped.pyi": "def add(x: int, y: int) -> int: ...",
408
            f"{PACKAGE}/util/BUILD": "python_sources()",
409
            f"{PACKAGE}/__init__.py": "",
410
            f"{PACKAGE}/app.py": dedent(
411
                """\
412
                from colors import red
413
                from project.util.untyped import add
414

415
                z = add(2, 2.0)
416
                print(red(z))
417
                """
418
            ),
419
            f"{PACKAGE}/BUILD": "python_sources()",
420
        }
421
    )
422
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="app.py"))
×
423
    result = run_mypy(
×
424
        rule_runner, [tgt], extra_args=["--source-root-patterns=['mypy_stubs', 'src/py']"]
425
    )
426
    assert len(result) == 1
×
427
    assert result[0].exit_code == 1
×
428
    assert f"{PACKAGE}/app.py:4: error: Argument 2 to" in result[0].stdout
×
429
    assert f"{PACKAGE}/app.py:5: error: Argument 1 to" in result[0].stdout
×
430

431

432
def test_mypy_shadows_requirements(rule_runner: PythonRuleRunner) -> None:
1✔
433
    """Test the behavior of a MyPy requirement shadowing a user's requirement.
434

435
    The way we load requirements is complex. We want to ensure that things still work properly in
436
    this edge case.
437
    """
438
    rule_runner.write_files(
×
439
        {
440
            "mypy.lock": read_sibling_resource(__name__, "mypy_shadowing_tomli.lock"),
441
            "BUILD": "python_requirement(name='ta', requirements=['tomli==2.1.0'])",
442
            f"{PACKAGE}/f.py": "import tomli",
443
            f"{PACKAGE}/BUILD": "python_sources()",
444
        }
445
    )
446
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
447
    extra_args = [
×
448
        "--python-resolves={'mypy':'mypy.lock'}",
449
        "--mypy-install-from-resolve=mypy",
450
    ]
451
    assert_success(rule_runner, tgt, extra_args=extra_args)
×
452

453

454
def test_source_plugin(rule_runner: PythonRuleRunner) -> None:
1✔
455
    # NB: We make this source plugin fairly complex by having it use transitive dependencies.
456
    # This is to ensure that we can correctly support plugins with dependencies.
457
    # The plugin changes the return type of functions ending in `__overridden_by_plugin` to have a
458
    # return type of `None`.
459
    plugin_file = dedent(
×
460
        """\
461
        from typing import Callable, Optional, Type
462

463
        from mypy.plugin import FunctionContext, Plugin
464
        from mypy.types import NoneType, Type as MyPyType
465

466
        from plugins.subdir.dep import is_overridable_function
467
        from project.subdir.util import noop
468

469
        noop()
470

471
        class ChangeReturnTypePlugin(Plugin):
472
            def get_function_hook(
473
                self, fullname: str
474
            ) -> Optional[Callable[[FunctionContext], MyPyType]]:
475
                return hook if is_overridable_function(fullname) else None
476

477
        def hook(ctx: FunctionContext) -> MyPyType:
478
            return NoneType()
479

480
        def plugin(_version: str) -> Type[Plugin]:
481
            return ChangeReturnTypePlugin
482
        """
483
    )
484
    rule_runner.write_files(
×
485
        {
486
            "mypy.lock": read_sibling_resource(__name__, "mypy_with_more_itertools.lock"),
487
            "BUILD": dedent(
488
                """\
489
                python_requirement(name='mypy', requirements=['mypy==1.1.1'])
490
                python_requirement(name="more-itertools", requirements=["more-itertools==8.4.0"])
491
                """
492
            ),
493
            "pants-plugins/plugins/subdir/__init__.py": "",
494
            "pants-plugins/plugins/subdir/dep.py": dedent(
495
                """\
496
                from more_itertools import flatten
497

498
                def is_overridable_function(name: str) -> bool:
499
                    assert list(flatten([[1, 2], [3, 4]])) == [1, 2, 3, 4]
500
                    return name.endswith("__overridden_by_plugin")
501
                """
502
            ),
503
            "pants-plugins/plugins/subdir/BUILD": "python_sources()",
504
            # The plugin can depend on code located anywhere in the project; its dependencies need
505
            # not be in the same directory.
506
            f"{PACKAGE}/subdir/__init__.py": "",
507
            f"{PACKAGE}/subdir/util.py": "def noop() -> None:\n    pass\n",
508
            f"{PACKAGE}/subdir/BUILD": "python_sources()",
509
            "pants-plugins/plugins/__init__.py": "",
510
            "pants-plugins/plugins/change_return_type.py": plugin_file,
511
            "pants-plugins/plugins/BUILD": "python_sources()",
512
            f"{PACKAGE}/__init__.py": "",
513
            f"{PACKAGE}/f.py": dedent(
514
                """\
515
                def add(x: int, y: int) -> int:
516
                    return x + y
517

518
                def add__overridden_by_plugin(x: int, y: int) -> int:
519
                    return x  + y
520

521
                result = add__overridden_by_plugin(1, 1)
522
                assert add(result, 2) == 4
523
                """
524
            ),
525
            f"{PACKAGE}/BUILD": "python_sources()",
526
            "mypy.ini": dedent(
527
                """\
528
                [mypy]
529
                plugins =
530
                    plugins.change_return_type
531
                """
532
            ),
533
        }
534
    )
535

536
    def run_mypy_with_plugin(tgt: Target) -> CheckResult:
×
537
        result = run_mypy(
×
538
            rule_runner,
539
            [tgt],
540
            extra_args=[
541
                "--python-resolves={'mypy':'mypy.lock'}",
542
                "--mypy-source-plugins=['pants-plugins/plugins']",
543
                "--mypy-install-from-resolve=mypy",
544
                "--source-root-patterns=['pants-plugins', 'src/py']",
545
            ],
546
        )
547
        assert len(result) == 1
×
548
        return result[0]
×
549

550
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
551
    result = run_mypy_with_plugin(tgt)
×
552
    assert result.exit_code == 1
×
553
    assert f"{PACKAGE}/f.py:8" in result.stdout
×
554
    # Ensure we don't accidentally check the source plugin itself.
555
    assert "(checked 1 source file)" in result.stdout
×
556

557
    # Ensure that running MyPy on the plugin itself still works.
558
    plugin_tgt = rule_runner.get_target(
×
559
        Address("pants-plugins/plugins", relative_file_path="change_return_type.py")
560
    )
561
    result = run_mypy_with_plugin(plugin_tgt)
×
562
    assert result.exit_code == 0
×
563
    assert "Success: no issues found in 1 source file" in result.stdout
×
564

565

566
@pytest.mark.parametrize("protoc_type_stubs", (False, True), ids=("mypy_plugin", "protoc_direct"))
1✔
567
def test_protobuf_mypy(rule_runner: PythonRuleRunner, protoc_type_stubs: bool) -> None:
1✔
568
    rule_runner = PythonRuleRunner(
×
569
        rules=[*rule_runner.rules, *protobuf_rules(), *protobuf_subsystem_rules()],
570
        target_types=[*rule_runner.target_types, ProtobufSourceTarget],
571
    )
572
    rule_runner.write_files(
×
573
        {
574
            "BUILD": ("python_requirement(name='protobuf', requirements=['protobuf==6.31.1'])"),
575
            f"{PACKAGE}/__init__.py": "",
576
            f"{PACKAGE}/proto.proto": dedent(
577
                """\
578
                syntax = "proto3";
579
                package project;
580

581
                message Person {
582
                    string name = 1;
583
                    int32 id = 2;
584
                    string email = 3;
585
                }
586
                """
587
            ),
588
            f"{PACKAGE}/f.py": dedent(
589
                """\
590
                from project.proto_pb2 import Person
591

592
                x = Person(name=123, id="abc", email=None)
593
                """
594
            ),
595
            f"{PACKAGE}/BUILD": dedent(
596
                """\
597
                python_sources(dependencies=[':proto'])
598
                protobuf_source(name='proto', source='proto.proto')
599
                """
600
            ),
601
        }
602
    )
603
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
604
    result = run_mypy(
×
605
        rule_runner,
606
        [tgt],
607
        extra_args=[
608
            "--python-protobuf-generate-type-stubs"
609
            if protoc_type_stubs
610
            else "--python-protobuf-mypy-plugin"
611
        ],
612
    )
613
    assert len(result) == 1
×
614
    assert 'Argument "name" to "Person" has incompatible type "int"' in result[0].stdout
×
615
    assert 'Argument "id" to "Person" has incompatible type "str"' in result[0].stdout
×
616
    assert result[0].exit_code == 1
×
617

618

619
def test_cache_directory_per_resolve(rule_runner: PythonRuleRunner) -> None:
1✔
620
    build_multiple_resolves = dedent(
×
621
        """\
622
        python_source(
623
            name='f_from_a',
624
            source='f.py',
625
            resolve='a',
626
        )
627
        python_source(
628
           name='f_from_b',
629
           source='f.py',
630
           resolve='b',
631
        )
632
        """
633
    )
634
    rule_runner.write_files(
×
635
        {
636
            f"{PACKAGE}/f.py": GOOD_FILE,
637
            f"{PACKAGE}/BUILD": build_multiple_resolves,
638
            "mypy.lock": read_sibling_resource(__name__, "mypy_with_django_stubs.lock"),
639
        }
640
    )
641
    target_a = rule_runner.get_target(Address(PACKAGE, target_name="f_from_a"))
×
642
    target_b = rule_runner.get_target(Address(PACKAGE, target_name="f_from_b"))
×
643

644
    runner_options = [
×
645
        "--python-resolves={'a': 'mypy.lock', 'b': 'mypy.lock'}",
646
        "--python-enable-resolves",
647
    ]
648
    run_mypy(rule_runner, [target_a, target_b], extra_args=runner_options)
×
649

650
    with rule_runner.pushd():
×
651
        Path("BUILDROOT").touch()
×
652
        bootstrap_options = rule_runner.options_bootstrapper.bootstrap_options.for_global_scope()
×
653
    named_cache_dir = bootstrap_options.named_caches_dir
×
654
    mypy_cache_dir = (
×
655
        f"{named_cache_dir}/mypy_cache/{sha256(rule_runner.build_root.encode()).hexdigest()}"
656
    )
657
    for resolve in ["a", "b"]:
×
658
        expected_cache_dir = f"{mypy_cache_dir}/{resolve}"
×
659
        assert os.path.exists(expected_cache_dir)
×
660

661

662
@skip_unless_all_pythons_present("3.8", "3.9")
1✔
663
def test_partition_targets(rule_runner: PythonRuleRunner) -> None:
1✔
664
    def create_folder(folder: str, resolve: str, interpreter: str) -> dict[str, str]:
×
665
        return {
×
666
            f"{folder}/dep.py": "",
667
            f"{folder}/root.py": "",
668
            f"{folder}/BUILD": dedent(
669
                f"""\
670
                python_source(
671
                    name='dep',
672
                    source='dep.py',
673
                    resolve='{resolve}',
674
                    interpreter_constraints=['=={interpreter}.*'],
675
                )
676
                python_source(
677
                    name='root',
678
                    source='root.py',
679
                    resolve='{resolve}',
680
                    interpreter_constraints=['=={interpreter}.*'],
681
                    dependencies=[':dep'],
682
                )
683
                """
684
            ),
685
        }
686

687
    files = {
×
688
        **create_folder("resolveA_py38", "a", "3.8"),
689
        **create_folder("resolveA_py39", "a", "3.9"),
690
        **create_folder("resolveB_1", "b", "3.9"),
691
        **create_folder("resolveB_2", "b", "3.9"),
692
    }
693
    rule_runner.write_files(files)
×
694
    rule_runner.set_options(
×
695
        ["--python-resolves={'a': '', 'b': ''}", "--python-enable-resolves"],
696
        env_inherit={"PATH", "PYENV_ROOT", "HOME"},
697
    )
698

699
    resolve_a_py38_dep = rule_runner.get_target(Address("resolveA_py38", target_name="dep"))
×
700
    resolve_a_py38_root = rule_runner.get_target(Address("resolveA_py38", target_name="root"))
×
701
    resolve_a_py39_dep = rule_runner.get_target(Address("resolveA_py39", target_name="dep"))
×
702
    resolve_a_py39_root = rule_runner.get_target(Address("resolveA_py39", target_name="root"))
×
703
    resolve_b_dep1 = rule_runner.get_target(Address("resolveB_1", target_name="dep"))
×
704
    resolve_b_root1 = rule_runner.get_target(Address("resolveB_1", target_name="root"))
×
705
    resolve_b_dep2 = rule_runner.get_target(Address("resolveB_2", target_name="dep"))
×
706
    resolve_b_root2 = rule_runner.get_target(Address("resolveB_2", target_name="root"))
×
707
    request = MyPyRequest(
×
708
        MyPyFieldSet.create(t)
709
        for t in (
710
            resolve_a_py38_root,
711
            resolve_a_py39_root,
712
            resolve_b_root1,
713
            resolve_b_root2,
714
        )
715
    )
716

717
    partitions = rule_runner.request(MyPyPartitions, [request])
×
718
    assert len(partitions) == 3
×
719

720
    def assert_partition(
×
721
        partition: MyPyPartition,
722
        roots: list[Target],
723
        deps: list[Target],
724
        interpreter: str,
725
        resolve: str,
726
    ) -> None:
727
        root_addresses = {t.address for t in roots}
×
728
        assert {fs.address for fs in partition.field_sets} == root_addresses
×
729
        assert {t.address for t in partition.root_targets.closure()} == {
×
730
            *root_addresses,
731
            *(t.address for t in deps),
732
        }
733
        ics = [f"CPython=={interpreter}.*"]
×
734
        assert partition.interpreter_constraints == InterpreterConstraints(ics)
×
735
        assert partition.description() == f"{resolve}, {ics}"
×
736

737
    assert_partition(partitions[0], [resolve_a_py38_root], [resolve_a_py38_dep], "3.8", "a")
×
738
    assert_partition(partitions[1], [resolve_a_py39_root], [resolve_a_py39_dep], "3.9", "a")
×
739
    assert_partition(
×
740
        partitions[2],
741
        [resolve_b_root1, resolve_b_root2],
742
        [resolve_b_dep1, resolve_b_dep2],
743
        "3.9",
744
        "b",
745
    )
746

747

748
def test_colors_and_formatting(rule_runner: PythonRuleRunner) -> None:
1✔
749
    rule_runner.write_files(
×
750
        {
751
            f"{PACKAGE}/f.py": dedent(
752
                """\
753
                class incredibly_long_type_name_to_force_wrapping_if_mypy_wrapped_error_messages_12345678901234567890123456789012345678901234567890:
754
                    pass
755

756
                x = incredibly_long_type_name_to_force_wrapping_if_mypy_wrapped_error_messages_12345678901234567890123456789012345678901234567890()
757
                x.incredibly_long_attribute_name_to_force_wrapping_if_mypy_wrapped_error_messages_12345678901234567890123456789012345678901234567890
758
                """
759
            ),
760
            f"{PACKAGE}/BUILD": "python_sources()",
761
        }
762
    )
763
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
764

765
    result = run_mypy(rule_runner, [tgt], extra_args=["--colors=true", "--mypy-args=--pretty"])
×
766

767
    assert len(result) == 1
×
768
    assert result[0].exit_code == 1
×
769
    # all one line
770
    assert re.search(
×
771
        "error:.*incredibly_long_type_name.*incredibly_long_attribute_name", result[0].stdout
772
    )
773
    # at least one escape sequence that sets text color (red)
774
    assert "\033[31m" in result[0].stdout
×
775
    assert result[0].report == EMPTY_DIGEST
×
776

777

778
def test_force(rule_runner: PythonRuleRunner) -> None:
1✔
779
    rule_runner.write_files(
×
780
        {
781
            f"{PACKAGE}/f.py": GOOD_FILE,
782
            f"{PACKAGE}/BUILD": "python_sources()",
783
        }
784
    )
785
    tgt = rule_runner.get_target(Address(PACKAGE, relative_file_path="f.py"))
×
786
    result = run_mypy(rule_runner, [tgt], extra_args=["--check-force"])
×
787
    assert len(result) == 1
×
788
    assert result[0].exit_code == 0
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc