Introduce a local coverage plugin + add conditional exclusions#2347
Introduce a local coverage plugin + add conditional exclusions#2347sirosen wants to merge 10 commits intojazzband:mainfrom
Conversation
These are either chronically uncovered up to a certain line or have flaky coverage. Either way, having them in is rather harmful for measurements.
These are version-dependent but are sometimes applied to conditionals checking the deps versions. They won't work perfectly but it's a start.
A new local plugin in `plugins/coverage/` as a standalone module, which
injects coverage excludes of the form
pragma: pip{comparator}{version} no cover
In order for this plugin to be picked up, `.coveragerc` is updated to
list the plugin by name and `PYTHONPATH` is set via `setenv` in order to
make the module importable.
All conditional checks on the pip version are now marked with the
relevant matching pragmas.
When looking at lost coverage reporting in codecov, it appears that it is counting the new plugin code as "uncovered". Though true -- it isn't being tested rigorously -- trying to measure it seems incorrect at present.
|
If the coverage still looks bad, I may have to look more deeply and potentially mark this draft until I can fix. |
webknjaz
left a comment
There was a problem hiding this comment.
Cool that you got to looking into this! I've left a few comments inline.
tests/test_cli_compile.py
Outdated
|
|
||
| # for older pip versions, recompute the output path to be relative to the input path | ||
| if not pip_produces_absolute_paths: | ||
| if not pip_produces_absolute_paths: # FIXME: figure out how to cover piplowest |
There was a problem hiding this comment.
Oh, that wasn't supposed to still be there! 😳
I think that's from before I had the plugin working.
But it's wrong. Let me replace with a pragma.
| @pytest.fixture | ||
| def runner(): | ||
| if Version(version_of("click")) < Version("8.2"): | ||
| # Coverage is excluded because we only test with the latest Click |
There was a problem hiding this comment.
Should we fix that? Or maybe make it not branchy by updating deps in the meta?
There was a problem hiding this comment.
I think we should go after a fix, but that it doesn't fit within the scope of this PR.
I've been considering the possible argument for vendoring click, on account of the fact that it would allow pip-tools to be used in environments with really old click versions.
Like pip, I think we should not have Python package dependencies, other than pip itself. (Also like pip, I think this will pose a licensing, SBOM, and transparency challenge! )
| piptools_coverage | ||
| omit = | ||
| piptools/_compat/* | ||
| # any local plugins we use aren't part of normal testing/coverage |
There was a problem hiding this comment.
Honestly, I'd argue that our own tooling is subject to being tested as well. Especially since it may grow into something that could eventually be ready to be moved out. So if possible, I'd add a test for the plugin.
There was a problem hiding this comment.
Although I agree in principle, I think we'll have trouble unless we add a dedicated test job in tox and CI, with a dedicated and distinct .coveragerc. The reason is that coverage imports this code on startup, so it's imported too early to measure. I can't think of a way to measure it right now, though I was also considering reaching out to Ned (he's active in the main Python discord) to see if he could help point me in the right direction.
I'm happy to write tests for it, but I think it might belong outside of coverage reporting until I can figure that out?
I was hoping we could include it early, and I could continue to work on this.
There was a problem hiding this comment.
FWIW, I'm not sure I like a top-level public name like plugins/. Wonder if it could live in tests/_plugins/ or smth like that since it's a part of our test toolchain.
There was a problem hiding this comment.
I can move it around. tests/_plugins/_coverage/ works for me, if that sounds good?
| config.set_option("report:exclude_lines", sorted(exclude)) | ||
|
|
||
|
|
||
| def coverage_init(registry: Plugins, options: dict[str, _t.Any]) -> None: |
There was a problem hiding this comment.
MyPy recommends object instead of _t.Any. Can we use that?
There was a problem hiding this comment.
When I circle back on this to do more work, I'll give it a try. 👍
tests/test_cli_compile.py
Outdated
| (first_posarg, *_tail_args), _kwargs = PyPIRepository.call_args | ||
|
|
||
| if _pip_api.PIP_VERSION_MAJOR_MINOR >= (25, 3): # pragma: >=3.9 cover | ||
| if _pip_api.PIP_VERSION_MAJOR_MINOR >= (25, 3): # pragma: pip<25.3 no cover |
There was a problem hiding this comment.
This feels a bit contradictary: the check is <= and the pragma's inversed. And then the opposite branch adds inversion that looks like what's in the if-expression down below. So I'd suggest supporting cover, not just no cover.
Althouh, can we analyse expressions with the known constants like _pip_api.PIP_VERSION_MAJOR_MINOR / _pip_api.PIP_VERSION and apply coverage inclusion/exclusion based on that automatically even w/o the comments? The else-branch could be handled the same way.
There was a problem hiding this comment.
Why don't I work on cover first, and we can think about supporting comparisons with _pip_api.PIP_VERSION attributes as a future bit of work?
tox.ini
Outdated
| pipmain: https://github.com/pypa/pip/archive/main.zip | ||
| setenv = | ||
| # local plugin inclusion | ||
| coverage: PYTHONPATH=./plugins/coverage/ |
There was a problem hiding this comment.
Do we know if pytest's pythonpath setting would be enough instead of this?
| CURRENT_YEAR_EPOCH = datetime.date.today().year - 2000 | ||
| _PRAGMA_SUPPORTED_PIP_VERSIONS: list[tuple[int, int]] = [ | ||
| (major, minor) | ||
| for major in range(22, CURRENT_YEAR_EPOCH + 1) |
There was a problem hiding this comment.
Can we read the lowest supported from pyproject.toml?
There was a problem hiding this comment.
Oh, that's a good idea. Yes, we can extract it from there.
|
It looks like codecov is still displeased with me. 😀 I've marked as a draft while I continue to work on this. I'll mark it ready when I feel that I've gotten it to a good place again. I have lots of lovely feedback to act upon, noted above, so changes to be made. As for codecov, I'm not totally sure. I clearly misdiagnosed the "loss of coverage" it's flagging, but the reporting it shows doesn't quite match my expectations there. I think it might be that it sees the missing |
In order to support these, the logic of the plugin had to become a bit more organized -- it now uses a list of supported operators to build out the full suite of pragmas. With these new pragmas available, update the various usage sites to use the affirmative 'cover' pragmas on comparisons, with the corresponding 'no cover' pragmaes on else branches. To improve branch coverage, all of the `else` branches are explicitly enumerated and captured with the appropriate pragmas, even though many are `else: pass`.
This is added to the python path via pytest options, and is excluded from pytest discovery (norecursedirs). Also, add more pip-version pragmas to test code which only runs on specific pip versions.
In order to determine the lowest supported version of `pip`, read the `project.dependencies` list from `pyproject.toml`, find the only listed `pip` dependency, and pull its specifier. If any of the data does not match expectations, a ValueError will be thrown (crashing any run of coverage/pytest-cov) with a message stating that the plugin needs to be updated. This lets us get a working solution without trying to solve a more general case than what we need.
Unit tests of the local coverage plugin exercise its various helpers, and the module-level docstring explains that we can test the parts even if we can't easily measure code coverage on the plugin itself.
These tests need `coverage` to be installed, and it is missing 1. In CI builds on pypy where`coverage` is not installed 2. When local tests run without the `coverage` factor
|
I moved the plugin into If we go with this approach, I plan to write a short blog post to share the technique of a local coverage plugin. It's not something I've seen anyone write about before, and it wasn't hard but it did have some non-obvious requirements (most notably, pythonpath manipulation). I've enhanced the plugin with I've also explicitly expanded a lot of if _pip_api.PIP_VERSION_MAJOR_MINOR >= (22.3): # pragma: pip>=22.3 cover
...
else: # pragma: pip>=22.3 no cover
...That is, I've been using the matching comparison in both branches, but with Deriving the minimum pip version from
My reasoning around bailing out early with errors is that we definitely don't want to try to solve the general case of "what is the minimum version of package X which satisfies this dependency list?" as it's a hard problem. If we make some simplifying assumptions -- and treat anything unexpected as an error -- we get an easy problem for now and can improve it if we ever need to. I'm watching CI to see if it comes up healthy. If everything looks okay, I feel like I can mark this ready. |
Local coverage runs won't report on this file, as configured, but codecov is still flagging it as uncovered code.
This PR is a continuation from #2268 .
As I mentioned in #2264 , I've worked up a local plugin and tried out using
PYTHONPATHto make it importable undercoverage, and locally my testing of it seems to work.The plugin is defined in
plugins/coverage/as a module, and injects coverage excludes of the formI've added it on top of the commits from #2268, rebased, and updated to use the new "pip-version pragmas" where possible.
Contributor checklist
changelog.d/(seechangelog.d/README.mdfor instructions) or the PR text says "no changelog needed".
Maintainer checklist
bot:chronographer:skiplabel.(following Semantic Versioning).