Skip to content

Commit ed8f69b

Browse files
Merge pull request #123 from nextmv-io/feature/eng-6316-functionality-for-scaffolding-a-nextmv-application-locally
Introduce application initialization
2 parents 2a5afe7 + 2e5845d commit ed8f69b

File tree

9 files changed

+398
-85
lines changed

9 files changed

+398
-85
lines changed

nextmv/nextmv/cloud/application.py

Lines changed: 162 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import tarfile
3030
import tempfile
3131
import time
32+
import uuid
3233
from collections.abc import Callable
3334
from dataclasses import dataclass
3435
from datetime import datetime
@@ -302,6 +303,15 @@ class Application:
302303
experiments_endpoint: str = "{base}/experiments"
303304
"""Base endpoint for the experiments in the application."""
304305

306+
# Local experience parameters.
307+
src: Optional[str] = None
308+
"""
309+
Source of the application, if initialized locally. This is the path
310+
to the application's source code.
311+
"""
312+
description: Optional[str] = None
313+
"""Description of the application."""
314+
305315
def __post_init__(self):
306316
"""Initialize the endpoint and experiments_endpoint attributes.
307317
@@ -311,6 +321,158 @@ def __post_init__(self):
311321
self.endpoint = self.endpoint.format(id=self.id)
312322
self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
313323

324+
@classmethod
325+
def initialize(
326+
cls,
327+
name: str,
328+
id: Optional[str] = None,
329+
description: Optional[str] = None,
330+
destination: Optional[str] = None,
331+
client: Optional[Client] = None,
332+
) -> "Application":
333+
"""
334+
Initialize a Nextmv application, locally.
335+
336+
This method will create a new application in the local file system. The
337+
application is a folder with the name given by `name`, under the
338+
location given by `destination`. If the `destination` parameter is not
339+
specified, the current working directory is used as default. This
340+
method will scaffold the application with the necessary files and
341+
directories to have an opinionated structure for your decision model.
342+
Once the application is initialized, you are encouraged to complete it
343+
with the decision model itself, so that the application can be run,
344+
locally or remotely.
345+
346+
This method differs from the `Application.new` method in that it
347+
creates the application locally rather than in the Cloud.
348+
349+
Although not required, you are encouraged to specify the `client`
350+
parameter, so that the application can be pushed and synced remotely,
351+
with the Nextmv Cloud. If you don't specify the `client`, and intend to
352+
interact with the Nextmv Cloud, you will encounter an error. Make sure
353+
you set the `client` parameter on the `Application` instance after
354+
initialization, if you don't provide it here.
355+
356+
Use the `destination` parameter to specify where you want the app to be
357+
initialized, using the current working directory by default.
358+
359+
Parameters
360+
----------
361+
name : str
362+
Name of the application.
363+
id : str, optional
364+
ID of the application. Will be generated if not provided.
365+
description : str, optional
366+
Description of the application.
367+
destination : str, optional
368+
Destination directory where the application will be initialized. If
369+
not provided, the current working directory will be used.
370+
client : Client, optional
371+
Client to use for interacting with the Nextmv Cloud API.
372+
373+
Returns
374+
-------
375+
Application
376+
The initialized application instance.
377+
"""
378+
379+
destination_dir = os.getcwd() if destination is None else destination
380+
app_id = id if id is not None else str(uuid.uuid4())
381+
382+
# Create the new directory with the given name.
383+
src = os.path.join(destination_dir, name)
384+
os.makedirs(src, exist_ok=True)
385+
386+
# Get the path to the initial app structure template.
387+
current_file_dir = os.path.dirname(os.path.abspath(__file__))
388+
initial_app_structure_path = os.path.join(current_file_dir, "..", "default_app")
389+
initial_app_structure_path = os.path.normpath(initial_app_structure_path)
390+
391+
# Copy everything from initial_app_structure to the new directory.
392+
if os.path.exists(initial_app_structure_path):
393+
for item in os.listdir(initial_app_structure_path):
394+
source_path = os.path.join(initial_app_structure_path, item)
395+
dest_path = os.path.join(src, item)
396+
397+
if os.path.isdir(source_path):
398+
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
399+
continue
400+
401+
shutil.copy2(source_path, dest_path)
402+
403+
return cls(
404+
id=app_id,
405+
client=client,
406+
src=src,
407+
description=description,
408+
)
409+
410+
@classmethod
411+
def new(
412+
cls,
413+
client: Client,
414+
name: str,
415+
id: Optional[str] = None,
416+
description: Optional[str] = None,
417+
is_workflow: Optional[bool] = None,
418+
exist_ok: bool = False,
419+
) -> "Application":
420+
"""
421+
Create a new application directly in Nextmv Cloud.
422+
423+
The application is created as an empty shell, and executable code must
424+
be pushed to the app before running it remotely.
425+
426+
Parameters
427+
----------
428+
client : Client
429+
Client to use for interacting with the Nextmv Cloud API.
430+
name : str
431+
Name of the application.
432+
id : str, optional
433+
ID of the application. Will be generated if not provided.
434+
description : str, optional
435+
Description of the application.
436+
is_workflow : bool, optional
437+
Whether the application is a Decision Workflow.
438+
exist_ok : bool, default=False
439+
If True and an application with the same ID already exists,
440+
return the existing application instead of creating a new one.
441+
442+
Returns
443+
-------
444+
Application
445+
The newly created (or existing) application.
446+
447+
Examples
448+
--------
449+
>>> from nextmv.cloud import Client
450+
>>> client = Client(api_key="your-api-key")
451+
>>> app = Application.new(client=client, name="My New App", id="my-app")
452+
"""
453+
454+
if exist_ok and cls.exists(client=client, id=id):
455+
return Application(client=client, id=id)
456+
457+
payload = {
458+
"name": name,
459+
}
460+
461+
if description is not None:
462+
payload["description"] = description
463+
if id is not None:
464+
payload["id"] = id
465+
if is_workflow is not None:
466+
payload["is_pipeline"] = is_workflow
467+
468+
response = client.request(
469+
method="POST",
470+
endpoint="v1/applications",
471+
payload=payload,
472+
)
473+
474+
return cls(client=client, id=response.json()["id"])
475+
314476
def acceptance_test(self, acceptance_test_id: str) -> AcceptanceTest:
315477
"""
316478
Retrieve details of an acceptance test.
@@ -902,69 +1064,6 @@ def managed_input(self, managed_input_id: str) -> ManagedInput:
9021064

