nextmv 0.1.0.dev2__tar.gz → 0.1.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/PKG-INFO +2 -1
  2. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/application.py +11 -10
  3. nextmv-0.1.0.dev4/nextmv/nextroute/check/__init__.py +26 -0
  4. nextmv-0.1.0.dev4/nextmv/nextroute/check/schema.py +140 -0
  5. nextmv-0.1.0.dev4/nextmv/nextroute/schema/__init__.py +25 -0
  6. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/nextroute/schema/input.py +1 -1
  7. nextmv-0.1.0.dev4/nextmv/nextroute/schema/output.py +202 -0
  8. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/pyproject.toml +4 -1
  9. nextmv-0.1.0.dev4/tests/nextroute/schema/output.json +851 -0
  10. nextmv-0.1.0.dev4/tests/nextroute/schema/output_with_check.json +954 -0
  11. nextmv-0.1.0.dev4/tests/nextroute/schema/test_output.py +316 -0
  12. nextmv-0.1.0.dev2/nextmv/nextroute/schema/__init__.py +0 -12
  13. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/.github/workflows/publish.yml +0 -0
  14. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/.github/workflows/python-lint.yml +0 -0
  15. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/.github/workflows/python-test.yml +0 -0
  16. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/.gitignore +0 -0
  17. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/LICENSE +0 -0
  18. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/README.md +0 -0
  19. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/__init__.py +0 -0
  20. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/base_model.py +0 -0
  21. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/__init__.py +0 -0
  22. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/acceptance_test.py +0 -0
  23. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/batch_experiment.py +0 -0
  24. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/client.py +0 -0
  25. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/cloud/input_set.py +0 -0
  26. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/nextroute/__init__.py +0 -0
  27. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/nextroute/schema/location.py +0 -0
  28. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/nextroute/schema/stop.py +0 -0
  29. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv/nextroute/schema/vehicle.py +0 -0
  30. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/nextmv-py.code-workspace +0 -0
  31. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/requirements.txt +0 -0
  32. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/__init__.py +0 -0
  33. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/nextroute/__init__.py +0 -0
  34. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/nextroute/schema/__init__.py +0 -0
  35. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/nextroute/schema/input.json +0 -0
  36. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/nextroute/schema/test_input.py +0 -0
  37. {nextmv-0.1.0.dev2 → nextmv-0.1.0.dev4}/tests/test_base_model.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nextmv
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev4
4
4
  Summary: The Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://www.nextmv.io/docs
7
7
  Project-URL: Repository, https://github.com/nextmv-io/nextmv-py
8
8
  Author-email: Nextmv <tech@nextmv.io>
9
+ Maintainer-email: Nextmv <tech@nextmv.io>
9
10
  License: Apache License
10
11
  Version 2.0, January 2004
11
12
  http://www.apache.org/licenses/
@@ -78,6 +78,10 @@ class PollingOptions(BaseModel):
78
78
  """Maximum number of tries to use."""
79
79
 
80
80
 
81
+ _DEFAULT_POLLING_OPTIONS: PollingOptions = PollingOptions()
82
+ """Default polling options to use when polling for a run result."""
83
+
84
+
81
85
  class RunInformation(BaseModel):
82
86
  """Information of a run."""
83
87
 
@@ -473,7 +477,7 @@ class Application:
473
477
  upload_id_used = upload_id is not None
474
478
  if not upload_id_used and input_size > _MAX_RUN_SIZE:
475
479
  upload_url = self.upload_url()
476
- self.upload_large_input(upload_url=upload_url, input=input)
480
+ self.upload_large_input(input=input, upload_url=upload_url)
477
481
  upload_id = upload_url.upload_id
478
482
  upload_id_used = True
479
483
 
@@ -510,7 +514,7 @@ class Application:
510
514
  description: str | None = None,
511
515
  upload_id: str | None = None,
512
516
  run_options: dict[str, Any] | None = None,
513
- polling_options: PollingOptions = None,
517
+ polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
514
518
  ) -> RunResult:
515
519
  """
516
520
  Submit an input to start a new run of the application and poll for the
@@ -526,8 +530,7 @@ class Application:
526
530
  description: Description of the run.
527
531
  upload_id: ID to use when running a large input.
528
532
  run_options: Options to use for the run.
529
- polling_options: Options to use when polling for the run result. If
530
- not provided, the default options will be used.
533
+ polling_options: Options to use when polling for the run result.
531
534
 
532
535
  Returns:
533
536
  Result of the run.
@@ -596,7 +599,7 @@ class Application:
596
599
  def run_result_with_polling(
597
600
  self,
598
601
  run_id: str,
599
- polling_options: PollingOptions,
602
+ polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
600
603
  ) -> RunResult:
601
604
  """
