@@ -88,6 +88,47 @@ class ArtifactLookupError(ValueError):
8888 """Raised when a spec type or artifact name cannot be found in projspec."""
8989
9090
91+ class TypeNotCreatableError (ValueError ):
92+ """Raised when a requested project type does not support creation."""
93+
94+
95+ # Cached list of project types that support creation.
96+ # Computed once on first access since the projspec registry is stable at runtime.
97+ _creatable_types_cache : list [dict ] | None = None
98+
99+
100+ def _get_creatable_types () -> list [dict ]:
101+ """Return the list of project types that have implemented _create.
102+
103+ Results are cached at module level since the registry does not change
104+ during the lifetime of the server process.
105+ """
106+ global _creatable_types_cache
107+ if _creatable_types_cache is not None :
108+ return _creatable_types_cache
109+
110+ from projspec .proj .base import ProjectSpec , registry
111+
112+ result = []
113+ for name , cls in sorted (registry .items ()):
114+ if not issubclass (cls , ProjectSpec ):
115+ continue
116+ if cls ._create is ProjectSpec ._create :
117+ continue
118+ result .append ({
119+ "name" : name ,
120+ "doc" : (cls .__doc__ or "" ).strip (),
121+ "link" : getattr (cls , "spec_doc" , "" ),
122+ })
123+ _creatable_types_cache = result
124+ return _creatable_types_cache
125+
126+
127+ def _is_type_creatable (type_name : str ) -> bool :
128+ """Check whether a given type name is in the creatable types list."""
129+ return any (t ["name" ] == type_name for t in _get_creatable_types ())
130+
131+
91132def resolve_path (contents_manager : ContentsManager , relative_path : str ) -> str :
92133 """Validate and resolve a relative path to an absolute path within the server root.
93134
@@ -508,16 +549,127 @@ def get(self):
508549 self .finish (json .dumps ({"error" : "Error scanning directory" }))
509550
510551
552+ class CreatableTypesRouteHandler (APIHandler ):
553+ """Handler that returns the list of project types supporting creation.
554+
555+ The result is computed once from projspec's registry and cached for the
556+ lifetime of the server process.
557+ """
558+
559+ @tornado .web .authenticated
560+ def get (self ):
561+ """Return all creatable project types.
562+
563+ Returns:
564+ JSON with "types" key containing a list of
565+ {name, doc, link} objects.
566+ """
567+ try :
568+ types = _get_creatable_types ()
569+ self .finish (json .dumps ({"types" : types }))
570+ except Exception as e :
571+ logger .error ("Error fetching creatable types: %s" , e , exc_info = True )
572+ self .set_status (500 )
573+ self .finish (json .dumps ({"error" : "Failed to retrieve creatable types" }))
574+
575+
576+ class CreateRouteHandler (APIHandler ):
577+ """Handler for creating new project types in a directory via projspec."""
578+
579+ @tornado .web .authenticated
580+ async def post (self ):
581+ """Create a new project type in the specified directory.
582+
583+ Request Body:
584+ path: Relative path from server root (empty string for root)
585+ type_name: The projspec type to create (e.g., "git_repo", "pixi")
586+
587+ Returns:
588+ JSON with "created_files" listing the new files.
589+ """
590+ try :
591+ data = self .get_json_body ()
592+ except (json .JSONDecodeError , ValueError ):
593+ self .set_status (400 )
594+ self .finish (json .dumps ({"error" : "Invalid or missing JSON body" }))
595+ return
596+
597+ if not isinstance (data , dict ):
598+ self .set_status (400 )
599+ self .finish (json .dumps ({"error" : "Request body must be a JSON object" }))
600+ return
601+
602+ type_name = data .get ("type_name" )
603+ if not isinstance (type_name , str ) or not type_name .strip ():
604+ self .set_status (400 )
605+ self .finish (json .dumps ({"error" : "Missing or invalid required field: type_name" }))
606+ return
607+
608+ path = data .get ("path" , "" )
609+ if not isinstance (path , str ):
610+ self .set_status (400 )
611+ self .finish (json .dumps ({"error" : "Field 'path' must be a string" }))
612+ return
613+
614+ try :
615+ result = await tornado .ioloop .IOLoop .current ().run_in_executor (
616+ _executor , self ._run_create , path , type_name .strip ()
617+ )
618+ self .finish (json .dumps (result ))
619+ except PathSecurityError as e :
620+ self .set_status (403 )
621+ self .finish (json .dumps ({"error" : str (e )}))
622+ except PathNotFoundError as e :
623+ self .set_status (404 )
624+ self .finish (json .dumps ({"error" : str (e )}))
625+ except PathNotDirectoryError as e :
626+ self .set_status (400 )
627+ self .finish (json .dumps ({"error" : str (e )}))
628+ except TypeNotCreatableError as e :
629+ self .set_status (400 )
630+ self .finish (json .dumps ({"error" : str (e )}))
631+ except Exception as e :
632+ logger .error ("Create project error: %s" , e , exc_info = True )
633+ self .set_status (500 )
634+ self .finish (json .dumps ({"error" : f"Failed to create project type: { e } " }))
635+
636+ def _run_create (self , path : str , type_name : str ) -> dict :
637+ """Resolve path and run projspec create (called in thread pool)."""
638+ absolute_path = resolve_path (self .contents_manager , path )
639+
640+ if not _is_type_creatable (type_name ):
641+ raise TypeNotCreatableError (
642+ f"Project type '{ type_name } ' does not support creation"
643+ )
644+
645+ project = projspec .Project (absolute_path )
646+
647+ if type_name in project .specs :
648+ raise TypeNotCreatableError (
649+ f"Project type '{ type_name } ' already exists in this directory"
650+ )
651+
652+ # project.create() returns the list of newly created file paths
653+ created_files = project .create (type_name )
654+ return {"created_files" : [str (f ) for f in created_files ]}
655+
656+
511657def setup_route_handlers (web_app ):
512658 host_pattern = ".*$"
513659 base_url = web_app .settings ["base_url" ]
514660
515661 scan_route_pattern = url_path_join (base_url , "jupyter-projspec" , "scan" )
516662 make_route_pattern = url_path_join (base_url , "jupyter-projspec" , "make" )
663+ creatable_types_pattern = url_path_join (
664+ base_url , "jupyter-projspec" , "creatable-types"
665+ )
666+ create_pattern = url_path_join (base_url , "jupyter-projspec" , "create" )
517667
518668 handlers = [
519669 (scan_route_pattern , ScanRouteHandler ),
520670 (make_route_pattern , MakeRouteHandler ),
671+ (creatable_types_pattern , CreatableTypesRouteHandler ),
672+ (create_pattern , CreateRouteHandler ),
521673 ]
522674
523675 web_app .add_handlers (host_pattern , handlers )
0 commit comments