Skip to content

Commit 93b82d1

Browse files
Towards application scaffolding
1 parent 2a5afe7 commit 93b82d1

File tree

9 files changed

+379
-85
lines changed

9 files changed

+379
-85
lines changed

nextmv/nextmv/cloud/application.py

Lines changed: 151 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
@@ -311,6 +312,156 @@ def __post_init__(self):
311312
self.endpoint = self.endpoint.format(id=self.id)
312313
self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
313314

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

9031054
return ManagedInput.from_dict(response.json())
9041055

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-
9681056
def new_acceptance_test(
9691057
self,
9701058
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

nextmv/nextmv/default_app/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Nextmv Application
2+
3+
This is the basic structure of a Nextmv application.
4+
5+
```text
6+
├── app.yaml
7+
├── README.md
8+
├── requirements.txt
9+
└── src
10+
```
11+
12+
* `app.yaml`: App manifest, containing the configuration to run the app
13+
remotely on Nextmv Cloud.
14+
* `README.md`: Description of the app.
15+
* `requirements.txt`: Python dependencies for the app.
16+
* `src/`: Source code for the app. The `main.py` file is the entry point for
17+
the app.

nextmv/nextmv/default_app/app.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# This manifest holds the information the app needs to run on the Nextmv Cloud.
2+
type: python
3+
runtime: ghcr.io/nextmv-io/runtime/python:3.11
4+
python:
5+
# All listed packages will get bundled with the app.
6+
pip-requirements: requirements.txt
7+
8+
# List all files/directories that should be included in the app. Globbing
9+
# (e.g.: configs/*.json) is supported.
10+
files:
11+
- src/
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
nextmv>=0.29.3
2+
plotly>=6.2.0

nextmv/nextmv/default_app/src/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)