Skip to content

Project

Recipes are stable identities with append-only revisions. Primary product API:

Intent Call
Deploy current definition deploy_recipe(name, spec, cases=…)
Run current revision recipe.run() then run.wait() (or run_recipe(recipe))
Typed partial edit recipe.update(cases=…)
Explicit first create only create_recipe(name, spec) (conflicts if name exists)
Strict create-or-verify get_or_create_recipe (raises RecipeSpecConflictError on mismatch)

deploy_recipe creates the recipe when absent, reuses it when the effective specification matches, and activates a new immutable revision when it differs. It requires an admitted binding through cases= (or one already carried by the advanced RecipeSpec). Use create_recipe only to intentionally store an unbound definition that cannot run on binding-required backends.

Capability discovery

Recipe specs take reader and writer ids as plain strings (writer="parquet_tabular"). deploy_recipe and validate_recipe resolve the writer id against the project before the spec leaves the client and raise ValidationError when the deployment does not publish it.

available_writers and available_readers are the discovery surface for those ids. Writer ids name the tabular/output formats a deployment can produce ("parquet_tabular"). Reader ids are the Sigma reader class_name values registered on the deployment — the same strings you pass to SigmaClassRef(class_name=...). Use the catalogs to list what is on offer, or to resolve an id yourself and get a "did you mean" suggestion for a near miss:

print(list(project.available_writers))
print(list(project.available_readers))
writer = project.available_writers.require("parquet_tabular")
reader = project.available_readers.require("vtkXdmfReader")

Both catalogs come from a single cached capability fetch per project (get_recipe_capabilities(refresh=True) forces a new one). deploy_recipe / validate_recipe check a Converter's reader the same way they check a generator's writer; leave the Sigma reader unset for auto-detect.

See Exceptions and recipe_lifecycle.py.

project

Project

User-friendly wrapper for a Nexus project.

This class provides a synchronous interface to project operations, including listing collections, creating new collections, and displaying project information. It acts as a facade over AsyncProject to provide a simpler, synchronous API.

Attributes:

Name Type Description
uuid str

Unique identifier for the project

name

Human-readable name of the project

owner_id

ID of the project owner

created_at

Project creation timestamp

updated_at

Last modification timestamp

metadata

Dictionary containing project metadata

project_uuid

Alias for uuid (for backward compatibility)

project_name

Alias for name (for backward compatibility)

