diff --git a/check_dist/_core.py b/check_dist/_core.py index 3679423..3ceda52 100644 --- a/check_dist/_core.py +++ b/check_dist/_core.py @@ -134,8 +134,6 @@ def load_hatch_config(pyproject_path: str | Path = "pyproject.toml") -> dict: return config.get("tool", {}).get("hatch", {}).get("build", {}) -# ── Copier template defaults ───────────────────────────────────────── - # Per-extension type defaults for sdist/wheel present/absent patterns. # Keys follow the ``add_extension`` value in ``.copier-answers.yaml``. _EXTENSION_DEFAULTS: dict[str, dict] = { @@ -217,6 +215,39 @@ def _module_name_from_project(project_name: str) -> str: return re.sub(r"[\s-]+", "_", project_name).strip("_") +def _normalize_name(name: str) -> str: + """Normalize a name by stripping underscores, hyphens, and periods.""" + return re.sub(r"[-_.]+", "", name).lower() + + +def _resolve_module_from_hatch(module: str, hatch_config: dict) -> str: + """Resolve the module name from hatch packages configuration. + + If any package in the hatch sdist or wheel ``packages`` (or + ``only-include``) is equivalent to *module* after normalizing away + underscores, hyphens, and periods, return that package name instead. + This handles projects where the distribution name differs from the + importable package name (e.g. ``jupyter-fs`` vs ``jupyterfs``). + """ + norm = _normalize_name(module) + candidates: list[str] = [] + for target in ("sdist", "wheel"): + target_cfg = hatch_config.get("targets", {}).get(target, {}) + for key in ("only-include", "packages"): + vals = target_cfg.get(key) + if vals: + candidates.extend(vals) + # Also check top-level packages / only-include + for key in ("only-include", "packages"): + vals = hatch_config.get(key) + if vals: + candidates.extend(vals) + for candidate in candidates: + if _normalize_name(candidate) == norm: + return candidate + return module + + def copier_defaults(copier_config: dict, hatch_config: dict | None = None) -> dict | None: """Derive default check-dist config from copier answers. @@ -240,6 +271,8 @@ def copier_defaults(copier_config: dict, hatch_config: dict | None = None) -> di return None module = _module_name_from_project(project_name) + if hatch_config: + module = _resolve_module_from_hatch(module, hatch_config) sdist_present_extra = list(ext_defaults.get("sdist_present_extra", [])) @@ -301,9 +334,6 @@ def _filter_extras_by_hatch(extras: list[str], hatch_config: dict) -> list[str]: return extras -# ── Building ────────────────────────────────────────────────────────── - - def build_dists(source_dir: str, output_dir: str, *, no_isolation: bool = False) -> list[str]: """Build sdist and wheel into *output_dir*. @@ -378,9 +408,6 @@ def _find_pre_built(source_dir: str) -> str | None: return None -# ── Listing files ───────────────────────────────────────────────────── - - def list_sdist_files(sdist_path: str) -> list[str]: """List files inside an sdist, stripping the top-level directory.""" files: list[str] = [] @@ -407,9 +434,6 @@ def list_wheel_files(wheel_path: str) -> list[str]: return sorted(name for name in zf.namelist() if not name.endswith("/")) -# ── VCS integration ─────────────────────────────────────────────────── - - def get_vcs_files(source_dir: str) -> list[str]: """Return files tracked by git in *source_dir*.""" try: @@ -426,9 +450,6 @@ def get_vcs_files(source_dir: str) -> list[str]: return sorted(f for f in result.stdout.split("\0") if f) -# ── Pattern matching ────────────────────────────────────────────────── - - def matches_pattern(filepath: str, pattern: str) -> bool: """Check whether *filepath* matches *pattern*. @@ -479,9 +500,6 @@ def _matches_hatch_pattern(filepath: str, pattern: str) -> bool: return False -# ── Checking helpers ────────────────────────────────────────────────── - - def check_present(files: list[str], patterns: list[str], dist_type: str) -> list[str]: """Return error strings for any *patterns* not found in *files*.""" errors: list[str] = [] @@ -664,9 +682,6 @@ def check_sdist_vs_vcs( return errors -# ── Main entry point ────────────────────────────────────────────────── - - def check_dist( source_dir: str = ".", *, @@ -742,7 +757,6 @@ def check_dist( if not wheel_path: errors.append("No wheel found in pre-built directory") - # ── sdist checks ───────────────────────────────────────── if sdist_path: sdist_files = list_sdist_files(sdist_path) messages.append(f"\nsdist ({os.path.basename(sdist_path)}) – {len(sdist_files)} file(s):") @@ -760,7 +774,6 @@ def check_dist( errors.extend(check_absent(sdist_files, config["sdist"]["absent"], "sdist", present_patterns=config["sdist"]["present"])) errors.extend(check_wrong_platform_extensions(sdist_files, "sdist")) - # ── wheel checks ───────────────────────────────────────── if wheel_path: wheel_files = list_wheel_files(wheel_path) messages.append(f"\nwheel ({os.path.basename(wheel_path)}) – {len(wheel_files)} file(s):") diff --git a/check_dist/tests/test_all.py b/check_dist/tests/test_all.py index 5b5dd4c..ddbe55a 100644 --- a/check_dist/tests/test_all.py +++ b/check_dist/tests/test_all.py @@ -19,6 +19,8 @@ _find_pre_built, _matches_hatch_pattern, _module_name_from_project, + _normalize_name, + _resolve_module_from_hatch, _sdist_expected_files, check_absent, check_dist, @@ -37,8 +39,6 @@ translate_extension, ) -# ── translate_extension ─────────────────────────────────────────────── - class TestTranslateExtension: def test_no_change_on_native(self): @@ -86,9 +86,6 @@ def test_glob_pattern(self): assert translate_extension("*.so") == "*.pyd" -# ── matches_pattern ─────────────────────────────────────────────────── - - class TestMatchesPattern: def test_exact_file(self): assert matches_pattern("LICENSE", "LICENSE") @@ -131,9 +128,6 @@ def test_nested_directory_pattern(self): assert matches_pattern(".github/workflows/ci.yml", ".github") -# ── check_present / check_absent ───────────────────────────────────── - - class TestCheckPresent: FILES = [ "check_dist/__init__.py", @@ -236,9 +230,6 @@ def test_present_patterns_none_flags_all(self): assert "lerna/tests/fake_package/pyproject.toml" in errors[0] -# ── check_wrong_platform_extensions ────────────────────────────────── - - class TestCheckWrongPlatformExtensions: @patch("check_dist._core._get_platform_key", return_value="win32") def test_so_on_windows(self, _mock): @@ -267,9 +258,6 @@ def test_clean_on_darwin(self, _mock): assert errors == [] -# ── check_sdist_vs_vcs ─────────────────────────────────────────────── - - class TestCheckSdistVsVcs: def test_matching(self): sdist = ["check_dist/__init__.py", "pyproject.toml", "README.md"] @@ -345,9 +333,6 @@ def test_only_include_vcs_check(self): assert errors == [] -# ── _sdist_expected_files ──────────────────────────────────────────── - - class TestMatchesHatchPattern: def test_exact_file(self): assert _matches_hatch_pattern("Cargo.toml", "Cargo.toml") @@ -389,8 +374,6 @@ def test_no_hatch_config(self): result = _sdist_expected_files(self.VCS, {}) assert result == set(self.VCS) - # ── only-include ───────────────────────────────────────────── - def test_only_include_exhaustive(self): hatch = {"targets": {"sdist": {"only-include": ["pkg", "rust", "Cargo.toml"]}}} result = _sdist_expected_files(self.VCS, hatch) @@ -402,8 +385,6 @@ def test_only_include_exhaustive(self): assert "tests/test_pkg.py" not in result assert "docs/index.md" not in result - # ── packages (acts as only-include fallback) ───────────────── - def test_packages_acts_as_only_include(self): hatch = {"targets": {"sdist": {"packages": ["pkg"]}}} result = _sdist_expected_files(self.VCS, hatch) @@ -433,8 +414,6 @@ def test_packages_with_include(self): assert "rust/src/lib.rs" not in result assert "tests/test_pkg.py" not in result - # ── include (no packages, no only-include) ─────────────────── - def test_include_filters_full_tree(self): hatch = {"targets": {"sdist": {"include": ["pkg", "Cargo.toml"]}}} result = _sdist_expected_files(self.VCS, hatch) @@ -443,8 +422,6 @@ def test_include_filters_full_tree(self): assert "rust/src/lib.rs" not in result assert "tests/test_pkg.py" not in result - # ── exclude ────────────────────────────────────────────────── - def test_exclude_with_only_include(self): hatch = { "targets": { @@ -480,8 +457,6 @@ def test_exclude_with_no_constraints(self): assert "pkg/__init__.py" in result assert "docs/index.md" not in result - # ── force-include ──────────────────────────────────────────── - def test_force_include_adds_files(self): hatch = { "targets": { @@ -535,8 +510,6 @@ def test_target_force_include_overrides_global(self): # global force-include is overridden by target-level assert "global.txt" not in result - # ── combined scenarios ─────────────────────────────────────── - def test_only_include_with_exclude(self): hatch = { "targets": { @@ -558,9 +531,6 @@ def test_empty_config(self): assert result == set(self.VCS) -# ── _filter_extras_by_hatch ────────────────────────────────────────── - - class TestFilterExtrasByHatch: """Verify that copier-derived extras are trimmed to match hatch config.""" @@ -631,9 +601,6 @@ def test_global_force_include(self): assert "rust" not in result -# ── load_config ─────────────────────────────────────────────────────── - - class TestLoadConfig: def test_missing_file(self, tmp_path): cfg = load_config(tmp_path / "nonexistent.toml") @@ -699,9 +666,6 @@ def test_valid_config(self, tmp_path): assert cfg["targets"]["sdist"]["packages"] == ["mylib"] -# ── Copier defaults ────────────────────────────────────────────────── - - class TestModuleNameFromProject: def test_spaces(self): assert _module_name_from_project("python template js") == "python_template_js" @@ -716,6 +680,81 @@ def test_no_change(self): assert _module_name_from_project("mypkg") == "mypkg" +class TestNormalizeName: + def test_underscores(self): + assert _normalize_name("jupyter_fs") == "jupyterfs" + + def test_hyphens(self): + assert _normalize_name("jupyter-fs") == "jupyterfs" + + def test_periods(self): + assert _normalize_name("jupyter.fs") == "jupyterfs" + + def test_mixed(self): + assert _normalize_name("my_pkg-name.ext") == "mypkgnameext" + + def test_no_separators(self): + assert _normalize_name("mypkg") == "mypkg" + + def test_case_insensitive(self): + assert _normalize_name("MyPkg") == "mypkg" + + +class TestResolveModuleFromHatch: + def test_wheel_packages_match(self): + hatch = {"targets": {"wheel": {"packages": ["jupyterfs"]}}} + assert _resolve_module_from_hatch("jupyter_fs", hatch) == "jupyterfs" + + def test_sdist_packages_match(self): + hatch = {"targets": {"sdist": {"packages": ["jupyterfs", "js"]}}} + assert _resolve_module_from_hatch("jupyter_fs", hatch) == "jupyterfs" + + def test_no_match_returns_original(self): + hatch = {"targets": {"wheel": {"packages": ["otherpkg"]}}} + assert _resolve_module_from_hatch("jupyter_fs", hatch) == "jupyter_fs" + + def test_empty_hatch_config(self): + assert _resolve_module_from_hatch("jupyter_fs", {}) == "jupyter_fs" + + def test_exact_match_unchanged(self): + hatch = {"targets": {"wheel": {"packages": ["my_project"]}}} + assert _resolve_module_from_hatch("my_project", hatch) == "my_project" + + def test_only_include_match(self): + hatch = {"targets": {"wheel": {"only-include": ["jupyterfs"]}}} + assert _resolve_module_from_hatch("jupyter_fs", hatch) == "jupyterfs" + + def test_top_level_packages(self): + hatch = {"packages": ["jupyterfs"]} + assert _resolve_module_from_hatch("jupyter_fs", hatch) == "jupyterfs" + + def test_period_variant(self): + hatch = {"targets": {"wheel": {"packages": ["my.pkg"]}}} + assert _resolve_module_from_hatch("my_pkg", hatch) == "my.pkg" + + +class TestCopierDefaultsWithHatchModuleResolution: + def test_hatch_resolves_module_name(self): + hatch = {"targets": {"wheel": {"packages": ["jupyterfs"]}}} + cfg = copier_defaults( + {"add_extension": "jupyter", "project_name": "jupyter-fs"}, + hatch_config=hatch, + ) + assert cfg is not None + assert "jupyterfs" in cfg["sdist"]["present"] + assert "jupyterfs" in cfg["wheel"]["present"] + assert "jupyter_fs" not in cfg["sdist"]["present"] + assert "jupyter_fs" not in cfg["wheel"]["present"] + + def test_no_hatch_uses_default_module(self): + cfg = copier_defaults( + {"add_extension": "jupyter", "project_name": "jupyter-fs"}, + ) + assert cfg is not None + assert "jupyter_fs" in cfg["sdist"]["present"] + assert "jupyter_fs" in cfg["wheel"]["present"] + + class TestLoadCopierConfig: def test_missing_file(self, tmp_path): assert load_copier_config(tmp_path) == {} @@ -800,9 +839,6 @@ def test_no_copier_file_returns_empty(self, tmp_path): assert cfg["wheel"]["present"] == [] -# ── list_sdist_files ────────────────────────────────────────────────── - - class TestListSdistFiles: def test_tar_gz(self, tmp_path): archive = tmp_path / "pkg-1.0.tar.gz" @@ -834,9 +870,6 @@ def test_unknown_format(self, tmp_path): list_sdist_files(str(archive)) -# ── list_wheel_files ────────────────────────────────────────────────── - - class TestListWheelFiles: def test_wheel(self, tmp_path): archive = tmp_path / "pkg-1.0-py3-none-any.whl" @@ -848,9 +881,6 @@ def test_wheel(self, tmp_path): assert files == ["pkg-1.0.dist-info/METADATA", "pkg/__init__.py"] -# ── find_dist_files ─────────────────────────────────────────────────── - - class TestFindDistFiles: def test_finds_both(self, tmp_path): (tmp_path / "pkg-1.0.tar.gz").touch() @@ -906,9 +936,6 @@ def test_none_when_no_dirs(self, tmp_path): assert _find_pre_built(str(tmp_path)) is None -# ── get_vcs_files ───────────────────────────────────────────────────── - - class TestGetVcsFiles: def test_in_git_repo(self, tmp_path): """Integration test: create a real tiny git repo.""" @@ -923,9 +950,6 @@ def test_in_git_repo(self, tmp_path): assert "hello.py" in files -# ── Integration: check_dist ────────────────────────────────────────── - - def _make_project(tmp_path: Path, *, extra_files: dict[str, str] | None = None) -> Path: """Create a minimal hatch-based project for integration tests.""" proj = tmp_path / "proj" @@ -1009,9 +1033,6 @@ def test_verbose_lists_files(self, tmp_path): assert "mypkg/__init__.py" in combined -# ── CLI smoke test ──────────────────────────────────────────────────── - - class TestCLI: def test_help(self): result = subprocess.run(