602
605
  Get the result of a run. The result includes the run output. This
@@ -605,6 +608,7 @@ class Application:
605
608
 
606
609
  Args:
607
610
  run_id: ID of the run.
611
+ polling_options: Options to use when polling for the run result.
608
612
 
609
613
  Returns:
610
614
  Result of the run.
@@ -613,9 +617,6 @@ class Application:
613
617
  requests.HTTPError: If the response status code is not 2xx.
614
618
  """
615
619
 
616
- if polling_options is None:
617
- polling_options = PollingOptions()
618
-
619
620
  time.sleep(polling_options.initial_delay)
620
621
  delay = polling_options.delay
621
622
  polling_ok = False
@@ -643,15 +644,15 @@ class Application:
643
644
 
644
645
  def upload_large_input(
645
646
  self,
646
- upload_url: UploadURL,
647
647
  input: dict[str, Any],
648
+ upload_url: UploadURL,
648
649
  ) -> None:
649
650
  """
650
651
  Upload the file located at the given path to the provided upload URL.
651
652
 
652
653
  Args:
653
654
  upload_url: Upload URL to use for uploading the file.
654
- input_path: Path to the input file.
655
+ input: Input to use for the run.
655
656
 
656
657
  Raises:
657
658
  requests.HTTPError: If the response status code is not 2xx.
