nextmv 1.0.0.dev7__py3-none-any.whl → 1.0.0.dev9__py3-none-any.whl
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.
- nextmv/__about__.py +1 -1
- nextmv/cli/cloud/app/push.py +15 -15
- nextmv/cli/cloud/input_set/__init__.py +0 -2
- nextmv/cli/cloud/run/create.py +9 -4
- nextmv/cli/cloud/shadow/stop.py +2 -14
- nextmv/cli/cloud/switchback/stop.py +2 -14
- nextmv/cli/community/clone.py +198 -11
- nextmv/cli/community/list.py +116 -46
- nextmv/cloud/__init__.py +0 -4
- nextmv/cloud/application/__init__.py +198 -1
- nextmv/cloud/application/_acceptance.py +8 -13
- nextmv/cloud/application/_input_set.py +6 -42
- nextmv/cloud/application/_run.py +2 -2
- nextmv/cloud/application/_shadow.py +3 -9
- nextmv/cloud/application/_switchback.py +2 -11
- nextmv/cloud/batch_experiment.py +1 -3
- nextmv/cloud/integration.py +4 -7
- nextmv/cloud/shadow.py +0 -25
- nextmv/cloud/switchback.py +0 -2
- nextmv/model.py +14 -11
- nextmv/options.py +1 -1
- {nextmv-1.0.0.dev7.dist-info → nextmv-1.0.0.dev9.dist-info}/METADATA +3 -6
- {nextmv-1.0.0.dev7.dist-info → nextmv-1.0.0.dev9.dist-info}/RECORD +26 -28
- nextmv/cli/cloud/input_set/delete.py +0 -67
- nextmv/cloud/community.py +0 -441
- {nextmv-1.0.0.dev7.dist-info → nextmv-1.0.0.dev9.dist-info}/WHEEL +0 -0
- {nextmv-1.0.0.dev7.dist-info → nextmv-1.0.0.dev9.dist-info}/entry_points.txt +0 -0
- {nextmv-1.0.0.dev7.dist-info → nextmv-1.0.0.dev9.dist-info}/licenses/LICENSE +0 -0
nextmv/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "v1.0.0.
|
|
1
|
+
__version__ = "v1.0.0.dev9"
|
nextmv/cli/cloud/app/push.py
CHANGED
|
@@ -129,34 +129,26 @@ def push(
|
|
|
129
129
|
$ [dim]nextmv cloud app push --app-id hare-app --version-yes --update-instance-id inst-1[/dim]
|
|
130
130
|
"""
|
|
131
131
|
|
|
132
|
-
cloud_app = build_app(app_id=app_id, profile=profile)
|
|
133
|
-
|
|
134
|
-
# If a version already exists, we cannot create it.
|
|
135
|
-
if version_id is not None and version_id != "":
|
|
136
|
-
exists = cloud_app.version_exists(version_id=version_id)
|
|
137
|
-
if exists:
|
|
138
|
-
error(
|
|
139
|
-
f"Version [magenta]{version_id}[/magenta] already exists for application [magenta]{app_id}[/magenta]."
|
|
140
|
-
)
|
|
141
|
-
|
|
142
132
|
# We cannot create and update an instance at the same time.
|
|
143
133
|
update_defined = update_instance_id is not None and update_instance_id != ""
|
|
144
134
|
create_defined = create_instance_id is not None and create_instance_id != ""
|
|
145
135
|
if update_defined and create_defined:
|
|
146
136
|
error("Cannot use --update-instance-id and --create-instance-id at the same time.")
|
|
147
137
|
|
|
138
|
+
cloud_app = build_app(app_id=app_id, profile=profile)
|
|
139
|
+
|
|
148
140
|
# We cannot update an instance that does not exist.
|
|
149
141
|
if update_defined and not cloud_app.instance_exists(instance_id=update_instance_id):
|
|
150
142
|
error(
|
|
151
|
-
f"Used option --update-instance-id but the instance
|
|
152
|
-
"
|
|
143
|
+
f"Used option --update-instance-id but the instance {update_instance_id} does not exist. "
|
|
144
|
+
"Use --create-instance-id instead."
|
|
153
145
|
)
|
|
154
146
|
|
|
155
147
|
# We cannot create an instance that already exists.
|
|
156
148
|
if create_defined and cloud_app.instance_exists(instance_id=create_instance_id):
|
|
157
149
|
error(
|
|
158
|
-
f"Used option --create-instance-id but the instance
|
|
159
|
-
"
|
|
150
|
+
f"Used option --create-instance-id but the instance {create_instance_id} already exists. "
|
|
151
|
+
"Use --update-instance-id instead."
|
|
160
152
|
)
|
|
161
153
|
|
|
162
154
|
# Do the normal push first.
|
|
@@ -251,6 +243,14 @@ def _handle_version_creation(
|
|
|
251
243
|
# If the user provides a version, and it exists, we use it directly and we
|
|
252
244
|
# are done.
|
|
253
245
|
if version_id is not None and version_id != "":
|
|
246
|
+
exists = cloud_app.version_exists(version_id=version_id)
|
|
247
|
+
if exists:
|
|
248
|
+
error(
|
|
249
|
+
f"Version [magenta]{version_id}[/magenta] already exists for application [magenta]{app_id}[/magenta]."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return "", False
|
|
253
|
+
|
|
254
254
|
info(
|
|
255
255
|
msg=f"Version [magenta]{version_id}[/magenta] does not exist. A new version will be created.",
|
|
256
256
|
emoji=":bulb:",
|
|
@@ -261,7 +261,7 @@ def _handle_version_creation(
|
|
|
261
261
|
# If we are not auto-confirming version creation, ask the user.
|
|
262
262
|
if not version_yes:
|
|
263
263
|
should_create = get_confirmation(
|
|
264
|
-
msg=f"Do you want to create a new version
|
|
264
|
+
msg=f"Do you want to create a new version [magenta]{app_id}[/magenta] now?",
|
|
265
265
|
default=True,
|
|
266
266
|
)
|
|
267
267
|
|
|
@@ -5,7 +5,6 @@ This module defines the cloud input-set command tree for the Nextmv CLI.
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
7
|
from nextmv.cli.cloud.input_set.create import app as create_app
|
|
8
|
-
from nextmv.cli.cloud.input_set.delete import app as delete_app
|
|
9
8
|
from nextmv.cli.cloud.input_set.get import app as get_app
|
|
10
9
|
from nextmv.cli.cloud.input_set.list import app as list_app
|
|
11
10
|
from nextmv.cli.cloud.input_set.update import app as update_app
|
|
@@ -13,7 +12,6 @@ from nextmv.cli.cloud.input_set.update import app as update_app
|
|
|
13
12
|
# Set up subcommand application.
|
|
14
13
|
app = typer.Typer()
|
|
15
14
|
app.add_typer(create_app)
|
|
16
|
-
app.add_typer(delete_app)
|
|
17
15
|
app.add_typer(get_app)
|
|
18
16
|
app.add_typer(list_app)
|
|
19
17
|
app.add_typer(update_app)
|
nextmv/cli/cloud/run/create.py
CHANGED
|
@@ -129,7 +129,7 @@ def create(
|
|
|
129
129
|
metavar="INSTANCE_ID",
|
|
130
130
|
rich_help_panel="Run configuration",
|
|
131
131
|
),
|
|
132
|
-
] =
|
|
132
|
+
] = "latest",
|
|
133
133
|
integration_id: Annotated[
|
|
134
134
|
str | None,
|
|
135
135
|
typer.Option(
|
|
@@ -240,11 +240,12 @@ def create(
|
|
|
240
240
|
specify the instance with the --instance-id flag. These are the possible
|
|
241
241
|
values for this flag:
|
|
242
242
|
|
|
243
|
-
- [yellow]unspecified[/yellow]: Run against the default instance of the
|
|
244
|
-
application. When an application is created, the default instance is [magenta]latest[/magenta].
|
|
245
243
|
- [yellow]latest[/yellow]: uses the special [magenta]latest[/magenta]
|
|
246
244
|
instance of the application. This corresponds to the latest pushed
|
|
247
|
-
executable.
|
|
245
|
+
executable. This is the default behavior.
|
|
246
|
+
- [yellow]default[/yellow]: if the application has a [italic]default[/italic]
|
|
247
|
+
instance configured, then it uses that instance. Setting the flag's value
|
|
248
|
+
to [magenta]''[/magenta] (empty string) has the same effect.
|
|
248
249
|
- [yellow]<INSTANCE_ID>[/yellow]: uses the instance with the given ID.
|
|
249
250
|
|
|
250
251
|
[bold][underline]Examples[/underline][/bold]
|
|
@@ -318,6 +319,10 @@ def create(
|
|
|
318
319
|
)
|
|
319
320
|
run_options = build_run_options(options)
|
|
320
321
|
|
|
322
|
+
# Handles the default instance.
|
|
323
|
+
if instance_id == "default":
|
|
324
|
+
instance_id = ""
|
|
325
|
+
|
|
321
326
|
# Start the run before deciding if we should poll or not.
|
|
322
327
|
input_kwarg = resolve_input_kwarg(
|
|
323
328
|
stdin=stdin,
|
nextmv/cli/cloud/shadow/stop.py
CHANGED
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
This module defines the cloud shadow stop command for the Nextmv CLI.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
5
|
import typer
|
|
8
6
|
|
|
9
7
|
from nextmv.cli.configuration.config import build_app
|
|
10
|
-
from nextmv.cli.message import
|
|
8
|
+
from nextmv.cli.message import in_progress, success
|
|
11
9
|
from nextmv.cli.options import AppIDOption, ProfileOption, ShadowTestIDOption
|
|
12
|
-
from nextmv.cloud.shadow import StopIntent
|
|
13
10
|
|
|
14
11
|
# Set up subcommand application.
|
|
15
12
|
app = typer.Typer()
|
|
@@ -18,15 +15,6 @@ app = typer.Typer()
|
|
|
18
15
|
@app.command()
|
|
19
16
|
def stop(
|
|
20
17
|
app_id: AppIDOption,
|
|
21
|
-
intent: Annotated[
|
|
22
|
-
StopIntent,
|
|
23
|
-
typer.Option(
|
|
24
|
-
"--intent",
|
|
25
|
-
"-i",
|
|
26
|
-
help=f"Intent for stopping the shadow test. Allowed values are: {enum_values(StopIntent)}.",
|
|
27
|
-
metavar="INTENT",
|
|
28
|
-
),
|
|
29
|
-
],
|
|
30
18
|
shadow_test_id: ShadowTestIDOption,
|
|
31
19
|
profile: ProfileOption = None,
|
|
32
20
|
) -> None:
|
|
@@ -46,7 +34,7 @@ def stop(
|
|
|
46
34
|
|
|
47
35
|
in_progress(msg="Stopping shadow test...")
|
|
48
36
|
cloud_app = build_app(app_id=app_id, profile=profile)
|
|
49
|
-
cloud_app.stop_shadow_test(shadow_test_id=shadow_test_id
|
|
37
|
+
cloud_app.stop_shadow_test(shadow_test_id=shadow_test_id)
|
|
50
38
|
success(
|
|
51
39
|
f"Shadow test [magenta]{shadow_test_id}[/magenta] stopped successfully "
|
|
52
40
|
f"in application [magenta]{app_id}[/magenta]."
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
This module defines the cloud switchback stop command for the Nextmv CLI.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
5
|
import typer
|
|
8
6
|
|
|
9
7
|
from nextmv.cli.configuration.config import build_app
|
|
10
|
-
from nextmv.cli.message import
|
|
8
|
+
from nextmv.cli.message import in_progress, success
|
|
11
9
|
from nextmv.cli.options import AppIDOption, ProfileOption, SwitchbackTestIDOption
|
|
12
|
-
from nextmv.cloud.shadow import StopIntent
|
|
13
10
|
|
|
14
11
|
# Set up subcommand application.
|
|
15
12
|
app = typer.Typer()
|
|
@@ -18,15 +15,6 @@ app = typer.Typer()
|
|
|
18
15
|
@app.command()
|
|
19
16
|
def stop(
|
|
20
17
|
app_id: AppIDOption,
|
|
21
|
-
intent: Annotated[
|
|
22
|
-
StopIntent,
|
|
23
|
-
typer.Option(
|
|
24
|
-
"--intent",
|
|
25
|
-
"-i",
|
|
26
|
-
help=f"Intent for stopping the switchback test. Allowed values are: {enum_values(StopIntent)}.",
|
|
27
|
-
metavar="INTENT",
|
|
28
|
-
),
|
|
29
|
-
],
|
|
30
18
|
switchback_test_id: SwitchbackTestIDOption,
|
|
31
19
|
profile: ProfileOption = None,
|
|
32
20
|
) -> None:
|
|
@@ -46,7 +34,7 @@ def stop(
|
|
|
46
34
|
|
|
47
35
|
in_progress(msg="Stopping switchback test...")
|
|
48
36
|
cloud_app = build_app(app_id=app_id, profile=profile)
|
|
49
|
-
cloud_app.stop_switchback_test(switchback_test_id=switchback_test_id
|
|
37
|
+
cloud_app.stop_switchback_test(switchback_test_id=switchback_test_id)
|
|
50
38
|
success(
|
|
51
39
|
f"Switchback test [magenta]{switchback_test_id}[/magenta] stopped successfully "
|
|
52
40
|
f"in application [magenta]{app_id}[/magenta]."
|
nextmv/cli/community/clone.py
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
This module defines the community clone command for the Nextmv CLI.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable
|
|
5
10
|
from typing import Annotated
|
|
6
11
|
|
|
12
|
+
import rich
|
|
7
13
|
import typer
|
|
8
14
|
|
|
9
|
-
from nextmv.cli.
|
|
10
|
-
from nextmv.cli.message import error
|
|
15
|
+
from nextmv.cli.community.list import download_file, download_manifest, find_app, versions_table
|
|
16
|
+
from nextmv.cli.message import error, success
|
|
11
17
|
from nextmv.cli.options import ProfileOption
|
|
12
|
-
from nextmv.cloud.community import clone_community_app
|
|
13
18
|
|
|
14
19
|
# Set up subcommand application.
|
|
15
20
|
app = typer.Typer()
|
|
@@ -72,15 +77,197 @@ def clone(
|
|
|
72
77
|
$ [dim]nextmv community clone --app go-nextroute --profile hare[/dim]
|
|
73
78
|
"""
|
|
74
79
|
|
|
80
|
+
manifest = download_manifest(profile=profile)
|
|
81
|
+
app_obj = find_app(manifest, app)
|
|
82
|
+
|
|
75
83
|
if version is not None and version == "":
|
|
76
84
|
error("The --version flag cannot be an empty string.")
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
if not app_has_version(app_obj, version):
|
|
87
|
+
# We don't use error() here to allow printing something before exiting.
|
|
88
|
+
rich.print(
|
|
89
|
+
f"[red]Error:[/red] Version [magenta]{version}[/magenta] not found "
|
|
90
|
+
f"for community app [magenta]{app}[/magenta]. Available versions are:"
|
|
91
|
+
)
|
|
92
|
+
versions_table(manifest, app)
|
|
93
|
+
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
original_version = version
|
|
97
|
+
if version == LATEST_VERSION:
|
|
98
|
+
version = app_obj.get("latest_app_version")
|
|
99
|
+
|
|
100
|
+
# Clean and normalize directory path in an OS-independent way
|
|
101
|
+
if directory is not None and directory != "":
|
|
102
|
+
destination = os.path.normpath(directory)
|
|
103
|
+
else:
|
|
104
|
+
destination = app
|
|
105
|
+
|
|
106
|
+
full_destination = get_valid_path(destination, os.stat)
|
|
107
|
+
os.makedirs(full_destination, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
tarball = f"{app}_{version}.tar.gz"
|
|
110
|
+
s3_file_path = f"{app}/{version}/{tarball}"
|
|
111
|
+
downloaded_object = download_object(
|
|
112
|
+
file=s3_file_path,
|
|
113
|
+
path="community-apps",
|
|
114
|
+
output_dir=full_destination,
|
|
115
|
+
output_file=tarball,
|
|
116
|
+
profile=profile,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Extract the tarball to a temporary directory to handle nested structure
|
|
120
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
121
|
+
with tarfile.open(downloaded_object, "r:gz") as tar:
|
|
122
|
+
tar.extractall(path=temp_dir, filter=None)
|
|
123
|
+
|
|
124
|
+
# Find the extracted directory (typically the app name)
|
|
125
|
+
extracted_items = os.listdir(temp_dir)
|
|
126
|
+
if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_dir, extracted_items[0])):
|
|
127
|
+
# Move contents from the extracted directory to full_destination
|
|
128
|
+
extracted_dir = os.path.join(temp_dir, extracted_items[0])
|
|
129
|
+
for item in os.listdir(extracted_dir):
|
|
130
|
+
shutil.move(os.path.join(extracted_dir, item), full_destination)
|
|
131
|
+
else:
|
|
132
|
+
# If structure is unexpected, move everything directly
|
|
133
|
+
for item in extracted_items:
|
|
134
|
+
shutil.move(os.path.join(temp_dir, item), full_destination)
|
|
135
|
+
|
|
136
|
+
# Remove the tarball after extraction
|
|
137
|
+
os.remove(downloaded_object)
|
|
138
|
+
|
|
139
|
+
success(
|
|
140
|
+
f"Successfully cloned the [magenta]{app}[/magenta] community app, "
|
|
141
|
+
f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta]."
|
|
86
142
|
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def app_has_version(app_obj: dict, version: str) -> bool:
|
|
146
|
+
"""
|
|
147
|
+
Check if the given app object has the specified version.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
app_obj : dict
|
|
152
|
+
The community app object.
|
|
153
|
+
version : str
|
|
154
|
+
The version to check.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
bool
|
|
159
|
+
True if the app has the specified version, False otherwise.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if version == LATEST_VERSION:
|
|
163
|
+
version = app_obj.get("latest_app_version")
|
|
164
|
+
|
|
165
|
+
if version in app_obj.get("app_versions", []):
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_valid_path(path: str, stat_fn: Callable[[str], os.stat_result], ending: str = "") -> str:
|
|
172
|
+
"""
|
|
173
|
+
Validates and returns a non-existing path. If the path exists,
|
|
174
|
+
it will append a number to the path and return it. If the path does not
|
|
175
|
+
exist, it will return the path as is.
|
|
176
|
+
|
|
177
|
+
The ending parameter is used to check if the path ends with a specific
|
|
178
|
+
string. This is useful to specify if it is a file (like foo.json, in which
|
|
179
|
+
case the next iteration is foo-1.json) or a directory (like foo, in which
|
|
180
|
+
case the next iteration is foo-1).
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
path : str
|
|
185
|
+
The initial path to validate.
|
|
186
|
+
stat_fn : Callable[[str], os.stat_result]
|
|
187
|
+
A function that takes a path and returns its stat result.
|
|
188
|
+
ending : str, optional
|
|
189
|
+
The expected ending of the path (e.g., file extension), by default "".
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
str
|
|
194
|
+
A valid, non-existing path.
|
|
195
|
+
|
|
196
|
+
Raises
|
|
197
|
+
------
|
|
198
|
+
Exception
|
|
199
|
+
If an unexpected error occurs during path validation
|
|
200
|
+
"""
|
|
201
|
+
base_name = os.path.basename(path)
|
|
202
|
+
name_without_ending = base_name.removesuffix(ending) if ending else base_name
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
stat_fn(path)
|
|
207
|
+
# If we get here, the path exists
|
|
208
|
+
# Get folder/file name number, increase it and create new path
|
|
209
|
+
name = os.path.basename(path)
|
|
210
|
+
|
|
211
|
+
# Get folder/file name number
|
|
212
|
+
parts = name.split("-")
|
|
213
|
+
last = parts[-1].removesuffix(ending) if ending else parts[-1]
|
|
214
|
+
|
|
215
|
+
# Save last folder name index to be changed
|
|
216
|
+
i = path.rfind(name)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
num = int(last)
|
|
220
|
+
# Increase number and create new path
|
|
221
|
+
if ending:
|
|
222
|
+
temp_path = path[:i] + f"{name_without_ending}-{num + 1}{ending}"
|
|
223
|
+
else:
|
|
224
|
+
temp_path = path[:i] + f"{base_name}-{num + 1}"
|
|
225
|
+
path = temp_path
|
|
226
|
+
except ValueError:
|
|
227
|
+
# If there is no number, add it
|
|
228
|
+
if ending:
|
|
229
|
+
temp_path = path[:i] + f"{name_without_ending}-1{ending}"
|
|
230
|
+
else:
|
|
231
|
+
temp_path = path[:i] + f"{name}-1"
|
|
232
|
+
path = temp_path
|
|
233
|
+
|
|
234
|
+
except FileNotFoundError:
|
|
235
|
+
# Path doesn't exist, we can use it
|
|
236
|
+
return path
|
|
237
|
+
except Exception:
|
|
238
|
+
# Re-raise unexpected errors
|
|
239
|
+
error(f"An unexpected error occurred while validating the path: {path}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def download_object(file: str, path: str, output_dir: str, output_file: str, profile: str | None = None) -> str:
|
|
243
|
+
"""
|
|
244
|
+
Downloads an object from the internal bucket and saves it to the specified
|
|
245
|
+
output directory.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
file : str
|
|
250
|
+
The name of the file to download.
|
|
251
|
+
path : str
|
|
252
|
+
The directory in the bucket where the file is located.
|
|
253
|
+
output_dir : str
|
|
254
|
+
The local directory where the file will be saved.
|
|
255
|
+
output_file : str
|
|
256
|
+
The name of the output file.
|
|
257
|
+
profile : str | None
|
|
258
|
+
The profile name to use. If None, the default profile is used.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
str
|
|
263
|
+
The path to the downloaded file.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
response = download_file(directory=path, file=file, profile=profile)
|
|
267
|
+
file_name = os.path.join(output_dir, output_file)
|
|
268
|
+
|
|
269
|
+
with open(file_name, "wb") as f:
|
|
270
|
+
f.write(response.content)
|
|
271
|
+
|
|
272
|
+
return file_name
|
|
273
|
+
return file_name
|