@@ -680,6 +680,126 @@ def test(
680680 raise SystemExit (pytest_p .returncode )
681681
682682
683+ def _resolve_cov_report (report : str , base : Path ) -> str :
684+ """Resolve a --cov-report value, rebasing relative paths under `base`."""
685+ if ":" not in report :
686+ return report
687+
688+ fmt , dest = report .split (":" , 1 )
689+ dest_path = Path (dest )
690+ if not dest_path .is_absolute ():
691+ dest_path = base / dest_path
692+ if dest_path .exists ():
693+ click .secho (f"Removing `{ dest_path } `" , fg = "bright_yellow" )
694+ if dest_path .is_dir ():
695+ shutil .rmtree (dest_path )
696+ else :
697+ dest_path .unlink ()
698+ dest_path .parent .mkdir (parents = True , exist_ok = True )
699+ return f"{ fmt } :{ dest_path } "
700+
701+
702+ @click .command ()
703+ @click .argument ("pytest_args" , nargs = - 1 )
704+ @click .option (
705+ "-j" ,
706+ "n_jobs" ,
707+ metavar = "N_JOBS" ,
708+ default = "1" ,
709+ help = "Number of parallel jobs for testing with pytest-xdist." ,
710+ )
711+ @click .option (
712+ "--tests" ,
713+ "-t" ,
714+ metavar = "TESTS" ,
715+ help = "Which tests to run. Can be a module, function, class, or method." ,
716+ )
717+ @click .option ("--verbose" , "-v" , is_flag = True , default = False )
718+ @click .option (
719+ "--cov-report" ,
720+ "cov_report" ,
721+ multiple = True ,
722+ metavar = "TYPE" ,
723+ help = (
724+ "Coverage report type passed to pytest-cov (e.g. term, term-missing, "
725+ "html:dir, xml:file.xml, json:file.json, lcov:file.lcov, annotate:dir). "
726+ "Can be specified multiple times. Defaults to `term`."
727+ ),
728+ )
729+ @build_option
730+ @build_dir_option
731+ @click .pass_context
732+ def coverage (
733+ ctx ,
734+ * ,
735+ pytest_args ,
736+ n_jobs ,
737+ tests ,
738+ verbose ,
739+ cov_report ,
740+ build = None ,
741+ build_dir = None ,
742+ ):
743+ """📊 Run tests with Python code coverage
744+
745+ Generate coverage reports using pytest-cov. By default, a terminal
746+ report is printed. Supports any report type that pytest-cov supports.
747+
748+ For file-based reports, use the `type:path` format. Relative paths
749+ are placed under `build/coverage/`.
750+
751+ To generate an HTML report:
752+
753+ spin coverage --cov-report html:htmlcov
754+
755+ Multiple report types can be specified:
756+
757+ spin coverage --cov-report term-missing --cov-report xml:coverage.xml
758+
759+ Run coverage on specific tests:
760+
761+ \b
762+ spin coverage -t example_pkg.echo
763+ spin coverage example_pkg/tests
764+
765+ Pass additional pytest arguments after `--`:
766+
767+ spin coverage -- --durations=10 -k "test_foo"
768+
769+ Run tests in parallel (requires pytest-xdist):
770+
771+ spin coverage -j auto
772+ """
773+ cfg = get_config ()
774+ package = cfg .get ("tool.spin.package" , None )
775+ if package is None :
776+ click .secho (
777+ "Please specify `package = packagename` under `tool.spin` section of `pyproject.toml`" ,
778+ fg = "bright_red" ,
779+ )
780+ raise SystemExit (1 )
781+
782+ # Build --cov-report flags, resolving relative paths under build/coverage/
783+ coverage_base = Path .cwd () / "build" / "coverage"
784+ cov_args = [f"--cov={ package } " ]
785+ cov_reports = cov_report or ("term" ,)
786+ for report in cov_reports :
787+ cov_args .append (f"--cov-report={ _resolve_cov_report (report , coverage_base )} " )
788+
789+ # Prepend cov args so user's `--` args come after
790+ pytest_args = tuple (cov_args ) + (pytest_args or ())
791+
792+ ctx .invoke (
793+ test ,
794+ pytest_args = pytest_args ,
795+ n_jobs = n_jobs ,
796+ tests = tests ,
797+ verbose = verbose ,
798+ build = build ,
799+ build_dir = build_dir ,
800+ )
801+
802+
683803@click .command ()
684804@click .option (
685805 "--code" , "-c" , metavar = "CODE" , help = "Python program passed in as a string"
0 commit comments