Skip to content

Commit 3e4c9cf

Browse files
Source Code and Solution Download (#76)
* added fetch app source code logic * added solutions logic
1 parent 48f8346 commit 3e4c9cf

21 files changed

+1315
-56
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,37 @@
77
[//]: # (Features)
88
[//]: # (BREAKING CHANGES)
99

10+
## May 16th, 2024
11+
12+
### Download Application Source Code
13+
14+
A new script was added to download platform-generated source code:
15+
16+
* `fetch_apps_source_code.py`
17+
18+
Use the following parameters to generate more human-readable outputs and facilitate the compilation of the source code:
19+
20+
* --friendly_package_names: source code packages with user-friendly names.
21+
* --include_all_refs: adds to .csproj file all assemblies in the bin folder as references.
22+
* --remove_resources_files: removes references to embedded resources files from the.csproj file.
23+
24+
### Solution Download and Deploy
25+
26+
Added new functions to leverage the recently released/improved APIs to download and deploy outsystems packages:
27+
28+
* `fetch_lifetime_solution_from_manifest.py` - downloads a solution file based on manifest data.
29+
* `deploy_package_to_target_env.py` - deploys an outsystems package (solution or application) to a target environment.
30+
* `deploy_package_to_target_env_with_osptool.py` - deploys an outsystems package (solution or application) using OSP Tool.
31+
32+
### Improved OSPTool Operations
33+
34+
OSP Tool command line calls now have live output callback and catalog mapping support.
35+
36+
### Updated Package Dependencies
37+
38+
* Updated python-dateutil dependency to version 2.9.0.post0
39+
* Updated python-dotenv dependency to version 1.0.1
40+
1041
## November 15th, 2023
1142

1243
### Config File Support

INSTALL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ In order to be able to test locally, there's a few things you'll have to install
55
## Install Python
66

77
* Go to <https://www.python.org/downloads/>
8-
* Install Python v3.7.x (the code was tested with v3.7.1)
8+
* Install Python v3.11.x (the code was tested with v3.11.3)
99

1010
## Install Python dependencies
1111

build-requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
python-dateutil==2.8.2
1+
python-dateutil==2.9.0.post0
22
requests==2.31.0
33
unittest-xml-reporting==3.2.0
44
xunitparser==1.3.4
55
toposort==1.10
6-
python-dotenv==1.0.0
6+
python-dotenv==1.0.1
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class InvalidOutSystemsPackage(Exception):
2+
pass

outsystems/file_helpers/file.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
# Python Modules
22
import json
33
import os
4-
import requests
5-
6-
7-
def download_oap(file_path: str, auth_token: str, oap_url: str):
8-
response = requests.get(oap_url, headers={"Authorization": auth_token})
9-
# Makes sure that, if a directory is in the filename, that directory exists
10-
os.makedirs(os.path.dirname(file_path), exist_ok=True)
11-
with open(file_path, "wb") as f:
12-
f.write(response.content)
134

145

156
def store_data(artifact_dir: str, filename: str, data: str):
@@ -43,3 +34,12 @@ def clear_cache(artifact_dir: str, filename: str):
4334
return
4435
filename = os.path.join(artifact_dir, filename)
4536
os.remove(filename)
37+
38+
39+
# Returns a human readable string representation of bytes
40+
def bytes_human_readable_size(bytes, units=[' bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']):
41+
return str(bytes) + units[0] if bytes < 1024 else bytes_human_readable_size(bytes >> 10, units[1:])
42+
43+
44+
def is_valid_os_package(filename: str):
45+
return filename.lower().split('.')[-1] in ("osp", "oap")

outsystems/lifetime/lifetime_applications.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from outsystems.exceptions.environment_not_found import EnvironmentNotFoundError
1313
from outsystems.exceptions.app_version_error import AppVersionsError
1414
# Functions
15-
from outsystems.file_helpers.file import store_data, load_data, clear_cache, download_oap
15+
from outsystems.file_helpers.file import store_data, load_data, clear_cache
1616
from outsystems.lifetime.lifetime_base import send_get_request, send_post_request
17+
from outsystems.lifetime.lifetime_downloads import download_package
1718
# Variables
1819
from outsystems.vars.file_vars import APPLICATION_FOLDER, APPLICATIONS_FILE, APPLICATION_FILE, APPLICATION_VERSIONS_FILE, APPLICATION_VERSION_FILE
1920
from outsystems.vars.lifetime_vars import APPLICATIONS_ENDPOINT, APPLICATION_VERSIONS_ENDPOINT, APPLICATIONS_SUCCESS_CODE, \
@@ -158,7 +159,8 @@ def get_running_app_version(artifact_dir: str, endpoint: str, auth_token: str, e
158159
"ApplicationName": app_tuple[0],
159160
"ApplicationKey": app_tuple[1],
160161
"Version": app_version_data["Version"],
161-
"VersionKey": status_in_env["BaseApplicationVersionKey"]
162+
"VersionKey": status_in_env["BaseApplicationVersionKey"],
163+
"IsModified": status_in_env["IsModified"]
162164
}
163165
# Since these 2 fields were only introduced in a minor of OS11, we check here if they exist
164166
# We can't just use the version
@@ -212,7 +214,7 @@ def export_app_oap(file_path: str, endpoint: str, auth_token: str, env_key: str,
212214
# Stores the result
213215
url_string = response["response"]
214216
url_string = url_string["url"]
215-
download_oap(file_path, auth_token, url_string)
217+
download_package(file_path, auth_token, url_string)
216218
return
217219
elif status_code == APPLICATION_VERSION_NO_PERMISSION_CODE:
218220
raise NotEnoughPermissionsError(

outsystems/lifetime/lifetime_base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from outsystems.vars.lifetime_vars import LIFETIME_SSL_CERT_VERIFY
1010
# Functions
1111
from outsystems.vars.vars_base import get_configuration_value
12+
from outsystems.file_helpers.file import check_file
1213

1314

1415
# Method that builds the LifeTime endpoint based on the LT host
@@ -55,6 +56,29 @@ def send_post_request(lt_api: str, token: str, api_endpoint: str, payload: str):
5556
return response_obj
5657

5758

59+
# Sends a POST request to LT, with binary content.
60+
def send_binary_post_request(lt_api: str, token: str, api_endpoint: str, dest_env: str, lt_endpont: str, binary_file_path: str):
61+
# Auth token + content type octet-stream
62+
headers = {'content-type': 'application/octet-stream',
63+
'authorization': 'Bearer ' + token}
64+
# Format the request URL to include the api endpoint
65+
request_string = "{}/{}/{}/{}".format(lt_api, api_endpoint, dest_env, lt_endpont)
66+
67+
if check_file("", binary_file_path):
68+
with open(binary_file_path, 'rb') as f:
69+
data = f.read()
70+
response = requests.post(request_string, data=data, headers=headers, verify=get_configuration_value("LIFETIME_SSL_CERT_VERIFY", LIFETIME_SSL_CERT_VERIFY))
71+
response_obj = {"http_status": response.status_code, "response": {}}
72+
# Since LT API POST requests do not reply with native JSON, we have to make it ourselves
73+
if len(response.text) > 0:
74+
try:
75+
response_obj["response"] = response.json()
76+
except:
77+
# Workaround for POST /deployments/ since the response is not JSON, just text
78+
response_obj["response"] = json.loads('"{}"'.format(response.text))
79+
return response_obj
80+
81+
5882
# Sends a DELETE request to LT
5983
def send_delete_request(lt_api: str, token: str, api_endpoint: str):
6084
# Auth token + content type json
@@ -71,3 +95,14 @@ def send_delete_request(lt_api: str, token: str, api_endpoint: str):
7195
raise InvalidJsonResponseError(
7296
"DELETE {}: The JSON response could not be parsed. Response: {}".format(request_string, response.text))
7397
return response_obj
98+
99+
100+
# Sends a GET request to LT, with url_params
101+
def send_download_request(pkg_url: str, token: str):
102+
# Auth token + content type json
103+
headers = {'content-type': 'application/json',
104+
'authorization': token}
105+
# Format the request URL to include the api endpoint
106+
response = requests.get(pkg_url, headers=headers, verify=get_configuration_value("LIFETIME_SSL_CERT_VERIFY", LIFETIME_SSL_CERT_VERIFY))
107+
response_obj = {"http_status": response.status_code, "response": response.content}
108+
return response_obj

outsystems/lifetime/lifetime_deployments.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from outsystems.exceptions.environment_not_found import EnvironmentNotFoundError
1313
from outsystems.exceptions.impossible_action_deployment import ImpossibleApplyActionDeploymentError
1414
# Functions
15-
from outsystems.lifetime.lifetime_base import send_get_request, send_post_request, send_delete_request
15+
from outsystems.lifetime.lifetime_base import send_get_request, send_post_request, send_delete_request, send_binary_post_request
1616
from outsystems.lifetime.lifetime_environments import get_environment_key
1717
from outsystems.file_helpers.file import store_data
1818
# Variables
@@ -24,7 +24,8 @@
2424
DEPLOYMENT_SUCCESS_CODE, DEPLOYMENT_INVALID_CODE, DEPLOYMENT_NO_PERMISSION_CODE, DEPLOYMENT_NO_ENVIRONMENT_CODE, DEPLOYMENT_FAILED_CODE, \
2525
DEPLOYMENT_DELETE_SUCCESS_CODE, DEPLOYMENT_DELETE_IMPOSSIBLE_CODE, DEPLOYMENT_DELETE_NO_PERMISSION_CODE, DEPLOYMENT_DELETE_NO_DEPLOYMENT_CODE, \
2626
DEPLOYMENT_DELETE_FAILED_CODE, DEPLOYMENT_ACTION_SUCCESS_CODE, DEPLOYMENT_ACTION_IMPOSSIBLE_CODE, DEPLOYMENT_ACTION_NO_PERMISSION_CODE, \
27-
DEPLOYMENT_ACTION_NO_DEPLOYMENT_CODE, DEPLOYMENT_ACTION_FAILED_CODE, DEPLOYMENT_PLAN_V1_API_OPS, DEPLOYMENT_PLAN_V2_API_OPS
27+
DEPLOYMENT_ACTION_NO_DEPLOYMENT_CODE, DEPLOYMENT_ACTION_FAILED_CODE, DEPLOYMENT_PLAN_V1_API_OPS, DEPLOYMENT_PLAN_V2_API_OPS, \
28+
ENVIRONMENTS_ENDPOINT, DEPLOYMENT_ENDPOINT
2829
from outsystems.vars.file_vars import DEPLOYMENTS_FILE, DEPLOYMENT_FILE, DEPLOYMENT_FOLDER, DEPLOYMENT_STATUS_FILE
2930
from outsystems.vars.pipeline_vars import DEPLOYMENT_STATUS_LIST, DEPLOYMENT_SAVED_STATUS
3031

@@ -193,6 +194,32 @@ def send_deployment(artifact_dir: str, endpoint: str, auth_token: str, lt_api_ve
193194
"There was an error. Response from server: {}".format(response))
194195

195196

197+
# Creates a deployment to a target environment.
198+
# The input is a binary file.
199+
def send_binary_deployment(artifact_dir: str, endpoint: str, auth_token: str, lt_api_version: int, dest_env: str, binary_path: str):
200+
# Sends the request
201+
response = send_binary_post_request(
202+
endpoint, auth_token, ENVIRONMENTS_ENDPOINT, dest_env, DEPLOYMENT_ENDPOINT, binary_path)
203+
status_code = int(response["http_status"])
204+
if status_code == DEPLOYMENT_SUCCESS_CODE:
205+
return response["response"]
206+
elif status_code == DEPLOYMENT_INVALID_CODE:
207+
raise InvalidParametersError("The request is invalid. Check the body of the request for errors. Details: {}.".format(
208+
response["response"]))
209+
elif status_code == DEPLOYMENT_NO_PERMISSION_CODE:
210+
raise NotEnoughPermissionsError(
211+
"You don't have enough permissions to create the deployment. Details: {}".format(response["response"]))
212+
elif status_code == DEPLOYMENT_NO_ENVIRONMENT_CODE:
213+
raise EnvironmentNotFoundError(
214+
"Can't find the source or target environment. Details: {}.".format(response["response"]))
215+
elif status_code == DEPLOYMENT_FAILED_CODE:
216+
raise ServerError(
217+
"Failed to create the deployment. Details: {}".format(response["response"]))
218+
else:
219+
raise NotImplementedError(
220+
"There was an error. Response from server: {}".format(response))
221+
222+
196223
# Discards a deployment, if possible. Only deployments whose state is “saved” can be deleted.
197224
def delete_deployment(endpoint: str, auth_token: str, deployment_key: str):
198225
# Builds the API call
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Python Modules
2+
import os
3+
4+
# Custom Modules
5+
# Exceptions
6+
from outsystems.exceptions.invalid_parameters import InvalidParametersError
7+
from outsystems.exceptions.environment_not_found import EnvironmentNotFoundError
8+
from outsystems.exceptions.not_enough_permissions import NotEnoughPermissionsError
9+
from outsystems.exceptions.server_error import ServerError
10+
# Functions
11+
from outsystems.lifetime.lifetime_base import send_download_request
12+
13+
# Variables
14+
from outsystems.vars.lifetime_vars import DOWNLOAD_SUCCESS_CODE, DOWNLOAD_INVALID_KEY_CODE, \
15+
DOWNLOAD_NO_PERMISSION_CODE, DOWNLOAD_NOT_FOUND, DOWNLOAD_FAILED_CODE
16+
17+
18+
# Downloads a binary file from a LifeTime download link
19+
def download_package(file_path: str, auth_token: str, pkg_url: str):
20+
# Sends the request
21+
response = send_download_request(pkg_url, auth_token)
22+
status_code = int(response["http_status"])
23+
24+
if status_code == DOWNLOAD_SUCCESS_CODE:
25+
# Remove the spaces in the filename
26+
file_path = file_path.replace(" ", "_")
27+
# Makes sure that, if a directory is in the filename, that directory exists
28+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
29+
with open(file_path, "wb") as f:
30+
f.write(response["response"])
31+
elif status_code == DOWNLOAD_INVALID_KEY_CODE:
32+
raise InvalidParametersError("The required type <Type> is invalid for given keys (EnvironmentKey; ApplicationKey). Details: {}".format(
33+
response["response"]))
34+
elif status_code == DOWNLOAD_NO_PERMISSION_CODE:
35+
raise NotEnoughPermissionsError("User doesn't have permissions for the given keys (EnvironmentKey; ApplicationKey). Details: {}".format(
36+
response["response"]))
37+
elif status_code == DOWNLOAD_NOT_FOUND:
38+
raise EnvironmentNotFoundError("No environment or application found. Please check that the EnvironmentKey and ApplicationKey exist. Details: {}".format(
39+
response["response"]))
40+
elif status_code == DOWNLOAD_FAILED_CODE:
41+
raise ServerError("Failed to start the operation to package. Details: {}".format(
42+
response["response"]))
43+
else:
44+
raise NotImplementedError(
45+
"There was an error. Response from server: {}".format(response))

outsystems/lifetime/lifetime_environments.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@
99
from outsystems.exceptions.app_does_not_exist import AppDoesNotExistError
1010
from outsystems.exceptions.server_error import ServerError
1111
# Functions
12-
from outsystems.lifetime.lifetime_base import send_get_request
12+
from outsystems.lifetime.lifetime_base import send_get_request, send_post_request
1313
from outsystems.lifetime.lifetime_applications import _get_application_info
1414
from outsystems.file_helpers.file import load_data, store_data, clear_cache
1515
# Variables
1616
from outsystems.vars.lifetime_vars import ENVIRONMENTS_ENDPOINT, ENVIRONMENT_APPLICATIONS_ENDPOINT, ENVIRONMENTS_SUCCESS_CODE, \
1717
ENVIRONMENTS_NOT_FOUND_CODE, ENVIRONMENTS_FAILED_CODE, ENVIRONMENT_APP_SUCCESS_CODE, ENVIRONMENT_APP_NOT_STATUS_CODE, \
1818
ENVIRONMENT_APP_NO_PERMISSION_CODE, ENVIRONMENT_APP_NOT_FOUND, ENVIRONMENT_APP_FAILED_CODE, ENVIRONMENT_DEPLOYMENT_ZONES_ENDPOINT, \
1919
ENVIRONMENT_ZONES_SUCCESS_CODE, ENVIRONMENT_ZONES_NOT_STATUS_CODE, ENVIRONMENT_ZONES_NO_PERMISSION_CODE, ENVIRONMENT_ZONES_NOT_FOUND, \
20-
ENVIRONMENT_ZONES_FAILED_CODE
21-
from outsystems.vars.file_vars import ENVIRONMENTS_FILE, ENVIRONMENT_FOLDER, ENVIRONMENT_APPLICATION_FILE, ENVIRONMENT_DEPLOYMENT_ZONES_FILE
20+
ENVIRONMENT_ZONES_FAILED_CODE, ENVIRONMENT_APPLICATIONS_SOURCECODE_ENDPOINT, ENVIRONMENT_SOURCECODE_PACKAGE_SUCCESS_CODE, \
21+
ENVIRONMENT_SOURCECODE_LINK_SUCCESS_CODE, ENVIRONMENT_SOURCECODE_FAILED_CODE
22+
from outsystems.vars.file_vars import ENVIRONMENTS_FILE, ENVIRONMENT_FOLDER, ENVIRONMENT_APPLICATION_FILE, \
23+
ENVIRONMENT_DEPLOYMENT_ZONES_FILE, ENVIRONMENT_SOURCECODE_FOLDER, ENVIRONMENT_SOURCECODE_STATUS_FILE, \
24+
ENVIRONMENT_SOURCECODE_LINK_FILE
2225

2326

2427
# Lists all the environments in the infrastructure.
@@ -120,6 +123,91 @@ def get_environment_deployment_zones(artifact_dir: str, endpoint: str, auth_toke
120123
"There was an error. Response from server: {}".format(response))
121124

122125

126+
# Returns the package key to download the source code of the specified application in a given environment.
127+
def get_environment_app_source_code(artifact_dir: str, endpoint: str, auth_token: str, **kwargs):
128+
# Tuple with (AppName, AppKey): app_tuple[0] = AppName; app_tuple[1] = AppKey
129+
app_tuple = _get_application_info(
130+
artifact_dir, endpoint, auth_token, **kwargs)
131+
# Tuple with (EnvName, EnvKey): env_tuple[0] = EnvName; env_tuple[1] = EnvKey
132+
env_tuple = _get_environment_info(
133+
artifact_dir, endpoint, auth_token, **kwargs)
134+
# Builds the query and arguments for the call to the API
135+
query = "{}/{}/{}/{}/{}".format(ENVIRONMENTS_ENDPOINT, env_tuple[1],
136+
ENVIRONMENT_APPLICATIONS_ENDPOINT, app_tuple[1],
137+
ENVIRONMENT_APPLICATIONS_SOURCECODE_ENDPOINT)
138+
# Sends the request
139+
response = send_post_request(endpoint, auth_token, query, None)
140+
status_code = int(response["http_status"])
141+
if status_code == ENVIRONMENT_SOURCECODE_PACKAGE_SUCCESS_CODE:
142+
return response["response"]
143+
elif status_code == ENVIRONMENT_SOURCECODE_FAILED_CODE:
144+
raise ServerError("Failed to access the source code of an application. Details: {}".format(
145+
response["response"]))
146+
else:
147+
raise NotImplementedError(
148+
"There was an error. Response from server: {}".format(response))
149+
150+
151+
# Returns current status of source code package of the specified application in a given environment.
152+
def get_environment_app_source_code_status(artifact_dir: str, endpoint: str, auth_token: str, **kwargs):
153+
# Tuple with (AppName, AppKey): app_tuple[0] = AppName; app_tuple[1] = AppKey
154+
app_tuple = _get_application_info(
155+
artifact_dir, endpoint, auth_token, **kwargs)
156+
# Tuple with (EnvName, EnvKey): env_tuple[0] = EnvName; env_tuple[1] = EnvKey
157+
env_tuple = _get_environment_info(
158+
artifact_dir, endpoint, auth_token, **kwargs)
159+
# Builds the query and arguments for the call to the API
160+
query = "{}/{}/{}/{}/{}/{}/status".format(ENVIRONMENTS_ENDPOINT, env_tuple[1],
161+
ENVIRONMENT_APPLICATIONS_ENDPOINT, app_tuple[1],
162+
ENVIRONMENT_APPLICATIONS_SOURCECODE_ENDPOINT, kwargs["pkg_key"])
163+
# Sends the request
164+
response = send_get_request(endpoint, auth_token, query, None)
165+
status_code = int(response["http_status"])
166+
if status_code == ENVIRONMENT_SOURCECODE_PACKAGE_SUCCESS_CODE:
167+
# Stores the result
168+
filename = "{}{}".format(
169+
kwargs["pkg_key"], ENVIRONMENT_SOURCECODE_STATUS_FILE)
170+
filename = os.path.join(ENVIRONMENT_SOURCECODE_FOLDER, filename)
171+
store_data(artifact_dir, filename, response["response"])
172+
return response["response"]
173+
elif status_code == ENVIRONMENT_SOURCECODE_FAILED_CODE:
174+
raise ServerError("Failed to access the source code package status of an application. Details: {}".format(
175+
response["response"]))
176+
else:
177+
raise NotImplementedError(
178+
"There was an error. Response from server: {}".format(response))
179+
180+
181+
# Returns download link of source code package of the specified application in a given environment.
182+
def get_environment_app_source_code_link(artifact_dir: str, endpoint: str, auth_token: str, **kwargs):
183+
# Tuple with (AppName, AppKey): app_tuple[0] = AppName; app_tuple[1] = AppKey
184+
app_tuple = _get_application_info(
185+
artifact_dir, endpoint, auth_token, **kwargs)
186+
# Tuple with (EnvName, EnvKey): env_tuple[0] = EnvName; env_tuple[1] = EnvKey
187+
env_tuple = _get_environment_info(
188+
artifact_dir, endpoint, auth_token, **kwargs)
189+
# Builds the query and arguments for the call to the API
190+
query = "{}/{}/{}/{}/{}/{}/download".format(ENVIRONMENTS_ENDPOINT, env_tuple[1],
191+
ENVIRONMENT_APPLICATIONS_ENDPOINT, app_tuple[1],
192+
ENVIRONMENT_APPLICATIONS_SOURCECODE_ENDPOINT, kwargs["pkg_key"])
193+
# Sends the request
194+
response = send_get_request(endpoint, auth_token, query, None)
195+
status_code = int(response["http_status"])
196+
if status_code == ENVIRONMENT_SOURCECODE_LINK_SUCCESS_CODE:
197+
# Stores the result
198+
filename = "{}{}".format(
199+
kwargs["pkg_key"], ENVIRONMENT_SOURCECODE_LINK_FILE)
200+
filename = os.path.join(ENVIRONMENT_SOURCECODE_FOLDER, filename)
201+
store_data(artifact_dir, filename, response["response"])
202+
return response["response"]
203+
elif status_code == ENVIRONMENT_SOURCECODE_FAILED_CODE:
204+
raise ServerError("Failed to access the source code package link of an application. Details: {}".format(
205+
response["response"]))
206+
else:
207+
raise NotImplementedError(
208+
"There was an error. Response from server: {}".format(response))
209+
210+
123211
# ---------------------- PRIVATE METHODS ----------------------
124212
# Private method to get the App name or key into a tuple (name,key).
125213
def _get_environment_info(artifact_dir: str, api_url: str, auth_token: str, **kwargs):

0 commit comments

Comments
 (0)