@@ -211,6 +211,47 @@ def display_name(self) -> str:
211211 return f"s3://{ self .bucket } "
212212
213213
214+ class DryRunStore (Store ):
215+ """Wrapper that validates write credentials without actually uploading.
216+
217+ Reads are delegated to the inner store. Writes use ``aws s3 cp --dryrun``
218+ (for S3 stores) so that IAM permissions are verified, but no data is
219+ written. For local stores, writes are silently skipped.
220+ """
221+
222+ def __init__ (self , inner : Store ):
223+ self .inner = inner
224+
225+ def read (self , key : str ) -> bytes | None :
226+ return self .inner .read (key )
227+
228+ def write (self , key : str , data : bytes ) -> None :
229+ with tempfile .NamedTemporaryFile (delete = False ) as f :
230+ f .write (data )
231+ tmp_path = f .name
232+ try :
233+ self .write_file (key , Path (tmp_path ))
234+ finally :
235+ os .unlink (tmp_path )
236+
237+ def write_file (self , key : str , local_path : Path ) -> None :
238+ if isinstance (self .inner , S3Store ):
239+ dest = f"s3://{ self .inner .bucket } /{ key } "
240+ _info (f" { local_path .name } -> { dest } (dry-run)" )
241+ subprocess .run (
242+ ["aws" , "s3" , "cp" , str (local_path ), dest , "--dryrun" ],
243+ check = True ,
244+ )
245+ else :
246+ _info (f" dry-run: would write { key } " )
247+
248+ def list_versions (self ) -> list [str ]:
249+ return self .inner .list_versions ()
250+
251+ def display_name (self ) -> str :
252+ return self .inner .display_name ()
253+
254+
214255def _parse_store (spec : str ) -> Store :
215256 """Parse a store specification into a Store instance."""
216257 if spec .startswith ("s3://" ):
@@ -409,56 +450,23 @@ def _publish_full(
409450 manifest = _merge_manifest (store , fixtures_json , version , prev )
410451 manifest_json = json .dumps (manifest , indent = 2 ) + "\n "
411452
412- if args .dry_run :
413- _info (f"dry run — would publish to { store .display_name ()} " )
414- existing = _read_manifest (store , version )
415- if existing :
416- existing .pop ("_prefix" , None )
417- existing_names = {e ["name" ] for e in existing ["fixtures" ]}
418- new_names = {e ["name" ] for e in manifest ["fixtures" ]}
419- added = new_names - existing_names
420- removed = existing_names - new_names
421- if added :
422- _info (f" new fixtures: { ', ' .join (sorted (added ))} " )
423- if removed :
424- _info (f" removed fixtures: { ', ' .join (sorted (removed ))} " )
425- if not added and not removed :
426- _info (f" same { len (new_names )} fixtures as existing" )
427- _info (" target paths:" )
428- for entry in manifest ["fixtures" ]:
429- _info (f" { store .display_name ()} /v{ version } /arrays/{ entry ['name' ]} " )
430- _info (f" { store .display_name ()} /v{ version } /arrays/manifest.json" )
431- _info (f" { store .display_name ()} /versions.json" )
432- if version not in versions :
433- updated_versions = sorted (versions + [version ], key = _version_sort_key )
434- _info (f" versions.json would update: { versions } -> { updated_versions } " )
435- else :
436- _info (f" versions.json unchanged: { versions } " )
437- return
438-
439- if not args .yes :
440- _info (f"\n about to upload { len (manifest ['fixtures' ])} fixtures for v{ version } to { store .display_name ()} " )
441- answer = input ("proceed? [y/N] " ).strip ().lower ()
442- if answer not in ("y" , "yes" ):
443- _info ("aborted" )
444- sys .exit (1 )
453+ write_store = _write_store (
454+ store ,
455+ args ,
456+ f"\n about to upload { len (manifest ['fixtures' ])} fixtures for v{ version } to { store .display_name ()} " ,
457+ )
445458
446- _info (f"uploading { len (manifest ['fixtures' ])} fixtures to { store .display_name ()} ..." )
447459 _parallel_upload (
448- store ,
460+ write_store ,
449461 [(f"v{ version } /arrays/{ e ['name' ]} " , output / e ["name" ]) for e in manifest ["fixtures" ]],
450462 )
451463
452- store .write (f"v{ version } /arrays/manifest.json" , manifest_json .encode ())
453- _info (" uploaded manifest.json" )
464+ write_store .write (f"v{ version } /arrays/manifest.json" , manifest_json .encode ())
454465
455466 if version not in versions :
456467 versions .append (version )
457468 versions .sort (key = _version_sort_key )
458- store .write ("versions.json" , (json .dumps (versions , indent = 2 ) + "\n " ).encode ())
459- _info (" updated versions.json" )
460-
461- _info (f"\n done: { len (manifest ['fixtures' ])} fixtures for v{ version } published to { store .display_name ()} " )
469+ write_store .write ("versions.json" , (json .dumps (versions , indent = 2 ) + "\n " ).encode ())
462470
463471
464472def _publish_update (
@@ -510,24 +518,15 @@ def _publish_update(
510518 _info ("no new fixtures to add" )
511519 return
512520
513- if args .dry_run :
514- _info (f"dry run — would upload { len (new_fixtures )} new fixture(s):" )
515- for name in new_fixtures :
516- _info (f" { store .display_name ()} /{ prefix } /{ name } " )
517- _info (f" { store .display_name ()} /{ prefix } /manifest.json (updated)" )
518- return
519-
520- if not args .yes :
521- _info (f"\n about to upload { len (new_fixtures )} new fixture(s) for v{ version } to { store .display_name ()} " )
522- answer = input ("proceed? [y/N] " ).strip ().lower ()
523- if answer not in ("y" , "yes" ):
524- _info ("aborted" )
525- sys .exit (1 )
521+ write_store = _write_store (
522+ store ,
523+ args ,
524+ f"\n about to upload { len (new_fixtures )} new fixture(s) for v{ version } to { store .display_name ()} " ,
525+ )
526526
527- # Upload only new fixture files.
528527 new_fixture_names = set (new_fixtures )
529528 _parallel_upload (
530- store ,
529+ write_store ,
531530 [(f"{ prefix } /{ name } " , output / name ) for name in new_fixtures ],
532531 )
533532
@@ -542,10 +541,7 @@ def _publish_update(
542541 "generated_at" : datetime .now (UTC ).isoformat (),
543542 "fixtures" : new_entries ,
544543 }
545- store .write (f"{ prefix } /manifest.json" , (json .dumps (updated_manifest , indent = 2 ) + "\n " ).encode ())
546- _info (" updated manifest.json" )
547-
548- _info (f"\n done: added { len (new_fixtures )} new fixture(s) to v{ version } in { store .display_name ()} " )
544+ write_store .write (f"{ prefix } /manifest.json" , (json .dumps (updated_manifest , indent = 2 ) + "\n " ).encode ())
549545
550546
551547def cmd_check (args : argparse .Namespace ) -> None :
@@ -748,6 +744,19 @@ def cmd_validate_manifest(args: argparse.Namespace) -> None:
748744# ---------------------------------------------------------------------------
749745
750746
747+ def _write_store (store : Store , args : argparse .Namespace , prompt : str ) -> Store :
748+ """Return a DryRunStore wrapper or the real store, with optional confirmation."""
749+ if args .dry_run :
750+ return DryRunStore (store )
751+ if not args .yes :
752+ _info (prompt )
753+ answer = input ("proceed? [y/N] " ).strip ().lower ()
754+ if answer not in ("y" , "yes" ):
755+ _info ("aborted" )
756+ sys .exit (1 )
757+ return store
758+
759+
751760def _parallel_upload (store : Store , items : list [tuple [str , Path ]], max_workers : int = 8 ) -> None :
752761 """Upload files to the store in parallel."""
753762 with ThreadPoolExecutor (max_workers = max_workers ) as pool :
@@ -944,7 +953,7 @@ def main() -> None:
944953 p .add_argument (
945954 "--dry-run" ,
946955 action = "store_true" ,
947- help = "Generate and show manifest, but don't upload " ,
956+ help = "Validate credentials and show what would be uploaded, without writing " ,
948957 )
949958 p .add_argument (
950959 "--force" ,
0 commit comments