9031065
return ManagedInput.from_dict(response.json())
9041066

905-
@classmethod
906-
def new(
907-
cls,
908-
client: Client,
909-
name: str,
910-
id: Optional[str] = None,
911-
description: Optional[str] = None,
912-
is_workflow: Optional[bool] = None,
913-
exist_ok: bool = False,
914-
) -> "Application":
915-
"""
916-
Create a new application.
917-
918-
Parameters
919-
----------
920-
client : Client
921-
Client to use for interacting with the Nextmv Cloud API.
922-
name : str
923-
Name of the application.
924-
id : str, optional
925-
ID of the application. Will be generated if not provided.
926-
description : str, optional
927-
Description of the application.
928-
is_workflow : bool, optional
929-
Whether the application is a Decision Workflow.
930-
exist_ok : bool, default=False
931-
If True and an application with the same ID already exists,
932-
return the existing application instead of creating a new one.
933-
934-
Returns
935-
-------
936-
Application
937-
The newly created (or existing) application.
938-
939-
Examples
940-
--------
941-
>>> from nextmv.cloud import Client
942-
>>> client = Client(api_key="your-api-key")
943-
>>> app = Application.new(client=client, name="My New App", id="my-app")
944-
"""
945-
946-
if exist_ok and cls.exists(client=client, id=id):
947-
return Application(client=client, id=id)
948-
949-
payload = {
950-
"name": name,
951-
}
952-
953-
if description is not None:
954-
payload["description"] = description
955-
if id is not None:
956-
payload["id"] = id
957-
if is_workflow is not None:
958-
payload["is_pipeline"] = is_workflow
959-
960-
response = client.request(
961-
method="POST",
962-
endpoint="v1/applications",
963-
payload=payload,
964-
)
965-
966-
return cls(client=client, id=response.json()["id"])
967-
9681067
def new_acceptance_test(
9691068
self,
9701069
candidate_instance_id: str,

nextmv/nextmv/cloud/manifest.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ class ManifestPython(BaseModel):
322322
from the app bundle.
323323
"""
324324

325+
325326
class ManifestOptionUI(BaseModel):
326327
"""
327328
UI attributes for an option in the manifest.
@@ -360,6 +361,7 @@ class ManifestOptionUI(BaseModel):
360361
hidden_from: Optional[list[str]] = None
361362
"""A list of team roles for which this option will be hidden in the UI."""
362363

364+
363365
class ManifestOption(BaseModel):
364366
"""
365367
An option for the decision model that is recorded in the manifest.
@@ -429,7 +431,6 @@ class ManifestOption(BaseModel):
429431
ui: Optional[ManifestOptionUI] = None
430432
"""Optional UI attributes for the option."""
431433

432-
433434
@classmethod
434435
def from_option(cls, option: Option) -> "ManifestOption":
435436
"""
@@ -483,7 +484,9 @@ def from_option(cls, option: Option) -> "ManifestOption":
483484
ui=ManifestOptionUI(
484485
control_type=option.control_type,
485486
hidden_from=option.hidden_from,
486-
) if option.control_type or option.hidden_from else None,
487+
)
488+
if option.control_type or option.hidden_from
489+
else None,
487490
)
488491

