@@ -99,7 +99,8 @@ def load_config(pyproject_path: str | Path = "pyproject.toml", *, source_dir: st
9999 if source_dir is None :
100100 source_dir = path .parent
101101 copier_cfg = load_copier_config (source_dir )
102- defaults = copier_defaults (copier_cfg )
102+ hatch_cfg = config .get ("tool" , {}).get ("hatch" , {}).get ("build" , {})
103+ defaults = copier_defaults (copier_cfg , hatch_config = hatch_cfg )
103104 if defaults is not None :
104105 return defaults
105106 return empty
@@ -145,7 +146,7 @@ def load_hatch_config(pyproject_path: str | Path = "pyproject.toml") -> dict:
145146 },
146147 "rust" : {
147148 "sdist_present_extra" : ["rust" , "src" , "Cargo.toml" , "Cargo.lock" ],
148- "sdist_absent_extra" : [".gitattributes" , "target" ],
149+ "sdist_absent_extra" : [".gitattributes" , ".vscode" , " target" ],
149150 "wheel_absent_extra" : ["rust" , "src" , "Cargo.toml" ],
150151 },
151152 "js" : {
@@ -216,12 +217,18 @@ def _module_name_from_project(project_name: str) -> str:
216217 return re .sub (r"[\s-]+" , "_" , project_name ).strip ("_" )
217218
218219
219- def copier_defaults (copier_config : dict ) -> dict | None :
220+ def copier_defaults (copier_config : dict , hatch_config : dict | None = None ) -> dict | None :
220221 """Derive default check-dist config from copier answers.
221222
222223 Returns a config dict with the same shape as ``load_config`` output,
223224 or ``None`` if deriving defaults is not possible (no ``add_extension``
224225 key, or unknown extension type).
226+
227+ When *hatch_config* is provided, ``sdist_present_extra`` patterns are
228+ filtered against the hatch sdist configuration to only require paths
229+ that will actually appear in the archive. Follows hatch precedence:
230+ ``only-include`` (exhaustive) > ``packages`` (used as ``only-include``
231+ fallback) > ``include`` (filter on full tree).
225232 """
226233 extension = copier_config .get ("add_extension" )
227234 project_name = copier_config .get ("project_name" )
@@ -234,7 +241,12 @@ def copier_defaults(copier_config: dict) -> dict | None:
234241
235242 module = _module_name_from_project (project_name )
236243
237- sdist_present = [module , * ext_defaults .get ("sdist_present_extra" , []), * _COMMON_SDIST_PRESENT ]
244+ sdist_present_extra = list (ext_defaults .get ("sdist_present_extra" , []))
245+
246+ if hatch_config :
247+ sdist_present_extra = _filter_extras_by_hatch (sdist_present_extra , hatch_config )
248+
249+ sdist_present = [module , * sdist_present_extra , * _COMMON_SDIST_PRESENT ]
238250 sdist_absent = [* _COMMON_SDIST_ABSENT , * ext_defaults .get ("sdist_absent_extra" , [])]
239251 wheel_present = [module ]
240252 wheel_absent = [* _COMMON_WHEEL_ABSENT , * ext_defaults .get ("wheel_absent_extra" , [])]
@@ -245,6 +257,50 @@ def copier_defaults(copier_config: dict) -> dict | None:
245257 }
246258
247259
260+ def _filter_extras_by_hatch (extras : list [str ], hatch_config : dict ) -> list [str ]:
261+ """Filter copier sdist_present_extra against the hatch sdist config.
262+
263+ Uses the same precedence as hatchling:
264+ 1. ``only-include`` — exhaustive; keep extras that appear in the list.
265+ 2. ``packages`` (when no ``only-include``) — acts as ``only-include``
266+ fallback; keep extras that appear in the list.
267+ 3. ``include`` — keep extras that match an include pattern.
268+ 4. ``force-include`` — destinations are always present; keep extras
269+ whose path appears as a force-include destination.
270+ 5. No constraints — keep everything.
271+ """
272+ sdist_cfg = hatch_config .get ("targets" , {}).get ("sdist" , {})
273+ only_include = sdist_cfg .get ("only-include" )
274+ packages = sdist_cfg .get ("packages" )
275+ includes = sdist_cfg .get ("include" )
276+ force_include = sdist_cfg .get ("force-include" ) or hatch_config .get ("force-include" ) or {}
277+
278+ # Collect the set of paths that will appear in the sdist.
279+ allowed : set [str ] | None = None
280+
281+ if only_include is not None :
282+ allowed = set (only_include )
283+ elif packages is not None :
284+ # Hatch treats packages as only-include (explicit walk).
285+ # include is bypassed during an explicit walk.
286+ allowed = set (packages )
287+
288+ # force-include destinations are always present.
289+ force_paths = {v .strip ("/" ) for v in force_include .values ()}
290+
291+ if allowed is not None :
292+ allowed |= force_paths
293+ return [p for p in extras if p in allowed ]
294+
295+ # include-only (no packages, no only-include): filter extras.
296+ if includes is not None :
297+ include_set = set (includes ) | force_paths
298+ return [p for p in extras if p in include_set ]
299+
300+ # No restrictions — keep all extras.
301+ return extras
302+
303+
248304# ── Building ──────────────────────────────────────────────────────────
249305
250306
@@ -295,6 +351,8 @@ def find_dist_files(output_dir: str) -> tuple[str | None, str | None]:
295351 """Return ``(sdist_path, wheel_path)`` found in *output_dir*."""
296352 sdist_path = None
297353 wheel_path = None
354+ if not os .path .isdir (output_dir ):
355+ return None , None
298356 for name in os .listdir (output_dir ):
299357 if name .endswith ((".tar.gz" , ".zip" )):
300358 sdist_path = os .path .join (output_dir , name )
@@ -303,6 +361,23 @@ def find_dist_files(output_dir: str) -> tuple[str | None, str | None]:
303361 return sdist_path , wheel_path
304362
305363
364+ _DEFAULT_DIST_DIRS = ["dist" , "wheelhouse" ]
365+
366+
367+ def _find_pre_built (source_dir : str ) -> str | None :
368+ """Search default directories for pre-built distributions.
369+
370+ Checks ``dist/`` and ``wheelhouse/`` under *source_dir*. Returns the
371+ first directory that contains at least one sdist or wheel, or ``None``.
372+ """
373+ for dirname in _DEFAULT_DIST_DIRS :
374+ candidate = os .path .join (source_dir , dirname )
375+ sdist , wheel = find_dist_files (candidate )
376+ if sdist or wheel :
377+ return candidate
378+ return None
379+
380+
306381# ── Listing files ─────────────────────────────────────────────────────
307382
308383
@@ -470,44 +545,63 @@ def _sdist_expected_files(vcs_files: list[str], hatch_config: dict) -> set[str]:
470545 """Derive the set of VCS files we expect to see in the sdist,
471546 taking ``[tool.hatch.build.targets.sdist]`` into account.
472547
473- This returns files under the declared ``packages``, ``only-include``,
474- or ``include`` patterns — i.e. the *source* files that must be present.
475- Top-level metadata files (pyproject.toml, README, LICENSE, …) are
476- intentionally left to the ``present``/``absent`` checks in the user config.
548+ Follows hatchling's precedence:
549+
550+ 1. ``only-include`` is an exhaustive list of paths to walk.
551+ 2. If absent, ``packages`` is used as ``only-include`` (hatch fallback).
552+ 3. If neither is set and ``include`` is present, only files matching
553+ ``include`` patterns are kept.
554+ 4. ``exclude`` always removes files (except force-included ones).
555+ 5. ``force-include`` entries are always present regardless of other
556+ settings. Hatch also auto-force-includes ``pyproject.toml``,
557+ ``.gitignore``, README, and LICENSE files.
477558 """
478559 sdist_cfg = hatch_config .get ("targets" , {}).get ("sdist" , {})
479560 only_include = sdist_cfg .get ("only-include" )
480561 packages = sdist_cfg .get ("packages" )
481562 includes = sdist_cfg .get ("include" , [])
482563 excludes = sdist_cfg .get ("exclude" , [])
564+ force_include = sdist_cfg .get ("force-include" , {})
483565
484- # Determine scan paths following hatch's precedence:
485- # only-include > packages > (everything)
486- if only_include is not None :
487- scan_paths = only_include
488- elif packages is not None :
489- scan_paths = packages
490- else :
491- scan_paths = None
566+ # Also pick up global-level force-include (target overrides global,
567+ # but if only global is set, use it).
568+ if not force_include :
569+ force_include = hatch_config .get ("force-include" , {})
570+
571+ # Step 1: Determine base set from only-include / packages / include.
572+ # Hatch's fallback: only_include = configured or (packages or [])
573+ # When truthy, only those directory roots are walked.
574+ scan_paths = only_include if only_include is not None else packages
492575
493576 expected = set ()
494- for f in vcs_files :
495- if scan_paths is not None :
496- under_path = any (f == p or f .startswith (p .rstrip ("/" ) + "/" ) for p in scan_paths )
497- if under_path :
577+ if scan_paths is not None :
578+ for f in vcs_files :
579+ if any (f == p or f .startswith (p .rstrip ("/" ) + "/" ) for p in scan_paths ):
498580 expected .add (f )
499- else :
500- # No explicit restrictions – everything in VCS is expected
501- expected .add (f )
502-
503- if includes :
504- # TODO:
505- pass
581+ elif includes :
582+ # No only-include or packages: full tree walk, but include
583+ # patterns act as a filter.
584+ for f in vcs_files :
585+ if any (_matches_hatch_pattern (f , inc ) for inc in includes ):
586+ expected .add (f )
587+ else :
588+ # No restrictions — everything in VCS is expected.
589+ expected = set (vcs_files )
506590
507- # Apply excludes
591+ # Step 2: Apply excludes (never affects force-include).
508592 if excludes :
509593 expected = {f for f in expected if not any (_matches_hatch_pattern (f , exc ) for exc in excludes )}
510594
595+ # Step 3: Add force-include destinations.
596+ # force-include is {source: dest} — we care about the dest paths
597+ # since those are what appear in the archive.
598+ for dest in force_include .values ():
599+ dest = dest .strip ("/" )
600+ # If the dest matches a VCS file, add it.
601+ for f in vcs_files :
602+ if f == dest or f .startswith (dest .rstrip ("/" ) + "/" ):
603+ expected .add (f )
604+
511605 return expected
512606
513607
@@ -567,6 +661,7 @@ def check_dist(
567661 no_isolation : bool = False ,
568662 verbose : bool = False ,
569663 pre_built : str | None = None ,
664+ rebuild : bool = False ,
570665) -> tuple [bool , list [str ]]:
571666 """Run all distribution checks.
572667
@@ -582,6 +677,9 @@ def check_dist(
582677 If given, skip building and use existing dist files from this
583678 directory. Useful when native toolchains have already produced
584679 the archives.
680+ rebuild:
681+ Force a fresh build even when pre-built distributions exist in
682+ ``dist/`` or ``wheelhouse/``.
585683
586684 Returns ``(success, messages)``.
587685 """
@@ -597,7 +695,20 @@ def check_dist(
597695 dist_dir = os .path .abspath (pre_built )
598696 messages .append (f"Using pre-built distributions from { dist_dir } " )
599697 sdist_path , wheel_path = find_dist_files (dist_dir )
698+ elif not rebuild :
699+ # Auto-detect pre-built dists in dist/ or wheelhouse/
700+ detected = _find_pre_built (source_dir )
701+ if detected is not None :
702+ dist_dir = detected
703+ messages .append (f"Using pre-built distributions from { dist_dir } " )
704+ sdist_path , wheel_path = find_dist_files (dist_dir )
705+ pre_built = dist_dir # so downstream logic treats it as pre-built
706+ else :
707+ pre_built = None # fall through to build
600708 else :
709+ pre_built = None # --rebuild: ignore any existing dists
710+
711+ if pre_built is None :
601712 tmpdir_ctx = tempfile .TemporaryDirectory (prefix = "check-dist-" )
602713 tmpdir = tmpdir_ctx .__enter__ ()
603714 try :
0 commit comments