Source code in src/miura/api/sync/project.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
class Project:
    """
    User-friendly wrapper for a Nexus project.

    This class provides a synchronous interface to project operations,
    including listing collections, creating new collections, and displaying
    project information. It acts as a facade over AsyncProject to provide
    a simpler, synchronous API.

    Attributes:
        uuid: Unique identifier for the project
        name: Human-readable name of the project
        owner_id: ID of the project owner
        created_at: Project creation timestamp
        updated_at: Last modification timestamp
        metadata: Dictionary containing project metadata
        project_uuid: Alias for uuid (for backward compatibility)
        project_name: Alias for name (for backward compatibility)
    """

    def __init__(
        self,
        project_data: Dict[str, Any],
        nexus_client: Any,  # NexusClient from parent
        runner: Any,  # _LoopThread from parent Nexus
        async_project: AsyncProject,  # AsyncProject instance (required)
    ):
        """
        Initialize a Project wrapper.

        Args:
            project_data: Project information dictionary
            nexus_client: The underlying NexusClient instance (from parent)
            runner: The loop thread for executing async operations (from parent Nexus)
            async_project: AsyncProject instance (required)
        """
        # Store reference to parent Nexus's runner (shared loop)
        self._nexus_client = nexus_client
        self._runner = runner

        # Store AsyncProject instance and extract attributes
        self._async = async_project
        self._uuid = async_project.id
        self.name = async_project.name
        self.owner_id = async_project.owner_id
        self.created_at = async_project.created_at
        self.updated_at = async_project.updated_at
        self.metadata = async_project.metadata

        # Initialize logger after name is set (instance-specific namespace)
        self.logger = get_logger(f"{__name__}.{self.name}")

        # Store project_data for Collection instantiation
        self._project_data = project_data

    @property
    def id(self) -> str:
        """Stable project identifier (value may be a UUID)."""
        return self._uuid

    @property
    def uuid(self) -> str:
        """Alias for id (UUID), same as :attr:`AsyncProject.uuid`."""
        return self._uuid

    def _handle_auth_errors(self, e: Exception) -> None:
        """Delegate to :func:`miura.api.auth_errors.handle_auth_errors`."""
        from miura.api.auth_errors import handle_auth_errors

        handle_auth_errors(self.logger, e)

    def list_collections(
        self,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> List["Collection"]:
        """
        List all collections in this project.

        Returns:
            List of Collection wrapper objects

        Raises:
            RuntimeError: If AsyncProject is not available
            Exception: If the API request fails or authentication errors occur
        """
        from .collection import Collection
        from miura.api.collection import AsyncCollection

        try:
            future = self._runner.submit(
                self._async.list_collections(
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            collection_infos = future.result()

            # Convert CollectionInfo to Collection wrappers
            collections = []
            for collection_info in collection_infos:
                # Convert CollectionInfo to dict format
                collection_data = {
                    "uuid": collection_info.id,
                    "name": collection_info.name,
                    "created_at": collection_info.created_at,
                    "updated_at": collection_info.updated_at,
                    "metadata": collection_info.metadata,
                }

                # Create AsyncCollection from collection data
                async_collection = AsyncCollection(
                    collection_data=collection_data,
                    project=self._async,
                    nexus_client=self._nexus_client,
                )

                # Wrap in Collection
                collection = Collection(
                    collection_data=collection_data,
                    project_data=self._project_data,
                    nexus_client=self._nexus_client,
                    runner=self._runner,
                    async_collection=async_collection,
                )
                collections.append(collection)

            return collections
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in list_collections: {e}")
            raise

    def get_collection(
        self,
        collection_name: str,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> "Collection":
        """
        Get a collection by name, returning a Collection wrapper object.

        Args:
            collection_name: Name of the collection to retrieve

        Returns:
            Collection wrapper object for the specified collection

        Raises:
            RuntimeError: If AsyncProject is not available
            CollectionNotFoundError: If the collection with the given name is not found
            Exception: If the API request fails or authentication errors occur
        """
        from .collection import Collection

        try:
            future = self._runner.submit(
                self._async.get_collection(
                    collection_name,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            async_collection = future.result()

            # Convert AsyncCollection to Collection wrapper
            collection_data = {
                "uuid": async_collection.id,
                "name": async_collection.name,
                "created_at": async_collection.created_at,
                "updated_at": async_collection.updated_at,
                "metadata": {"attributes": dict(async_collection.attributes)},
            }

            collection = Collection(
                collection_data=collection_data,
                project_data=self._project_data,
                nexus_client=self._nexus_client,
                runner=self._runner,
                async_collection=async_collection,
            )
            return collection
        except CollectionNotFoundError:
            # Expected miss path for get_or_create_collection; do not log as error
            raise
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in get_collection: {e}")
            raise

    def create_collection(
        self,
        collection_name: str,
        schema: Any,
        *,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> "Collection":
        """
        Create a new collection in this project.

        Args:
            collection_name: Name of the collection (same parameter as
                :meth:`miura.api.project.AsyncProject.create_collection`)
            schema: Schema definition for the collection

        Returns:
            Collection wrapper object for the newly created collection

        Raises:
            ConflictError: If a collection with the same name already exists
        """
        from .collection import Collection

        try:
            compiled_schema = normalize_collection_schema(schema)
            create_kwargs: dict[str, Any] = {
                "retry_policy": retry_policy,
                "timeouts": timeouts,
                "hooks": hooks,
            }
            future = self._runner.submit(
                self._async.create_collection(collection_name, compiled_schema, **create_kwargs)
            )
            async_collection = future.result()

            # Convert AsyncCollection to Collection wrapper
            collection_data = {
                "uuid": async_collection.id,
                "name": async_collection.name,
                "created_at": async_collection.created_at,
                "updated_at": async_collection.updated_at,
                "metadata": {"attributes": dict(async_collection.attributes)},
            }

            collection = Collection(
                collection_data=collection_data,
                project_data=self._project_data,
                nexus_client=self._nexus_client,
                runner=self._runner,
                async_collection=async_collection,
            )
            return collection
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in create_collection for project '{self.name}': {e}")
            raise

    def get_or_create_collection(
        self,
        collection_name: str,
        schema: Dict[str, Any],
        *,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> "Collection":
        """
        Get an existing collection by name, or create it if it does not exist.

        Args:
            collection_name: Name of the collection to get or create (same as
                :meth:`miura.api.project.AsyncProject.get_or_create_collection`)
            schema: Schema definition (used only when creating)

        Returns:
            Collection wrapper object for the existing or newly created collection
        """
        try:
            return self.get_collection(
                collection_name,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        except CollectionNotFoundError:
            return self.create_collection(
                collection_name,
                schema,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )

    def iter_collections(
        self,
        prefetch_pages: int = 1,
        page_size: int = 50,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> Iterator["Collection"]:
        """
        Iterate over all collections with transparent pagination.

        This method provides a sync iterator that yields collections one at a time,
        handling pagination transparently. It wraps the async iterator from AsyncProject.

        Args:
            prefetch_pages: Number of pages to prefetch (default: 1, max: 5)
            page_size: Number of items per page (default: 50, max: 1000)

        Yields:
            Collection wrapper objects

        Note:
            Pagination stability: Results may be reordered if backend changes.
            For deterministic ordering, backend should support sort_by parameter.
        """
        from .collection import Collection
        from miura.api.collection import AsyncCollection

        # Get async generator from AsyncProject
        agen = self._async.iter_collections(
            prefetch_pages=prefetch_pages,
            page_size=page_size,
            retry_policy=retry_policy,
            timeouts=timeouts,
            hooks=hooks,
        )

        try:
            while True:
                # Submit __anext__ to get next item
                fut = self._runner.submit(agen.__anext__())
                try:
                    collection_info = fut.result()

                    # Convert CollectionInfo to Collection wrapper
                    collection_data = {
                        "uuid": collection_info.id,
                        "name": collection_info.name,
                        "created_at": collection_info.created_at,
                        "updated_at": collection_info.updated_at,
                        "metadata": collection_info.metadata,
                    }

                    # Create AsyncCollection from collection data
                    async_collection = AsyncCollection(
                        collection_data=collection_data,
                        project=self._async,
                        nexus_client=self._nexus_client,
                    )

                    # Wrap in Collection
                    collection = Collection(
                        collection_data=collection_data,
                        project_data=self._project_data,
                        nexus_client=self._nexus_client,
                        runner=self._runner,
                        async_collection=async_collection,
                    )
                    yield collection
                except StopAsyncIteration:
                    break
        except (GeneratorExit, KeyboardInterrupt):
            # Graceful cancellation: cancel underlying async generator
            try:
                close_fut = self._runner.submit(agen.aclose())
                close_fut.result(timeout=2.0)
            except Exception:
                pass  # Ignore errors during cleanup
            raise

    def update_project(
        self,
        metadata: Optional[Dict[str, Any]] = None,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> None:
        """
        Update project metadata.

        Args:
            metadata: Optional metadata dictionary to update

        Raises:
            NotImplementedError: Same as :meth:`AsyncProject.update_project` until implemented.
        """
        try:
            future = self._runner.submit(
                self._async.update_project(
                    metadata,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in update_project for project '{self.name}': {e}")
            raise

    def delete_project(
        self,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> None:
        """
        Delete this project and all associated resources.

        Raises:
            RuntimeError: If AsyncProject is not available
            ProjectNotFoundError: If project is not found
            NetworkError: If network request fails
            PermissionError: If authentication fails
        """
        try:
            future = self._runner.submit(
                self._async.delete_project(
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error deleting project '{self.name}': {e}")
            raise

    @property
    def info(self) -> "ProjectInfo":
        """
        Get the underlying ProjectInfo DTO.

        This provides access to the raw data structure for serialization,
        caching, or other internal operations.

        Returns:
            The underlying ProjectInfo data
        """
        from miura.sdk.types import ProjectInfo, normalize_owner_profile

        return ProjectInfo(
            uuid=self._uuid,
            name=self.name,
            owner_id=self.owner_id,
            created_at=self.created_at or "",
            updated_at=self.updated_at or "",
            metadata=self.metadata or {},
            owner_profile=normalize_owner_profile(self._project_data.get("owner")),
        )

    def print_info(self) -> None:
        """Print comprehensive information about the project, collections, and recipes."""
        project_info = self.info

        try:
            collections = self.list_collections()
            recipes = self.list_recipes()
            # Convert collections to CollectionInfo for display
            collection_infos = [
                CollectionInfo(
                    uuid=c.id,
                    name=c.name,
                    project_uuid=c.project_id,
                    created_at=c.created_at or "",
                    updated_at=c.updated_at or "",
                    metadata={"attributes": dict(c.attributes)},
                )
                for c in collections
            ]
            print_project_info(project_info, collections=collection_infos, recipes=recipes)
        except Exception as e:
            self.logger.error(f"Error in print_info: {e}")
            # Print project info without collections/recipes
            print_project_info(project_info, collections=None, recipes=None)
            raise

    def __str__(self):
        """String representation of the project for printing."""
        return self._get_info_string()

    def __repr__(self):
        """Detailed representation of the project."""
        collections_count = len(self.list_collections()) if hasattr(self, "_collections_cache") else "unknown"
        return f"Project(name='{self.name}', id='{self._uuid}', collections_count={collections_count})"

    def _format_metadata_value(self, value: Any) -> str:
        """Format metadata values for pretty display."""
        if isinstance(value, dict):
            # Show key names and types for better understanding
            if len(value) > 6:  # Large dictionary
                key_preview = ", ".join(list(value.keys())[:3])
                remaining = len(value) - 3
                if remaining > 0:
                    return f"<dict: {key_preview}... (+{remaining} more)>"
                else:
                    return f"<dict: {key_preview}>"
            else:
                # Pretty format smaller dictionaries
                items = []
                for k, v in value.items():
                    if isinstance(v, (dict, list)):
                        items.append(f"{k}: <{type(v).__name__}>")
                    elif isinstance(v, str) and len(v) > 30:
                        items.append(f"{k}: {v[:27]}...")
                    else:
                        items.append(f"{k}: {v}")
                return "{" + ", ".join(items) + "}"
        elif isinstance(value, list):
            if len(value) > 5:  # Long list
                return f"<list with {len(value)} items>"
            else:
                return str(value)
        elif isinstance(value, str) and len(value) > 50:
            return f"{value[:47]}..."
        else:
            return str(value)

    def _get_info_string(self) -> str:
        """Get the information string without printing."""
        import io
        import sys

        # Capture the print output
        old_stdout = sys.stdout
        sys.stdout = buffer = io.StringIO()

        try:
            self.print_info()
            return buffer.getvalue()
        finally:
            sys.stdout = old_stdout

    def list_recipes(
        self,
        source_collection_name: Optional[str] = None,
        source_group_name: Optional[str] = None,
        target_collection_name: Optional[str] = None,
        target_group_name: Optional[str] = None,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> List[RecipeResponse]:
        """
        List all recipes in this project.

        Args:
            source_collection_name: Optional filter by source collection name
            source_group_name: Optional filter by source group name
            target_collection_name: Optional filter by target collection name
            target_group_name: Optional filter by target group name
            retry_policy: Optional per-call retry policy override (same as :meth:`AsyncProject.list_recipes`)
            timeouts: Optional per-call timeout override
            hooks: Optional per-call hooks override

        Returns:
            List of RecipeResponse objects
        """
        try:
            future = self._runner.submit(
                self._async.list_recipes(
                    source_collection_name=source_collection_name,
                    source_group_name=source_group_name,
                    target_collection_name=target_collection_name,
                    target_group_name=target_group_name,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            return to_sync_recipe_responses(
                future.result(),
                project=self,
                async_project=self._async,
                runner=self._runner,
            )
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in list_recipes for project '{self.name}': {e}")
            raise

    def get_recipe(
        self,
        recipe_name: str,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> RecipeResponse:
        """
        Get a recipe by name.

        Args:
            recipe_name: Name of the recipe (must be unique within project)
            retry_policy: Optional per-call retry policy override (same as :meth:`AsyncProject.get_recipe`)
            timeouts: Optional per-call timeout override
            hooks: Optional per-call hooks override

        Returns:
            RecipeResponse with recipe data

        Raises:
            RecipeNotFoundError: If recipe not found
        """
        try:
            future = self._runner.submit(
                self._async.get_recipe(
                    recipe_name,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            return to_sync_recipe_response(
                future.result(),
                project=self,
                async_project=self._async,
                runner=self._runner,
            )
        except RecipeNotFoundError:
            # Expected miss path for get_or_create_recipe (async); do not log as error
            raise
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in get_recipe for project '{self.name}': {e}")
            raise

    def get_or_create_recipe(
        self,
        name: str,
        spec: RecipeSpec,
        *,
        description: str = "",
        submit: bool = False,
        options: Optional[RecipeCreateOptions] = None,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> RecipeResponse:
        """
        Get a recipe by name, or create it from a spec if not found (same contract as async).

        Raises ``RecipeSpecConflictError`` when an existing recipe's stored executable
        specification differs from ``spec`` (existing recipe is left unchanged).
        """
        from miura.sdk.exceptions import RecipeSpecConflictError

        try:
            future = self._runner.submit(
                self._async.get_or_create_recipe(
                    name,
                    spec,
                    description=description,
                    submit=submit,
                    options=options,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            return to_sync_recipe_response(
                future.result(),
                project=self,
                async_project=self._async,
                runner=self._runner,
            )
        except RecipeSpecConflictError:
            raise
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in get_or_create_recipe for project '{self.name}': {e}")
            raise

    def validate_recipe_spec(self, spec: RecipeSpec) -> ValidationResult:
        """
        Validate a RecipeSpec (same contract as :meth:`AsyncProject.validate_recipe_spec`).

        A writer id the project does not publish raises
        :class:`~miura.sdk.exceptions.ValidationError` before the call is made.
        A Converter reader id that is not published raises the same error.
        """
        try:
            future = self._runner.submit(self._async.validate_recipe_spec(spec))
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in validate_recipe_spec for project '{self.name}': {e}")
            raise

    def validate_recipe_payload(self, payload: Dict[str, Any]) -> ValidationResult:
        """Validate a recipe payload dict (same contract as :meth:`AsyncProject.validate_recipe_payload`)."""
        try:
            future = self._runner.submit(self._async.validate_recipe_payload(payload))
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in validate_recipe_payload for project '{self.name}': {e}")
            raise

    def validate_recipe(self, spec_or_payload: Union[RecipeSpec, Dict[str, Any]]) -> ValidationResult:
        """Validate a recipe (same contract as :meth:`AsyncProject.validate_recipe`)."""
        try:
            future = self._runner.submit(self._async.validate_recipe(spec_or_payload))
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in validate_recipe for project '{self.name}': {e}")
            raise

    def get_recipe_capabilities(self, *, refresh: bool = False) -> Capabilities:
        """
        Return typed recipe capabilities (cached on the async twin).

        Same contract as :meth:`AsyncProject.get_recipe_capabilities`. Prefer
        :attr:`available_writers` / :attr:`available_readers` when you only need
        a single id.
        """
        try:
            future = self._runner.submit(self._async.get_recipe_capabilities(refresh=refresh))
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in get_recipe_capabilities for project '{self.name}': {e}")
            raise

    @property
    def available_writers(self) -> CapabilityCatalog:
        """
        Writer ids this project can produce, fetched once and cached.

        Recipe specs take the writer id directly (``writer="parquet_tabular"``)
        and :meth:`deploy_recipe` rejects an id the project does not publish.
        Reach for this catalog when you want to see what is on offer, or to
        resolve an id yourself and get a "did you mean" hint for a typo::

            print(list(project.available_writers))
            writer = project.available_writers.require("parquet_tabular")
        """
        try:
            future = self._runner.submit(self._async.available_writers())
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in available_writers for project '{self.name}': {e}")
            raise

    @property
    def available_readers(self) -> CapabilityCatalog:
        """
        Reader ids this project can consume, fetched once and cached.

        These ids are the Sigma reader ``class_name`` values registered on the
        deployment (e.g. ``"vtkXdmfReader"``). Pass the same string to
        ``SigmaClassRef(class_name=...)`` when building a converter;
        :meth:`deploy_recipe` rejects a Converter whose reader is not published.
        Leave the Sigma reader unset for auto-detect — that path is not checked.

        Use this catalog to list what is on offer, or to resolve an id yourself::

            print(list(project.available_readers))
            reader = project.available_readers.require("vtkXdmfReader")
        """
        try:
            future = self._runner.submit(self._async.available_readers())
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in available_readers for project '{self.name}': {e}")
            raise

    def deploy_recipe(
        self,
        name: str,
        spec: RecipeSpec,
        *,
        description: str = "",
        cases: "CaseBinding | None" = None,
    ) -> RecipeResponse:
        """
        Deploy a recipe definition as the current executable version.

        Same contract as :meth:`AsyncProject.deploy_recipe` (create / no-op / revise).
        Pass ``cases=`` with ``upload.cases`` / ``plan.cases``
        (:class:`~miura.nexus.case_grouping.AdmittedCases`) to bind the case
        universe at deploy time.

        A writer id the project does not publish raises
        :class:`~miura.sdk.exceptions.ValidationError` before anything is
        created. A Converter reader id that is not published raises the same
        error. Use :attr:`available_writers` / :attr:`available_readers` to
        list the supported ids.
        """
        try:
            future = self._runner.submit(
                self._async.deploy_recipe(
                    name,
                    spec,
                    description=description,
                    cases=cases,
                )
            )
            return to_sync_recipe_response(
                future.result(),
                project=self,
                async_project=self._async,
                runner=self._runner,
            )
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in deploy_recipe for project '{self.name}': {e}")
            raise

    def create_recipe(
        self,
        name: str,
        spec: RecipeSpec,
        *,
        description: str = "",
        submit: bool = False,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> RecipeResponse:
        """
        Explicitly create a new recipe identity and its first immutable revision.

        Same contract as :meth:`AsyncProject.create_recipe`. Prefer
        :meth:`deploy_recipe` for repeatable authoring scripts.
        """
        try:
            future = self._runner.submit(
                self._async.create_recipe(
                    name,
                    spec,
                    description=description,
                    submit=submit,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            return to_sync_recipe_response(
                future.result(),
                project=self,
                async_project=self._async,
                runner=self._runner,
            )
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in create_recipe for project '{self.name}': {e}")
            raise

    def run_recipe(
        self,
        recipe: Union[RecipeResponse, str],
        execution_profile: Optional[str] = None,
        tags: Optional[dict[str, str]] = None,
        idempotency_key: Optional[str] = None,
        execution_plan_version_suffix: Optional[str] = None,
        target_collection_mode: Optional[str] = None,
        inputs: Optional[str] = None,
        request_schema_version: int = 1,
        execution_options: Optional[dict[str, Any]] = None,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> "SyncRecipeRunHandle":
        """
        Execute a recipe (queue for execution). Same behavior as :meth:`AsyncProject.run_recipe`.

        Returns:
            :class:`SyncRecipeRunHandle` for blocking ``wait`` / ``refresh`` / ``cancel``.
        """
        from .recipe_run import SyncRecipeRunHandle

        try:
            async_recipe = recipe._async if isinstance(recipe, RecipeResponse) else recipe
            future = self._runner.submit(
                self._async.run_recipe(
                    async_recipe,
                    execution_profile=execution_profile,
                    tags=tags,
                    idempotency_key=idempotency_key,
                    execution_plan_version_suffix=execution_plan_version_suffix,
                    target_collection_mode=target_collection_mode,
                    inputs=inputs,
                    request_schema_version=request_schema_version,
                    execution_options=execution_options,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            async_handle = future.result()
            return SyncRecipeRunHandle(async_handle, self._runner)
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in run_recipe for project '{self.name}': {e}")
            raise

    def list_runs(
        self,
        *,
        state: Optional[List[str]] = None,
        sort_by: str = "created_at",
        sort_order: str = "desc",
        page: int = 1,
        limit: int = 50,
    ) -> "RunListResponse":
        """List runs scoped to this project (same contract as :meth:`miura.api.project.AsyncProject.list_runs`)."""
        try:
            future = self._runner.submit(
                self._async.list_runs(
                    state=state,
                    sort_by=sort_by,
                    sort_order=sort_order,
                    page=page,
                    limit=limit,
                )
            )
            return future.result()
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in list_runs for project '{self.name}': {e}")
            raise

    def delete_recipe(
        self,
        name: str,
        *,
        confirm: bool = True,
        retry_policy: Optional[RetryPolicy] = None,
        timeouts: Optional[Timeouts] = None,
        hooks: Optional[ApiHooks] = None,
    ) -> None:
        """Delete a recipe by name (same behavior as :meth:`AsyncProject.delete_recipe`)."""
        try:
            future = self._runner.submit(
                self._async.delete_recipe(
                    name,
                    confirm=confirm,
                    retry_policy=retry_policy,
                    timeouts=timeouts,
                    hooks=hooks,
                )
            )
            future.result()
        except CancelledError:
            raise
        except Exception as e:
            self._handle_auth_errors(e)
            self.logger.error(f"Error in delete_recipe for project '{self.name}': {e}")
            raise

    def help(self) -> None:
        """
        Display help information about available methods.

        Shows a terse summary of available methods with key parameters and examples.
        """
        print_project_help()

id property

id: str

Stable project identifier (value may be a UUID).

uuid property

uuid: str

Alias for id (UUID), same as :attr:AsyncProject.uuid.

info property

info: 'ProjectInfo'

Get the underlying ProjectInfo DTO.

This provides access to the raw data structure for serialization, caching, or other internal operations.

Returns:

Type Description
'ProjectInfo'

The underlying ProjectInfo data

available_writers property

available_writers: CapabilityCatalog

Writer ids this project can produce, fetched once and cached.

Recipe specs take the writer id directly (writer="parquet_tabular") and :meth:deploy_recipe rejects an id the project does not publish. Reach for this catalog when you want to see what is on offer, or to resolve an id yourself and get a "did you mean" hint for a typo::

print(list(project.available_writers))
writer = project.available_writers.require("parquet_tabular")

available_readers property

available_readers: CapabilityCatalog

Reader ids this project can consume, fetched once and cached.

These ids are the Sigma reader class_name values registered on the deployment (e.g. "vtkXdmfReader"). Pass the same string to SigmaClassRef(class_name=...) when building a converter; :meth:deploy_recipe rejects a Converter whose reader is not published. Leave the Sigma reader unset for auto-detect — that path is not checked.

Use this catalog to list what is on offer, or to resolve an id yourself::

print(list(project.available_readers))
reader = project.available_readers.require("vtkXdmfReader")

__init__

__init__(project_data: Dict[str, Any], nexus_client: Any, runner: Any, async_project: AsyncProject)

Initialize a Project wrapper.

Parameters:

Name Type Description Default
project_data Dict[str, Any]

Project information dictionary

required
nexus_client Any

The underlying NexusClient instance (from parent)

required
runner Any

The loop thread for executing async operations (from parent Nexus)

required
async_project AsyncProject

AsyncProject instance (required)

required
Source code in src/miura/api/sync/project.py
def __init__(
    self,
    project_data: Dict[str, Any],
    nexus_client: Any,  # NexusClient from parent
    runner: Any,  # _LoopThread from parent Nexus
    async_project: AsyncProject,  # AsyncProject instance (required)
):
    """
    Initialize a Project wrapper.

    Args:
        project_data: Project information dictionary
        nexus_client: The underlying NexusClient instance (from parent)
        runner: The loop thread for executing async operations (from parent Nexus)
        async_project: AsyncProject instance (required)
    """
    # Store reference to parent Nexus's runner (shared loop)
    self._nexus_client = nexus_client
    self._runner = runner

    # Store AsyncProject instance and extract attributes
    self._async = async_project
    self._uuid = async_project.id
    self.name = async_project.name
    self.owner_id = async_project.owner_id
    self.created_at = async_project.created_at
    self.updated_at = async_project.updated_at
    self.metadata = async_project.metadata

    # Initialize logger after name is set (instance-specific namespace)
    self.logger = get_logger(f"{__name__}.{self.name}")

    # Store project_data for Collection instantiation
    self._project_data = project_data

_handle_auth_errors

_handle_auth_errors(e: Exception) -> None

Delegate to :func:miura.api.auth_errors.handle_auth_errors.

Source code in src/miura/api/sync/project.py
def _handle_auth_errors(self, e: Exception) -> None:
    """Delegate to :func:`miura.api.auth_errors.handle_auth_errors`."""
    from miura.api.auth_errors import handle_auth_errors

    handle_auth_errors(self.logger, e)

list_collections

list_collections(retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> List['Collection']

List all collections in this project.

Returns:

Type Description
List['Collection']

List of Collection wrapper objects

Raises:

Type Description
RuntimeError

If AsyncProject is not available

Exception

If the API request fails or authentication errors occur

Source code in src/miura/api/sync/project.py
def list_collections(
    self,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> List["Collection"]:
    """
    List all collections in this project.

    Returns:
        List of Collection wrapper objects

    Raises:
        RuntimeError: If AsyncProject is not available
        Exception: If the API request fails or authentication errors occur
    """
    from .collection import Collection
    from miura.api.collection import AsyncCollection

    try:
        future = self._runner.submit(
            self._async.list_collections(
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        collection_infos = future.result()

        # Convert CollectionInfo to Collection wrappers
        collections = []
        for collection_info in collection_infos:
            # Convert CollectionInfo to dict format
            collection_data = {
                "uuid": collection_info.id,
                "name": collection_info.name,
                "created_at": collection_info.created_at,
                "updated_at": collection_info.updated_at,
                "metadata": collection_info.metadata,
            }

            # Create AsyncCollection from collection data
            async_collection = AsyncCollection(
                collection_data=collection_data,
                project=self._async,
                nexus_client=self._nexus_client,
            )

            # Wrap in Collection
            collection = Collection(
                collection_data=collection_data,
                project_data=self._project_data,
                nexus_client=self._nexus_client,
                runner=self._runner,
                async_collection=async_collection,
            )
            collections.append(collection)

        return collections
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in list_collections: {e}")
        raise

get_collection

get_collection(collection_name: str, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> 'Collection'

Get a collection by name, returning a Collection wrapper object.

Parameters:

Name Type Description Default
collection_name str

Name of the collection to retrieve

required

Returns:

Type Description
'Collection'

Collection wrapper object for the specified collection

Raises:

Type Description
RuntimeError

If AsyncProject is not available

CollectionNotFoundError

If the collection with the given name is not found

Exception

If the API request fails or authentication errors occur

Source code in src/miura/api/sync/project.py
def get_collection(
    self,
    collection_name: str,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> "Collection":
    """
    Get a collection by name, returning a Collection wrapper object.

    Args:
        collection_name: Name of the collection to retrieve

    Returns:
        Collection wrapper object for the specified collection

    Raises:
        RuntimeError: If AsyncProject is not available
        CollectionNotFoundError: If the collection with the given name is not found
        Exception: If the API request fails or authentication errors occur
    """
    from .collection import Collection

    try:
        future = self._runner.submit(
            self._async.get_collection(
                collection_name,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        async_collection = future.result()

        # Convert AsyncCollection to Collection wrapper
        collection_data = {
            "uuid": async_collection.id,
            "name": async_collection.name,
            "created_at": async_collection.created_at,
            "updated_at": async_collection.updated_at,
            "metadata": {"attributes": dict(async_collection.attributes)},
        }

        collection = Collection(
            collection_data=collection_data,
            project_data=self._project_data,
            nexus_client=self._nexus_client,
            runner=self._runner,
            async_collection=async_collection,
        )
        return collection
    except CollectionNotFoundError:
        # Expected miss path for get_or_create_collection; do not log as error
        raise
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in get_collection: {e}")
        raise

create_collection

create_collection(collection_name: str, schema: Any, *, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> 'Collection'

Create a new collection in this project.

Parameters:

Name Type Description Default
collection_name str

Name of the collection (same parameter as :meth:miura.api.project.AsyncProject.create_collection)

required
schema Any

Schema definition for the collection

required

Returns:

Type Description
'Collection'

Collection wrapper object for the newly created collection

Raises:

Type Description
ConflictError

If a collection with the same name already exists

Source code in src/miura/api/sync/project.py
def create_collection(
    self,
    collection_name: str,
    schema: Any,
    *,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> "Collection":
    """
    Create a new collection in this project.

    Args:
        collection_name: Name of the collection (same parameter as
            :meth:`miura.api.project.AsyncProject.create_collection`)
        schema: Schema definition for the collection

    Returns:
        Collection wrapper object for the newly created collection

    Raises:
        ConflictError: If a collection with the same name already exists
    """
    from .collection import Collection

    try:
        compiled_schema = normalize_collection_schema(schema)
        create_kwargs: dict[str, Any] = {
            "retry_policy": retry_policy,
            "timeouts": timeouts,
            "hooks": hooks,
        }
        future = self._runner.submit(
            self._async.create_collection(collection_name, compiled_schema, **create_kwargs)
        )
        async_collection = future.result()

        # Convert AsyncCollection to Collection wrapper
        collection_data = {
            "uuid": async_collection.id,
            "name": async_collection.name,
            "created_at": async_collection.created_at,
            "updated_at": async_collection.updated_at,
            "metadata": {"attributes": dict(async_collection.attributes)},
        }

        collection = Collection(
            collection_data=collection_data,
            project_data=self._project_data,
            nexus_client=self._nexus_client,
            runner=self._runner,
            async_collection=async_collection,
        )
        return collection
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in create_collection for project '{self.name}': {e}")
        raise

get_or_create_collection

get_or_create_collection(collection_name: str, schema: Dict[str, Any], *, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> 'Collection'

Get an existing collection by name, or create it if it does not exist.

Parameters:

Name Type Description Default
collection_name str

Name of the collection to get or create (same as :meth:miura.api.project.AsyncProject.get_or_create_collection)

required
schema Dict[str, Any]

Schema definition (used only when creating)

required

Returns:

Type Description
'Collection'

Collection wrapper object for the existing or newly created collection

Source code in src/miura/api/sync/project.py
def get_or_create_collection(
    self,
    collection_name: str,
    schema: Dict[str, Any],
    *,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> "Collection":
    """
    Get an existing collection by name, or create it if it does not exist.

    Args:
        collection_name: Name of the collection to get or create (same as
            :meth:`miura.api.project.AsyncProject.get_or_create_collection`)
        schema: Schema definition (used only when creating)

    Returns:
        Collection wrapper object for the existing or newly created collection
    """
    try:
        return self.get_collection(
            collection_name,
            retry_policy=retry_policy,
            timeouts=timeouts,
            hooks=hooks,
        )
    except CollectionNotFoundError:
        return self.create_collection(
            collection_name,
            schema,
            retry_policy=retry_policy,
            timeouts=timeouts,
            hooks=hooks,
        )

iter_collections

iter_collections(prefetch_pages: int = 1, page_size: int = 50, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> Iterator['Collection']

Iterate over all collections with transparent pagination.

This method provides a sync iterator that yields collections one at a time, handling pagination transparently. It wraps the async iterator from AsyncProject.

Parameters:

Name Type Description Default
prefetch_pages int

Number of pages to prefetch (default: 1, max: 5)

1
page_size int

Number of items per page (default: 50, max: 1000)

50

Yields:

Type Description
'Collection'

Collection wrapper objects

Note

Pagination stability: Results may be reordered if backend changes. For deterministic ordering, backend should support sort_by parameter.

Source code in src/miura/api/sync/project.py
def iter_collections(
    self,
    prefetch_pages: int = 1,
    page_size: int = 50,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> Iterator["Collection"]:
    """
    Iterate over all collections with transparent pagination.

    This method provides a sync iterator that yields collections one at a time,
    handling pagination transparently. It wraps the async iterator from AsyncProject.

    Args:
        prefetch_pages: Number of pages to prefetch (default: 1, max: 5)
        page_size: Number of items per page (default: 50, max: 1000)

    Yields:
        Collection wrapper objects

    Note:
        Pagination stability: Results may be reordered if backend changes.
        For deterministic ordering, backend should support sort_by parameter.
    """
    from .collection import Collection
    from miura.api.collection import AsyncCollection

    # Get async generator from AsyncProject
    agen = self._async.iter_collections(
        prefetch_pages=prefetch_pages,
        page_size=page_size,
        retry_policy=retry_policy,
        timeouts=timeouts,
        hooks=hooks,
    )

    try:
        while True:
            # Submit __anext__ to get next item
            fut = self._runner.submit(agen.__anext__())
            try:
                collection_info = fut.result()

                # Convert CollectionInfo to Collection wrapper
                collection_data = {
                    "uuid": collection_info.id,
                    "name": collection_info.name,
                    "created_at": collection_info.created_at,
                    "updated_at": collection_info.updated_at,
                    "metadata": collection_info.metadata,
                }

                # Create AsyncCollection from collection data
                async_collection = AsyncCollection(
                    collection_data=collection_data,
                    project=self._async,
                    nexus_client=self._nexus_client,
                )

                # Wrap in Collection
                collection = Collection(
                    collection_data=collection_data,
                    project_data=self._project_data,
                    nexus_client=self._nexus_client,
                    runner=self._runner,
                    async_collection=async_collection,
                )
                yield collection
            except StopAsyncIteration:
                break
    except (GeneratorExit, KeyboardInterrupt):
        # Graceful cancellation: cancel underlying async generator
        try:
            close_fut = self._runner.submit(agen.aclose())
            close_fut.result(timeout=2.0)
        except Exception:
            pass  # Ignore errors during cleanup
        raise

update_project

update_project(metadata: Optional[Dict[str, Any]] = None, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> None

Update project metadata.

Parameters:

Name Type Description Default
metadata Optional[Dict[str, Any]]

Optional metadata dictionary to update

None

Raises:

Type Description
NotImplementedError

Same as :meth:AsyncProject.update_project until implemented.

Source code in src/miura/api/sync/project.py
def update_project(
    self,
    metadata: Optional[Dict[str, Any]] = None,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> None:
    """
    Update project metadata.

    Args:
        metadata: Optional metadata dictionary to update

    Raises:
        NotImplementedError: Same as :meth:`AsyncProject.update_project` until implemented.
    """
    try:
        future = self._runner.submit(
            self._async.update_project(
                metadata,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in update_project for project '{self.name}': {e}")
        raise

delete_project

delete_project(retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> None

Delete this project and all associated resources.

Raises:

Type Description
RuntimeError

If AsyncProject is not available

ProjectNotFoundError

If project is not found

NetworkError

If network request fails

PermissionError

If authentication fails

Source code in src/miura/api/sync/project.py
def delete_project(
    self,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> None:
    """
    Delete this project and all associated resources.

    Raises:
        RuntimeError: If AsyncProject is not available
        ProjectNotFoundError: If project is not found
        NetworkError: If network request fails
        PermissionError: If authentication fails
    """
    try:
        future = self._runner.submit(
            self._async.delete_project(
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error deleting project '{self.name}': {e}")
        raise

print_info

print_info() -> None

Print comprehensive information about the project, collections, and recipes.

Source code in src/miura/api/sync/project.py
def print_info(self) -> None:
    """Print comprehensive information about the project, collections, and recipes."""
    project_info = self.info

    try:
        collections = self.list_collections()
        recipes = self.list_recipes()
        # Convert collections to CollectionInfo for display
        collection_infos = [
            CollectionInfo(
                uuid=c.id,
                name=c.name,
                project_uuid=c.project_id,
                created_at=c.created_at or "",
                updated_at=c.updated_at or "",
                metadata={"attributes": dict(c.attributes)},
            )
            for c in collections
        ]
        print_project_info(project_info, collections=collection_infos, recipes=recipes)
    except Exception as e:
        self.logger.error(f"Error in print_info: {e}")
        # Print project info without collections/recipes
        print_project_info(project_info, collections=None, recipes=None)
        raise

_format_metadata_value

_format_metadata_value(value: Any) -> str

Format metadata values for pretty display.

Source code in src/miura/api/sync/project.py
def _format_metadata_value(self, value: Any) -> str:
    """Format metadata values for pretty display."""
    if isinstance(value, dict):
        # Show key names and types for better understanding
        if len(value) > 6:  # Large dictionary
            key_preview = ", ".join(list(value.keys())[:3])
            remaining = len(value) - 3
            if remaining > 0:
                return f"<dict: {key_preview}... (+{remaining} more)>"
            else:
                return f"<dict: {key_preview}>"
        else:
            # Pretty format smaller dictionaries
            items = []
            for k, v in value.items():
                if isinstance(v, (dict, list)):
                    items.append(f"{k}: <{type(v).__name__}>")
                elif isinstance(v, str) and len(v) > 30:
                    items.append(f"{k}: {v[:27]}...")
                else:
                    items.append(f"{k}: {v}")
            return "{" + ", ".join(items) + "}"
    elif isinstance(value, list):
        if len(value) > 5:  # Long list
            return f"<list with {len(value)} items>"
        else:
            return str(value)
    elif isinstance(value, str) and len(value) > 50:
        return f"{value[:47]}..."
    else:
        return str(value)

_get_info_string

_get_info_string() -> str

Get the information string without printing.

Source code in src/miura/api/sync/project.py
def _get_info_string(self) -> str:
    """Get the information string without printing."""
    import io
    import sys

    # Capture the print output
    old_stdout = sys.stdout
    sys.stdout = buffer = io.StringIO()

    try:
        self.print_info()
        return buffer.getvalue()
    finally:
        sys.stdout = old_stdout

list_recipes

list_recipes(source_collection_name: Optional[str] = None, source_group_name: Optional[str] = None, target_collection_name: Optional[str] = None, target_group_name: Optional[str] = None, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> List[RecipeResponse]

List all recipes in this project.

Parameters:

Name Type Description Default
source_collection_name Optional[str]

Optional filter by source collection name

None
source_group_name Optional[str]

Optional filter by source group name

None
target_collection_name Optional[str]

Optional filter by target collection name

None
target_group_name Optional[str]

Optional filter by target group name

None
retry_policy Optional[RetryPolicy]

Optional per-call retry policy override (same as :meth:AsyncProject.list_recipes)

None
timeouts Optional[Timeouts]

Optional per-call timeout override

None
hooks Optional[ApiHooks]

Optional per-call hooks override

None

Returns:

Type Description
List[RecipeResponse]

List of RecipeResponse objects

Source code in src/miura/api/sync/project.py
def list_recipes(
    self,
    source_collection_name: Optional[str] = None,
    source_group_name: Optional[str] = None,
    target_collection_name: Optional[str] = None,
    target_group_name: Optional[str] = None,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> List[RecipeResponse]:
    """
    List all recipes in this project.

    Args:
        source_collection_name: Optional filter by source collection name
        source_group_name: Optional filter by source group name
        target_collection_name: Optional filter by target collection name
        target_group_name: Optional filter by target group name
        retry_policy: Optional per-call retry policy override (same as :meth:`AsyncProject.list_recipes`)
        timeouts: Optional per-call timeout override
        hooks: Optional per-call hooks override

    Returns:
        List of RecipeResponse objects
    """
    try:
        future = self._runner.submit(
            self._async.list_recipes(
                source_collection_name=source_collection_name,
                source_group_name=source_group_name,
                target_collection_name=target_collection_name,
                target_group_name=target_group_name,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        return to_sync_recipe_responses(
            future.result(),
            project=self,
            async_project=self._async,
            runner=self._runner,
        )
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in list_recipes for project '{self.name}': {e}")
        raise

get_recipe

get_recipe(recipe_name: str, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> RecipeResponse

Get a recipe by name.

Parameters:

Name Type Description Default
recipe_name str

Name of the recipe (must be unique within project)

required
retry_policy Optional[RetryPolicy]

Optional per-call retry policy override (same as :meth:AsyncProject.get_recipe)

None
timeouts Optional[Timeouts]

Optional per-call timeout override

None
hooks Optional[ApiHooks]

Optional per-call hooks override

None

Returns:

Type Description
RecipeResponse

RecipeResponse with recipe data

Raises:

Type Description
RecipeNotFoundError

If recipe not found

Source code in src/miura/api/sync/project.py
def get_recipe(
    self,
    recipe_name: str,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> RecipeResponse:
    """
    Get a recipe by name.

    Args:
        recipe_name: Name of the recipe (must be unique within project)
        retry_policy: Optional per-call retry policy override (same as :meth:`AsyncProject.get_recipe`)
        timeouts: Optional per-call timeout override
        hooks: Optional per-call hooks override

    Returns:
        RecipeResponse with recipe data

    Raises:
        RecipeNotFoundError: If recipe not found
    """
    try:
        future = self._runner.submit(
            self._async.get_recipe(
                recipe_name,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        return to_sync_recipe_response(
            future.result(),
            project=self,
            async_project=self._async,
            runner=self._runner,
        )
    except RecipeNotFoundError:
        # Expected miss path for get_or_create_recipe (async); do not log as error
        raise
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in get_recipe for project '{self.name}': {e}")
        raise

get_or_create_recipe

get_or_create_recipe(name: str, spec: RecipeSpec, *, description: str = '', submit: bool = False, options: Optional[RecipeCreateOptions] = None, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> RecipeResponse

Get a recipe by name, or create it from a spec if not found (same contract as async).

Raises RecipeSpecConflictError when an existing recipe's stored executable specification differs from spec (existing recipe is left unchanged).

Source code in src/miura/api/sync/project.py
def get_or_create_recipe(
    self,
    name: str,
    spec: RecipeSpec,
    *,
    description: str = "",
    submit: bool = False,
    options: Optional[RecipeCreateOptions] = None,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> RecipeResponse:
    """
    Get a recipe by name, or create it from a spec if not found (same contract as async).

    Raises ``RecipeSpecConflictError`` when an existing recipe's stored executable
    specification differs from ``spec`` (existing recipe is left unchanged).
    """
    from miura.sdk.exceptions import RecipeSpecConflictError

    try:
        future = self._runner.submit(
            self._async.get_or_create_recipe(
                name,
                spec,
                description=description,
                submit=submit,
                options=options,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        return to_sync_recipe_response(
            future.result(),
            project=self,
            async_project=self._async,
            runner=self._runner,
        )
    except RecipeSpecConflictError:
        raise
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in get_or_create_recipe for project '{self.name}': {e}")
        raise

validate_recipe_spec

validate_recipe_spec(spec: RecipeSpec) -> ValidationResult

Validate a RecipeSpec (same contract as :meth:AsyncProject.validate_recipe_spec).

A writer id the project does not publish raises :class:~miura.sdk.exceptions.ValidationError before the call is made. A Converter reader id that is not published raises the same error.

Source code in src/miura/api/sync/project.py
def validate_recipe_spec(self, spec: RecipeSpec) -> ValidationResult:
    """
    Validate a RecipeSpec (same contract as :meth:`AsyncProject.validate_recipe_spec`).

    A writer id the project does not publish raises
    :class:`~miura.sdk.exceptions.ValidationError` before the call is made.
    A Converter reader id that is not published raises the same error.
    """
    try:
        future = self._runner.submit(self._async.validate_recipe_spec(spec))
        return future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in validate_recipe_spec for project '{self.name}': {e}")
        raise

validate_recipe_payload

validate_recipe_payload(payload: Dict[str, Any]) -> ValidationResult

Validate a recipe payload dict (same contract as :meth:AsyncProject.validate_recipe_payload).

Source code in src/miura/api/sync/project.py
def validate_recipe_payload(self, payload: Dict[str, Any]) -> ValidationResult:
    """Validate a recipe payload dict (same contract as :meth:`AsyncProject.validate_recipe_payload`)."""
    try:
        future = self._runner.submit(self._async.validate_recipe_payload(payload))
        return future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in validate_recipe_payload for project '{self.name}': {e}")
        raise

validate_recipe

validate_recipe(spec_or_payload: Union[RecipeSpec, Dict[str, Any]]) -> ValidationResult

Validate a recipe (same contract as :meth:AsyncProject.validate_recipe).

Source code in src/miura/api/sync/project.py
def validate_recipe(self, spec_or_payload: Union[RecipeSpec, Dict[str, Any]]) -> ValidationResult:
    """Validate a recipe (same contract as :meth:`AsyncProject.validate_recipe`)."""
    try:
        future = self._runner.submit(self._async.validate_recipe(spec_or_payload))
        return future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in validate_recipe for project '{self.name}': {e}")
        raise

get_recipe_capabilities

get_recipe_capabilities(*, refresh: bool = False) -> Capabilities

Return typed recipe capabilities (cached on the async twin).

Same contract as :meth:AsyncProject.get_recipe_capabilities. Prefer :attr:available_writers / :attr:available_readers when you only need a single id.

Source code in src/miura/api/sync/project.py
def get_recipe_capabilities(self, *, refresh: bool = False) -> Capabilities:
    """
    Return typed recipe capabilities (cached on the async twin).

    Same contract as :meth:`AsyncProject.get_recipe_capabilities`. Prefer
    :attr:`available_writers` / :attr:`available_readers` when you only need
    a single id.
    """
    try:
        future = self._runner.submit(self._async.get_recipe_capabilities(refresh=refresh))
        return future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in get_recipe_capabilities for project '{self.name}': {e}")
        raise

deploy_recipe

deploy_recipe(name: str, spec: RecipeSpec, *, description: str = '', cases: 'CaseBinding | None' = None) -> RecipeResponse

Deploy a recipe definition as the current executable version.

Same contract as :meth:AsyncProject.deploy_recipe (create / no-op / revise). Pass cases= with upload.cases / plan.cases (:class:~miura.nexus.case_grouping.AdmittedCases) to bind the case universe at deploy time.

A writer id the project does not publish raises :class:~miura.sdk.exceptions.ValidationError before anything is created. A Converter reader id that is not published raises the same error. Use :attr:available_writers / :attr:available_readers to list the supported ids.

Source code in src/miura/api/sync/project.py
def deploy_recipe(
    self,
    name: str,
    spec: RecipeSpec,
    *,
    description: str = "",
    cases: "CaseBinding | None" = None,
) -> RecipeResponse:
    """
    Deploy a recipe definition as the current executable version.

    Same contract as :meth:`AsyncProject.deploy_recipe` (create / no-op / revise).
    Pass ``cases=`` with ``upload.cases`` / ``plan.cases``
    (:class:`~miura.nexus.case_grouping.AdmittedCases`) to bind the case
    universe at deploy time.

    A writer id the project does not publish raises
    :class:`~miura.sdk.exceptions.ValidationError` before anything is
    created. A Converter reader id that is not published raises the same
    error. Use :attr:`available_writers` / :attr:`available_readers` to
    list the supported ids.
    """
    try:
        future = self._runner.submit(
            self._async.deploy_recipe(
                name,
                spec,
                description=description,
                cases=cases,
            )
        )
        return to_sync_recipe_response(
            future.result(),
            project=self,
            async_project=self._async,
            runner=self._runner,
        )
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in deploy_recipe for project '{self.name}': {e}")
        raise

create_recipe

create_recipe(name: str, spec: RecipeSpec, *, description: str = '', submit: bool = False, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> RecipeResponse

Explicitly create a new recipe identity and its first immutable revision.

Same contract as :meth:AsyncProject.create_recipe. Prefer :meth:deploy_recipe for repeatable authoring scripts.

Source code in src/miura/api/sync/project.py
def create_recipe(
    self,
    name: str,
    spec: RecipeSpec,
    *,
    description: str = "",
    submit: bool = False,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> RecipeResponse:
    """
    Explicitly create a new recipe identity and its first immutable revision.

    Same contract as :meth:`AsyncProject.create_recipe`. Prefer
    :meth:`deploy_recipe` for repeatable authoring scripts.
    """
    try:
        future = self._runner.submit(
            self._async.create_recipe(
                name,
                spec,
                description=description,
                submit=submit,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        return to_sync_recipe_response(
            future.result(),
            project=self,
            async_project=self._async,
            runner=self._runner,
        )
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in create_recipe for project '{self.name}': {e}")
        raise

run_recipe

run_recipe(recipe: Union[RecipeResponse, str], execution_profile: Optional[str] = None, tags: Optional[dict[str, str]] = None, idempotency_key: Optional[str] = None, execution_plan_version_suffix: Optional[str] = None, target_collection_mode: Optional[str] = None, inputs: Optional[str] = None, request_schema_version: int = 1, execution_options: Optional[dict[str, Any]] = None, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> 'SyncRecipeRunHandle'

Execute a recipe (queue for execution). Same behavior as :meth:AsyncProject.run_recipe.

Returns:

Type Description
'SyncRecipeRunHandle'

class:SyncRecipeRunHandle for blocking wait / refresh / cancel.

Source code in src/miura/api/sync/project.py
def run_recipe(
    self,
    recipe: Union[RecipeResponse, str],
    execution_profile: Optional[str] = None,
    tags: Optional[dict[str, str]] = None,
    idempotency_key: Optional[str] = None,
    execution_plan_version_suffix: Optional[str] = None,
    target_collection_mode: Optional[str] = None,
    inputs: Optional[str] = None,
    request_schema_version: int = 1,
    execution_options: Optional[dict[str, Any]] = None,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> "SyncRecipeRunHandle":
    """
    Execute a recipe (queue for execution). Same behavior as :meth:`AsyncProject.run_recipe`.

    Returns:
        :class:`SyncRecipeRunHandle` for blocking ``wait`` / ``refresh`` / ``cancel``.
    """
    from .recipe_run import SyncRecipeRunHandle

    try:
        async_recipe = recipe._async if isinstance(recipe, RecipeResponse) else recipe
        future = self._runner.submit(
            self._async.run_recipe(
                async_recipe,
                execution_profile=execution_profile,
                tags=tags,
                idempotency_key=idempotency_key,
                execution_plan_version_suffix=execution_plan_version_suffix,
                target_collection_mode=target_collection_mode,
                inputs=inputs,
                request_schema_version=request_schema_version,
                execution_options=execution_options,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        async_handle = future.result()
        return SyncRecipeRunHandle(async_handle, self._runner)
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in run_recipe for project '{self.name}': {e}")
        raise

list_runs

list_runs(*, state: Optional[List[str]] = None, sort_by: str = 'created_at', sort_order: str = 'desc', page: int = 1, limit: int = 50) -> 'RunListResponse'

List runs scoped to this project (same contract as :meth:miura.api.project.AsyncProject.list_runs).

Source code in src/miura/api/sync/project.py
def list_runs(
    self,
    *,
    state: Optional[List[str]] = None,
    sort_by: str = "created_at",
    sort_order: str = "desc",
    page: int = 1,
    limit: int = 50,
) -> "RunListResponse":
    """List runs scoped to this project (same contract as :meth:`miura.api.project.AsyncProject.list_runs`)."""
    try:
        future = self._runner.submit(
            self._async.list_runs(
                state=state,
                sort_by=sort_by,
                sort_order=sort_order,
                page=page,
                limit=limit,
            )
        )
        return future.result()
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in list_runs for project '{self.name}': {e}")
        raise

delete_recipe

delete_recipe(name: str, *, confirm: bool = True, retry_policy: Optional[RetryPolicy] = None, timeouts: Optional[Timeouts] = None, hooks: Optional[ApiHooks] = None) -> None

Delete a recipe by name (same behavior as :meth:AsyncProject.delete_recipe).

Source code in src/miura/api/sync/project.py
def delete_recipe(
    self,
    name: str,
    *,
    confirm: bool = True,
    retry_policy: Optional[RetryPolicy] = None,
    timeouts: Optional[Timeouts] = None,
    hooks: Optional[ApiHooks] = None,
) -> None:
    """Delete a recipe by name (same behavior as :meth:`AsyncProject.delete_recipe`)."""
    try:
        future = self._runner.submit(
            self._async.delete_recipe(
                name,
                confirm=confirm,
                retry_policy=retry_policy,
                timeouts=timeouts,
                hooks=hooks,
            )
        )
        future.result()
    except CancelledError:
        raise
    except Exception as e:
        self._handle_auth_errors(e)
        self.logger.error(f"Error in delete_recipe for project '{self.name}': {e}")
        raise

help

help() -> None

Display help information about available methods.

Shows a terse summary of available methods with key parameters and examples.

Source code in src/miura/api/sync/project.py
def help(self) -> None:
    """
    Display help information about available methods.

    Shows a terse summary of available methods with key parameters and examples.
    """
    print_project_help()