489492
def to_option(self) -> Option:
@@ -534,6 +537,7 @@ def to_option(self) -> Option:
534537
hidden_from=self.ui.hidden_from if self.ui else None,
535538
)
536539

540+
537541
class ManifestValidation(BaseModel):
538542
"""
539543
Validation rules for options in the manifest.
@@ -569,6 +573,7 @@ class ManifestValidation(BaseModel):
569573
created if any of the rules of the options are violated.
570574
"""
571575

576+
572577
class ManifestOptions(BaseModel):
573578
"""
574579
Options for the decision model.
@@ -649,9 +654,10 @@ def from_options(cls, options: Options, validation: OptionsEnforcement = None) -
649654
return cls(
650655
strict=validation.strict if validation else False,
651656
validation=ManifestValidation(enforce="all" if validation and validation.validation_enforce else "none"),
652-
items=items
657+
items=items,
653658
)
654659

660+
655661
class ManifestConfiguration(BaseModel):
656662
"""
657663
Configuration for the decision model.
@@ -749,14 +755,17 @@ class Manifest(BaseModel):
749755
"""The files to include (or exclude) in the app. This is mandatory."""
750756

751757
runtime: ManifestRuntime = ManifestRuntime.PYTHON
752-
"""The runtime to use for the app.
753-
754-
It provides the environment in which the app runs. This is mandatory.
758+
"""
759+
The runtime to use for the app. It provides the environment in which the
760+
app runs. This is mandatory.
755761
"""
756762
type: ManifestType = ManifestType.PYTHON
757-
"""Type of application, based on the programming language. This is mandatory."""
763+
"""
764+
Type of application, based on the programming language. This is mandatory.
765+
"""
758766
build: Optional[ManifestBuild] = None
759-
"""Build-specific attributes.
767+
"""
768+
Build-specific attributes.
760769
761770
The `build.command` to run to build the app. This command will be executed
762771
without a shell, i.e., directly. The command must exit with a status of 0
@@ -770,7 +779,8 @@ class Manifest(BaseModel):
770779
validation_alias=AliasChoices("pre-push", "pre_push"),
771780
default=None,
772781
)
773-
"""A command to run before the app is pushed to the Nextmv Cloud.
782+
"""
783+
A command to run before the app is pushed to the Nextmv Cloud.
774784
775785
This command can be used to compile a binary, run tests or similar tasks.
776786
One difference with what is specified under build, is that the command will
@@ -780,15 +790,14 @@ class Manifest(BaseModel):
780790
pushed (after the build command).
781791
"""
782792
python: Optional[ManifestPython] = None
783-
"""Python-specific attributes.
784-
785-
Only for Python apps. Contains further Python-specific attributes.
793+
"""
794+
Python-specific attributes. Only for Python apps. Contains further
795+
Python-specific attributes.
786796
"""
787797
configuration: Optional[ManifestConfiguration] = None
788-
"""Configuration for the decision model.
789-
790-
A list of options for the decision model. An option is a parameter that
791-
configures the decision model.
798+
"""
799+
Configuration for the decision model. A list of options for the decision
800+
model. An option is a parameter that configures the decision model.
792801
"""
793802

794803
@classmethod
@@ -1041,11 +1050,8 @@ def from_options(cls, options: Options, validation: OptionsEnforcement = None) -
10411050
type=ManifestType.PYTHON,
10421051
python=ManifestPython(pip_requirements="requirements.txt"),
10431052
configuration=ManifestConfiguration(
1044-
options= ManifestOptions.from_options(
1045-
options=options,
1046-
validation=validation
1047-
),
1048-
)
1053+
options=ManifestOptions.from_options(options=options, validation=validation),
1054+
),
10491055
)
10501056

10511057
return manifest

0 commit comments

Comments
 (0)