@@ -0,0 +1,26 @@
1
+ """
2
+ Check provides a plugin that allows you check models and solutions.
3
+
4
+ Checking a model or a solution checks the unplanned plan units. It checks each
5
+ individual plan unit if it can be added to the solution. If the plan unit can
6
+ be added to the solution, the report will include on how many vehicles and
7
+ what the impact would be on the objective value. If the plan unit cannot be
8
+ added to the solution, the report will include the reason why it cannot be
9
+ added to the solution.
10
+
11
+ The check can be invoked on a nextroute.Model or a nextroute.Solution. If the
12
+ check is invoked on a model, an empty solution is created and the check is
13
+ executed on this empty solution. An empty solution is a solution with all the
14
+ initial stops that are fixed, initial stops that are not fixed are not added
15
+ to the solution. The check is executed on the unplanned plan units of the
16
+ solution. If the check is invoked on a solution, it is executed on the
17
+ unplanned plan units of the solution.
18
+ """
19
+
20
+ from .schema import Objective as Objective
21
+ from .schema import ObjectiveTerm as ObjectiveTerm
22
+ from .schema import Output as Output
23
+ from .schema import PlanUnit as PlanUnit
24
+ from .schema import Solution as Solution
25
+ from .schema import Summary as Summary
26
+ from .schema import Vehicle as Vehicle
@@ -0,0 +1,140 @@
1
+ """This module contains definitions for the schema in the Nextroute check."""
2
+
3
+
4
+ from nextmv.base_model import BaseModel
5
+
6
+
7
+ class ObjectiveTerm(BaseModel):
8
+ """Check of the individual terms of the objective for a move."""
9
+
10
+ base: float | None = None
11
+ """Base of the objective term."""
12
+ factor: float | None = None
13
+ """Factor of the objective term."""
14
+ name: str | None = None
15
+ """Name of the objective term."""
16
+ value: float | None = None
17
+ """Value of the objective term, which is equivalent to `self.base *
18
+ self.factor`."""
19
+
20
+
21
+ class Objective(BaseModel):
22
+ """Estimate of an objective of a move."""
23
+
24
+ terms: list[ObjectiveTerm] | None = None
25
+ """Check of the individual terms of the objective."""
26
+ value: float | None = None
27
+ """Value of the objective."""
28
+ vehicle: str | None = None
29
+ """ID of the vehicle for which it reports the objective."""
30
+
31
+
32
+ class Solution(BaseModel):
33
+ """Solution that the check has been executed on."""
34
+
35
+ objective: Objective | None = None
36
+ """Objective of the start solution."""
37
+ plan_units_planned: int | None = None
38
+ """Number of plan units planned in the start solution."""
39
+ plan_units_unplanned: int | None = None
40
+ """Number of plan units unplanned in the start solution."""
41
+ stops_planned: int | None = None
42
+ """Number of stops planned in the start solution."""
43
+ vehicles_not_used: int | None = None
44
+ """Number of vehicles not used in the start solution."""
45
+ vehicles_used: int | None = None
46
+ """Number of vehicles used in the start solution."""
47
+
48
+
49
+ class Summary(BaseModel):
50
+ """Summary of the check."""
51
+
52
+ moves_failed: int | None = None
53
+ """number of moves that failed. A move can fail if the estimate of a
54
+ constraint is incorrect. A constraint is incorrect if `ModelConstraint.
55
+ EstimateIsViolated` returns true and one of the violation checks returns
56
+ false. Violation checks are implementations of one or more of the
57
+ interfaces [SolutionStopViolationCheck], [SolutionVehicleViolationCheck] or
58
+ [SolutionViolationCheck] on the same constraint. Most constraints do not
59
+ need and do not have violation checks as the estimate is perfect. The
60
+ number of moves failed can be more than one per plan unit as we continue to
61
+ try moves on different vehicles until we find a move that is executable or
62
+ all vehicles have been visited."""
63
+ plan_units_best_move_failed: int | None = None
64
+ """Number of plan units for which the best move can not be planned. This
65
+ should not happen if all the constraints are implemented correct."""
66
+ plan_units_best_move_found: int | None = None
67
+ """Number of plan units for which at least one move has been found and the
68
+ move is executable."""
69
+ plan_units_best_move_increases_objective: int | None = None
70
+ """Number of plan units for which the best move is executable but would
71
+ increase the objective value instead of decreasing it."""
72
+ plan_units_checked: int | None = None
73
+ """Number of plan units that have been checked. If this is less than
74
+ `self.plan_units_to_be_checked` the check timed out."""
75
+ plan_units_have_no_move: int | None = None
76
+ """Number of plan units for which no feasible move has been found. This
77
+ implies there is no move that can be executed without violating a
78
+ constraint."""
79
+ plan_units_to_be_checked: int | None = None
80
+ """Number of plan units to be checked."""
81
+
82
+
83
+ class PlanUnit(BaseModel):
84
+ """Check of a plan unit."""
85
+
86
+ best_move_failed: bool | None = None
87
+ """True if the plan unit's best move failed to execute."""
88
+ best_move_increases_objective: bool | None = None
89
+ """True if the best move for the plan unit increases the objective."""
90
+ best_move_objective: Objective | None = None
91
+ """Estimate of the objective of the best move if the plan unit has a best
92
+ move."""
93
+ constraints: dict[str, int] | None = None
94
+ """Constraints that are violated for the plan unit."""
95
+ has_best_move: bool | None = None
96
+ """True if a move is found for the plan unit. A plan unit has no move found
97
+ if the plan unit is over-constrained or the move found is too expensive."""
98
+ stops: list[str] | None = None
99
+ """IDs of the sops in the plan unit."""
100
+ vehicles_have_moves: int | None = None
101
+ """Number of vehicles that have moves for the plan unit. Only calculated if
102
+ the verbosity is very high."""
103
+ vehicles_with_moves: list[str] | None = None
104
+ """IDs of the vehicles that have moves for the plan unit. Only calculated
105
+ if the verbosity is very high."""
106
+
107
+
108
+ class Vehicle(BaseModel):
109
+ """Check of a vehicle."""
110
+
111
+ id: str
112
+ """ID of the vehicle."""
113
+
114
+ plan_units_have_moves: int | None = None
115
+ """Number of plan units that have moves for the vehicle. Only calculated if
116
+ the depth is medium."""
117
+
118
+
119
+ class Output(BaseModel):
120
+ """Output of a feasibility check."""
121
+
122
+ duration_maximum: float | None = None
123
+ """Maximum duration of the check, in seconds."""
124
+ duration_used: float | None = None
125
+ """Duration used by the check, in seconds."""
126
+ error: str | None = None
127
+ """Error raised during the check."""
128
+ plan_units: list[PlanUnit] | None = None
129
+ """Check of the individual plan units."""
130
+ remark: str | None = None
131
+ """Remark of the check. It can be "ok", "timeout" or anything else that
132
+ should explain itself."""
133
+ solution: Solution | None = None
134
+ """Start soltuion of the check."""
135
+ summary: Summary | None = None
136
+ """Summary of the check."""
137
+ vehicles: list[Vehicle] | None = None
138
+ """Check of the vehicles."""
139
+ verbosity: str | None = None
140
+ """Verbosity level of the check."""
@@ -0,0 +1,25 @@
1
+ """Schema (class) definitions for the entities in Nextroute."""
2
+
3
+ from .input import Defaults as Defaults
4
+ from .input import DurationGroup as DurationGroup
5
+ from .input import Input as Input
6
+ from .location import Location as Location
7
+ from .output import DataPoint as DataPoint
8
+ from .output import ObjectiveOutput as ObjectiveOutput
9
+ from .output import Output as Output
10
+ from .output import PlannedStopOutput as PlannedStopOutput
11
+ from .output import ResultStatistics as ResultStatistics
12
+ from .output import RunStatistics as RunStatistics
13
+ from .output import Series as Series
14
+ from .output import SeriesData as SeriesData
15
+ from .output import Solution as Solution
16
+ from .output import Statistics as Statistics
17
+ from .output import StopOutput as StopOutput
18
+ from .output import VehicleOutput as VehicleOutput
19
+ from .output import Version as Version
20
+ from .stop import AlternateStop as AlternateStop
21
+ from .stop import Stop as Stop
22
+ from .stop import StopDefaults as StopDefaults
23
+ from .vehicle import InitialStop as InitialStop
24
+ from .vehicle import Vehicle as Vehicle
25
+ from .vehicle import VehicleDefaults as VehicleDefaults
@@ -1,4 +1,4 @@
1
- """Defines the input class"""
1
+ """Defines the input class."""
2
2
 
3
3
  from typing import Any
4
4
 
@@ -0,0 +1,202 @@
1
+ """Defines the output class."""
2
+
3
+
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from pydantic import Field
8
+
9
+ from nextmv.base_model import BaseModel
10
+ from nextmv.nextroute.check import Output as checkOutput
11
+ from nextmv.nextroute.schema.location import Location
12
+
13
+
14
+ class Version(BaseModel):
15
+ """A version used for solving."""
16
+
17
+ sdk: str
18
+ """Nextmv SDK."""
19
+
20
+
21
+ class StopOutput(BaseModel):
22
+ """Basic structure for the output of a stop."""
23
+
24
+ id: str
25
+ """ID of the stop."""
26
+ location: Location
27
+ """Location of the stop."""
28
+
29
+ custom_data: Any | None = None
30
+ """Custom data of the stop."""
31
+
32
+
33
+ class PlannedStopOutput(BaseModel):
34
+ """Output of a stop planned in the solution."""
35
+
36
+ stop: StopOutput
37
+ """Basic information on the stop."""
38
+
39
+ arrival_time: datetime | None = None
40
+ """Actual arrival time at this stop."""
41
+ cumulative_travel_distance: float | None = None
42
+ """Cumulative distance to travel from the first stop to this one, in meters."""
43
+ cumulative_travel_duration: float | None = None
44
+ """Cumulative duration to travel from the first stop to this one, in seconds."""
45
+ custom_data: Any | None = None
46
+ """Custom data of the stop."""
47
+ duration: float | None = None
48
+ """Duration of the service at the stop, in seconds."""
49
+ early_arrival_duration: float | None = None
50
+ """Duration of early arrival at the stop, in seconds."""
51
+ end_time: datetime | None = None
52
+ """End time of the service at the stop."""
53
+ late_arrival_duration: float | None = None
54
+ """Duration of late arrival at the stop, in seconds."""
55
+ mix_items: Any | None = None
56
+ """Mix items at the stop."""
57
+ start_time: datetime | None = None
58
+ """Start time of the service at the stop."""
59
+ target_arrival_time: datetime | None = None
60
+ """Target arrival time at this stop."""
61
+ travel_distance: float | None = None
62
+ """Distance to travel from the previous stop to this one, in meters."""
63
+ travel_duration: float | None = None
64
+ """Duration to travel from the previous stop to this one, in seconds."""
65
+ waiting_duration: float | None = None
66
+ """Waiting duratino at the stop, in seconds."""
67
+
68
+
69
+ class VehicleOutput(BaseModel):
70
+ """Output of a vehicle in the solution."""
71
+
72
+ id: str
73
+ """ID of the vehicle."""
74
+
75
+ alternate_stops: list[str] | None = None
76
+ """List of alternate stops that were planned on the vehicle."""
77
+ custom_data: Any | None = None
78
+ """Custom data of the vehicle."""
79
+ route: list[PlannedStopOutput] | None = None
80
+ """Route of the vehicle, which is a list of stops that were planned on
81
+ it."""
82
+ route_duration: float | None = None
83
+ """Total duration of the vehicle's route, in seconds."""
84
+ route_stops_duration: float | None = None
85
+ """Total duration of the stops of the vehicle, in seconds."""
86
+ route_travel_distance: float | None = None
87
+ """Total travel distance of the vehicle, in meters."""
88
+ route_travel_duration: float | None = None
89
+ """Total travel duration of the vehicle, in seconds."""
90
+ route_waiting_duration: float | None = None
91
+ """Total waiting duration of the vehicle, in seconds."""
92
+
93
+
94
+ class ObjectiveOutput(BaseModel):
95
+ """Information of the objective (value function)."""
96
+
97
+ name: str
98
+ """Name of the objective."""
99
+
100
+ base: float | None = None
101
+ """Base of the objective."""
102
+ custom_data: Any | None = None
103
+ """Custom data of the objective."""
104
+ factor: float | None = None
105
+ """Factor of the objective."""
106
+ objectives: list[dict[str, Any]] | None = None
107
+ """List of objectives. Each list is actually of the same class
108
+ `ObjectiveOutput`, but we avoid a recursive definition here."""
109
+ value: float | None = None
110
+ """Value of the objective, which is equivalent to `self.base *
111
+ self.factor`."""
112
+
113
+
114
+ class Solution(BaseModel):
115
+ """Solution to a Vehicle Routing Problem (VRP)."""
116
+
117
+ unplanned: list[StopOutput] | None = None
118
+ """List of stops that were not planned in the solution."""
119
+ vehicles: list[VehicleOutput] | None = None
120
+ """List of vehicles in the solution."""
121
+ objective: ObjectiveOutput | None = None
122
+ """Information of the objective (value function)."""
123
+ check: checkOutput | None = None
124
+ """Check of the solution, if enabled."""
125
+
126
+
127
+ class RunStatistics(BaseModel):
128
+ """Statistics about a general run."""
129
+
130
+ duration: float | None = None
131
+ """Duration of the run in seconds."""
132
+ iterations: int | None = None
133
+ """Number of iterations."""
134
+ custom: Any | None = None
135
+ """Custom statistics created by the user. Can normally expect a `dict[str,
136
+ Any]`."""
137
+
138
+
139
+ class ResultStatistics(BaseModel):
140
+ """Statistics about a specific result."""
141
+
142
+ duration: float | None = None
143
+ """Duration of the run in seconds."""
144
+ value: float | None = None
145
+ """Value of the result."""
146
+ custom: Any | None = None
147
+ """Custom statistics created by the user. Can normally expect a `dict[str,
148
+ Any]`."""
149
+
150
+
151
+ class DataPoint(BaseModel):
152
+ """A data point."""
153
+
154
+ x: float
155
+ """X coordinate of the data point."""
156
+ y: float
157
+ """Y coordinate of the data point."""
158
+
159
+
160
+ class Series(BaseModel):
161
+ """A series of data points."""
162
+
163
+ name: str | None = None
164
+ """Name of the series."""
165
+ data_points: list[DataPoint] | None = None
166
+ """Data of the series."""
167
+
168
+
169
+ class SeriesData(BaseModel):
170
+ """Data of a series."""
171
+
172
+ value: Series | None = None
173
+ """A series for the value of the solution."""
174
+ custom: list[Series] | None = None
175
+ """A list of series for custom statistics."""
176
+
177
+
178
+ class Statistics(BaseModel):
179
+ """Statistics of a solution."""
180
+
181
+ run: RunStatistics | None = None
182
+ """Statistics about the run."""
183
+ result: ResultStatistics | None = None
184
+ """Statistics about the last result."""
185
+ series_data: SeriesData | None = None
186
+ """Data of the series."""
187
+ statistics_schema: str | None = Field(alias="schema")
188
+ """Schema (version)."""
189
+
190
+
191
+ class Output(BaseModel):
192
+ """Output schema for Nextroute."""
193
+
194
+ options: dict[str, Any]
195
+ """Options used to obtain this output."""
196
+ version: Version
197
+ """Versions used for the solution."""
198
+
199
+ solutions: list[Solution] | None = None
200
+ """Solutions to the problem."""
201
+ statistics: Statistics | None = None
202
+ """Statistics of the solution."""
@@ -28,10 +28,13 @@ keywords = [
28
28
  "vehicle routing problem",
29
29
  ]
30
30
  license = { file = "LICENSE" }
31
+ maintainers = [
32
+ { email = "tech@nextmv.io", name = "Nextmv" }
33
+ ]
31
34
  name = "nextmv"
32
35
  readme = "README.md"
33
36
  requires-python = ">=3.10"
34
- version = "0.1.0.dev2"
37
+ version = "0.1.0.dev4"
35
38
 
36
39
  [project.urls]
37
40
  Homepage = "https://www.nextmv.io"