trainml 0.5.13__tar.gz → 0.5.14__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.
- {trainml-0.5.13 → trainml-0.5.14}/PKG-INFO +1 -1
- trainml-0.5.14/tests/integration/projects/test_projects_members_integration.py +49 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_jobs_integration.py +8 -8
- trainml-0.5.14/tests/unit/projects/test_project_members_unit.py +107 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/__init__.py +1 -1
- {trainml-0.5.13 → trainml-0.5.14}/trainml/auth.py +1 -1
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/job/create.py +32 -2
- {trainml-0.5.13 → trainml-0.5.14}/trainml/jobs.py +9 -0
- trainml-0.5.14/trainml/projects/members.py +98 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/projects.py +2 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/trainml.py +37 -20
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/PKG-INFO +1 -1
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/SOURCES.txt +3 -0
- {trainml-0.5.13 → trainml-0.5.14}/LICENSE +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/README.md +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/examples/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/examples/create_dataset_and_training_job.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/examples/local_storage.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/examples/training_inference_pipeline.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/pyproject.toml +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/setup.cfg +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/setup.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/cloudbender/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/cloudbender/test_providers_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/conftest.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/conftest.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_credentials_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_data_connectors_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_datastores_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_secrets_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_services_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_checkpoints_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_datasets_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_environments_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_gpu_types_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_models_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/integration/test_volumes_integration.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_datastore_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_device_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_node_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_provider_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_region_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/cloudbender/test_cli_service_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/conftest.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_credential_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_data_connector_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_datastore_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_secret_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_service_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_checkpoint_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_datasets_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_environment_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_gpu_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_job_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_model_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/test_cli_volume_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_data_connectors_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_datastores_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_device_configs_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_devices_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_nodes_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_providers_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_regions_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/cloudbender/test_services_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/conftest.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_project_credentials_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_project_data_connectors_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_project_datastores_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_project_secrets_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_project_services_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/projects/test_projects_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_auth_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_checkpoints_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_connections_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_datasets_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_environments_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_exceptions.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_gpu_types_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_jobs_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_models_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_trainml_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/tests/unit/test_volumes_unit.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/__main__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/checkpoints.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/checkpoint.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/data_connector.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/datastore.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/device.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/node.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/provider.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/region.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/cloudbender/service.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/connection.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/dataset.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/environment.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/gpu.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/job/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/model.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/credential.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/data_connector.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/datastore.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/secret.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/project/service.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cli/volume.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/cloudbender.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/data_connectors.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/datastores.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/device_configs.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/devices.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/nodes.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/providers.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/regions.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/cloudbender/services.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/connections.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/datasets.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/environments.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/exceptions.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/gpu_types.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/models.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/__init__.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/credentials.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/data_connectors.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/datastores.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/secrets.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/projects/services.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml/volumes.py +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/dependency_links.txt +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/entry_points.txt +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/requires.txt +0 -0
- {trainml-0.5.13 → trainml-0.5.14}/trainml.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
from pytest import mark, fixture
|
|
5
|
+
|
|
6
|
+
pytestmark = [mark.sdk, mark.integration, mark.projects]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@mark.create
|
|
10
|
+
@mark.asyncio
|
|
11
|
+
class ProjectMembersTests:
|
|
12
|
+
@fixture(scope="class")
|
|
13
|
+
async def project_member(self, project):
|
|
14
|
+
member = await project.members.add("test.account@proximl.ai","read","read","read","read","read")
|
|
15
|
+
yield member
|
|
16
|
+
await project.members.remove("test.account@proximl.ai")
|
|
17
|
+
|
|
18
|
+
async def test_list_project_members(self, project):
|
|
19
|
+
members = await project.members.list()
|
|
20
|
+
assert len(members) > 0
|
|
21
|
+
|
|
22
|
+
async def test_project_member_properties(self, project, project_member):
|
|
23
|
+
assert isinstance(project_member.id, str)
|
|
24
|
+
assert isinstance(project_member.email, str)
|
|
25
|
+
assert isinstance(project_member.project_uuid, str)
|
|
26
|
+
assert isinstance(project_member.owner, bool)
|
|
27
|
+
assert isinstance(project_member.job, str)
|
|
28
|
+
assert isinstance(project_member.dataset, str)
|
|
29
|
+
assert isinstance(project_member.model, str)
|
|
30
|
+
assert isinstance(project_member.checkpoint, str)
|
|
31
|
+
assert isinstance(project_member.volume, str)
|
|
32
|
+
assert project.id == project_member.project_uuid
|
|
33
|
+
assert project_member.id == "test.account@proximl.ai"
|
|
34
|
+
|
|
35
|
+
async def test_project_member_str(self, project_member):
|
|
36
|
+
string = str(project_member)
|
|
37
|
+
regex = r"^{.*\"email\": \"" + project_member.email + r"\".*}$"
|
|
38
|
+
assert isinstance(string, str)
|
|
39
|
+
assert re.match(regex, string)
|
|
40
|
+
|
|
41
|
+
async def test_project_member_repr(self, project_member):
|
|
42
|
+
string = repr(project_member)
|
|
43
|
+
regex = (
|
|
44
|
+
r"^ProjectMember\( trainml , \*\*{.*'email': '"
|
|
45
|
+
+ project_member.email
|
|
46
|
+
+ r"'.*}\)$"
|
|
47
|
+
)
|
|
48
|
+
assert isinstance(string, str)
|
|
49
|
+
assert re.match(regex, string)
|
|
@@ -423,11 +423,11 @@ class JobIOTests:
|
|
|
423
423
|
disk_size=10,
|
|
424
424
|
workers=["python $ML_MODEL_PATH/tensorflow/main.py"],
|
|
425
425
|
environment=dict(
|
|
426
|
-
type="
|
|
426
|
+
type="DEEPLEARNING_PY312",
|
|
427
427
|
env=[
|
|
428
428
|
dict(
|
|
429
429
|
key="CHECKPOINT_FILE",
|
|
430
|
-
value="model.ckpt-0050",
|
|
430
|
+
value="model.ckpt-0050.weights.h5",
|
|
431
431
|
)
|
|
432
432
|
],
|
|
433
433
|
),
|
|
@@ -469,7 +469,7 @@ class JobIOTests:
|
|
|
469
469
|
sys.stderr.write(captured.err)
|
|
470
470
|
assert "Epoch 1/2" in captured.out
|
|
471
471
|
assert "Epoch 2/2" in captured.out
|
|
472
|
-
assert "adding: model.ckpt-0001
|
|
472
|
+
assert "adding: model.ckpt-0001" in captured.out
|
|
473
473
|
assert "Send complete" in captured.out
|
|
474
474
|
|
|
475
475
|
async def test_job_model_input_and_output(self, trainml, capsys):
|
|
@@ -558,7 +558,7 @@ class JobTypeTests:
|
|
|
558
558
|
assert job.url
|
|
559
559
|
assert extract_domain_suffix(urlparse(job.url).hostname) == "proximl.cloud"
|
|
560
560
|
tries = 0
|
|
561
|
-
await asyncio.sleep(
|
|
561
|
+
await asyncio.sleep(180) ## downloading weights can be slow
|
|
562
562
|
async with aiohttp.ClientSession() as session:
|
|
563
563
|
retry = True
|
|
564
564
|
while retry:
|
|
@@ -640,7 +640,7 @@ class JobTypeTests:
|
|
|
640
640
|
assert "Epoch 2/2" in captured.out
|
|
641
641
|
assert "Uploading s3://trainml-example/output/resnet_cifar10" in captured.out
|
|
642
642
|
assert (
|
|
643
|
-
"upload: ./model.ckpt-0002.
|
|
643
|
+
"upload: ./model.ckpt-0002.weights.h5 to s3://trainml-example/output/resnet_cifar10/model.ckpt-0002.weights.h5"
|
|
644
644
|
in captured.out
|
|
645
645
|
)
|
|
646
646
|
assert "Upload complete" in captured.out
|
|
@@ -713,9 +713,9 @@ class JobFeatureTests:
|
|
|
713
713
|
sys.stderr.write(captured.err)
|
|
714
714
|
upload_contents = os.listdir(temp_dir.name)
|
|
715
715
|
temp_dir.cleanup()
|
|
716
|
-
assert len(upload_contents)
|
|
716
|
+
assert len(upload_contents) >= 3
|
|
717
717
|
assert any(
|
|
718
|
-
"model.ckpt-0002
|
|
718
|
+
"model.ckpt-0002" in content
|
|
719
719
|
for content in upload_contents
|
|
720
720
|
)
|
|
721
721
|
|
|
@@ -724,5 +724,5 @@ class JobFeatureTests:
|
|
|
724
724
|
sys.stderr.write(captured.err)
|
|
725
725
|
assert "Epoch 1/2" in captured.out
|
|
726
726
|
assert "Epoch 2/2" in captured.out
|
|
727
|
-
assert "Number of regular files transferred:
|
|
727
|
+
assert "Number of regular files transferred: 4" in captured.out
|
|
728
728
|
assert "Send complete" in captured.out
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
from pytest import mark, fixture, raises
|
|
6
|
+
from aiohttp import WSMessage, WSMsgType
|
|
7
|
+
|
|
8
|
+
import trainml.projects.members as specimen
|
|
9
|
+
from trainml.exceptions import (
|
|
10
|
+
ApiError,
|
|
11
|
+
SpecificationError,
|
|
12
|
+
TrainMLException,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
pytestmark = [mark.sdk, mark.unit, mark.projects]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@fixture
|
|
19
|
+
def project_members(mock_trainml):
|
|
20
|
+
yield specimen.ProjectMembers(mock_trainml, project_id="1")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@fixture
|
|
24
|
+
def project_member(mock_trainml):
|
|
25
|
+
yield specimen.ProjectMember(
|
|
26
|
+
mock_trainml,
|
|
27
|
+
id="owner@gmail.com",
|
|
28
|
+
email="owner@gmail.com",
|
|
29
|
+
project_uuid="proj-id-1",
|
|
30
|
+
owner= True,
|
|
31
|
+
job= "all",
|
|
32
|
+
dataset= "all",
|
|
33
|
+
model= "all",
|
|
34
|
+
checkpoint="all",
|
|
35
|
+
volume= "all"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProjectMembersTests:
|
|
40
|
+
@mark.asyncio
|
|
41
|
+
async def test_project_members_list(self, project_members, mock_trainml):
|
|
42
|
+
api_response = [
|
|
43
|
+
{
|
|
44
|
+
"project_uuid": "proj-id-1",
|
|
45
|
+
"email": "owner@gmail.com",
|
|
46
|
+
"createdAt": "2024-09-04T00:42:39.529Z",
|
|
47
|
+
"updatedAt": "2024-09-04T00:42:39.529Z",
|
|
48
|
+
"owner": True,
|
|
49
|
+
"job": "all",
|
|
50
|
+
"dataset": "all",
|
|
51
|
+
"model": "all",
|
|
52
|
+
"checkpoint": "all",
|
|
53
|
+
"volume": "all"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"project_uuid": "proj-id-1",
|
|
57
|
+
"email": "non-owner@gmail.com",
|
|
58
|
+
"createdAt": "2024-09-04T00:42:39.529Z",
|
|
59
|
+
"updatedAt": "2024-09-04T00:42:39.529Z",
|
|
60
|
+
"owner": False,
|
|
61
|
+
"job": "all",
|
|
62
|
+
"dataset": "all",
|
|
63
|
+
"model": "all",
|
|
64
|
+
"checkpoint": "read",
|
|
65
|
+
"volume": "read"
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
mock_trainml._query = AsyncMock(return_value=api_response)
|
|
69
|
+
resp = await project_members.list()
|
|
70
|
+
mock_trainml._query.assert_called_once_with(
|
|
71
|
+
"/project/1/access", "GET", dict()
|
|
72
|
+
)
|
|
73
|
+
assert len(resp) == 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ProjectMemberTests:
|
|
77
|
+
def test_project_member_properties(self, project_member):
|
|
78
|
+
assert isinstance(project_member.id, str)
|
|
79
|
+
assert isinstance(project_member.email, str)
|
|
80
|
+
assert isinstance(project_member.project_uuid, str)
|
|
81
|
+
assert isinstance(project_member.owner, bool)
|
|
82
|
+
assert isinstance(project_member.job, str)
|
|
83
|
+
assert isinstance(project_member.dataset, str)
|
|
84
|
+
assert isinstance(project_member.model, str)
|
|
85
|
+
assert isinstance(project_member.checkpoint, str)
|
|
86
|
+
assert isinstance(project_member.volume, str)
|
|
87
|
+
|
|
88
|
+
def test_project_member_str(self, project_member):
|
|
89
|
+
string = str(project_member)
|
|
90
|
+
regex = r"^{.*\"id\": \"" + project_member.id + r"\".*}$"
|
|
91
|
+
assert isinstance(string, str)
|
|
92
|
+
assert re.match(regex, string)
|
|
93
|
+
|
|
94
|
+
def test_project_member_repr(self, project_member):
|
|
95
|
+
string = repr(project_member)
|
|
96
|
+
regex = (
|
|
97
|
+
r"^ProjectMember\( trainml , \*\*{.*'id': '"
|
|
98
|
+
+ project_member.id
|
|
99
|
+
+ r"'.*}\)$"
|
|
100
|
+
)
|
|
101
|
+
assert isinstance(string, str)
|
|
102
|
+
assert re.match(regex, string)
|
|
103
|
+
|
|
104
|
+
def test_project_member_bool(self, project_member, mock_trainml):
|
|
105
|
+
empty_project_member = specimen.ProjectMember(mock_trainml)
|
|
106
|
+
assert bool(project_member)
|
|
107
|
+
assert not bool(empty_project_member)
|
|
@@ -508,7 +508,7 @@ class AWSSRP(object):
|
|
|
508
508
|
|
|
509
509
|
|
|
510
510
|
class Auth(object):
|
|
511
|
-
def __init__(self, config_dir, domain_suffix="
|
|
511
|
+
def __init__(self, config_dir, domain_suffix="proximl.ai", **kwargs):
|
|
512
512
|
try:
|
|
513
513
|
with open(f"{config_dir}/environment.json", "r") as file:
|
|
514
514
|
env_str = file.read().replace("\n", "")
|
|
@@ -180,6 +180,12 @@ def create(config):
|
|
|
180
180
|
help="Third Party Credentials to add to the job environment",
|
|
181
181
|
multiple=True,
|
|
182
182
|
)
|
|
183
|
+
@click.option(
|
|
184
|
+
"--secret",
|
|
185
|
+
type=click.STRING,
|
|
186
|
+
help="Project secrets to add to the job environment",
|
|
187
|
+
multiple=True,
|
|
188
|
+
)
|
|
183
189
|
@click.option(
|
|
184
190
|
"--git-uri",
|
|
185
191
|
type=click.STRING,
|
|
@@ -222,6 +228,7 @@ def notebook(
|
|
|
222
228
|
custom_image,
|
|
223
229
|
env,
|
|
224
230
|
credential,
|
|
231
|
+
secret,
|
|
225
232
|
apt_packages,
|
|
226
233
|
pip_packages,
|
|
227
234
|
conda_packages,
|
|
@@ -254,7 +261,7 @@ def notebook(
|
|
|
254
261
|
data=dict(datasets=datasets),
|
|
255
262
|
model=dict(checkpoints=checkpoints),
|
|
256
263
|
environment=dict(
|
|
257
|
-
credentials=[k for k in credential],
|
|
264
|
+
credentials=[k for k in credential], secrets=[s for s in secret]
|
|
258
265
|
),
|
|
259
266
|
)
|
|
260
267
|
|
|
@@ -508,6 +515,12 @@ def notebook(
|
|
|
508
515
|
help="Third Party Credentials to add to the job environment",
|
|
509
516
|
multiple=True,
|
|
510
517
|
)
|
|
518
|
+
@click.option(
|
|
519
|
+
"--secret",
|
|
520
|
+
type=click.STRING,
|
|
521
|
+
help="Project secrets to add to the job environment",
|
|
522
|
+
multiple=True,
|
|
523
|
+
)
|
|
511
524
|
@click.option(
|
|
512
525
|
"--apt-packages",
|
|
513
526
|
type=click.STRING,
|
|
@@ -564,6 +577,7 @@ def training(
|
|
|
564
577
|
custom_image,
|
|
565
578
|
env,
|
|
566
579
|
credential,
|
|
580
|
+
secret,
|
|
567
581
|
apt_packages,
|
|
568
582
|
pip_packages,
|
|
569
583
|
conda_packages,
|
|
@@ -596,7 +610,7 @@ def training(
|
|
|
596
610
|
data=dict(datasets=datasets),
|
|
597
611
|
model=dict(checkpoints=checkpoints),
|
|
598
612
|
environment=dict(
|
|
599
|
-
credentials=[k for k in credential],
|
|
613
|
+
credentials=[k for k in credential], secrets=[s for s in secret]
|
|
600
614
|
),
|
|
601
615
|
)
|
|
602
616
|
|
|
@@ -852,6 +866,12 @@ def training(
|
|
|
852
866
|
help="Third Party Credentials to add to the job environment",
|
|
853
867
|
multiple=True,
|
|
854
868
|
)
|
|
869
|
+
@click.option(
|
|
870
|
+
"--secret",
|
|
871
|
+
type=click.STRING,
|
|
872
|
+
help="Project secrets to add to the job environment",
|
|
873
|
+
multiple=True,
|
|
874
|
+
)
|
|
855
875
|
@click.option(
|
|
856
876
|
"--apt-packages",
|
|
857
877
|
type=click.STRING,
|
|
@@ -908,6 +928,7 @@ def inference(
|
|
|
908
928
|
custom_image,
|
|
909
929
|
env,
|
|
910
930
|
credential,
|
|
931
|
+
secret,
|
|
911
932
|
apt_packages,
|
|
912
933
|
pip_packages,
|
|
913
934
|
conda_packages,
|
|
@@ -934,6 +955,7 @@ def inference(
|
|
|
934
955
|
model=dict(checkpoints=checkpoints),
|
|
935
956
|
environment=dict(
|
|
936
957
|
credentials=[k for k in credential],
|
|
958
|
+
secrets=[s for s in secret],
|
|
937
959
|
),
|
|
938
960
|
)
|
|
939
961
|
|
|
@@ -1171,6 +1193,12 @@ def from_json(config, attach, connect, file):
|
|
|
1171
1193
|
help="Third Party Credentials to add to the job environment",
|
|
1172
1194
|
multiple=True,
|
|
1173
1195
|
)
|
|
1196
|
+
@click.option(
|
|
1197
|
+
"--secret",
|
|
1198
|
+
type=click.STRING,
|
|
1199
|
+
help="Project secrets to add to the job environment",
|
|
1200
|
+
multiple=True,
|
|
1201
|
+
)
|
|
1174
1202
|
@click.option(
|
|
1175
1203
|
"--apt-packages",
|
|
1176
1204
|
type=click.STRING,
|
|
@@ -1231,6 +1259,7 @@ def endpoint(
|
|
|
1231
1259
|
custom_image,
|
|
1232
1260
|
env,
|
|
1233
1261
|
credential,
|
|
1262
|
+
secret,
|
|
1234
1263
|
apt_packages,
|
|
1235
1264
|
pip_packages,
|
|
1236
1265
|
conda_packages,
|
|
@@ -1258,6 +1287,7 @@ def endpoint(
|
|
|
1258
1287
|
model=dict(checkpoints=checkpoints),
|
|
1259
1288
|
environment=dict(
|
|
1260
1289
|
credentials=[k for k in credential],
|
|
1290
|
+
secrets=[s for s in secret],
|
|
1261
1291
|
),
|
|
1262
1292
|
)
|
|
1263
1293
|
|
|
@@ -530,6 +530,15 @@ class Job:
|
|
|
530
530
|
"waiting for data/model download",
|
|
531
531
|
]
|
|
532
532
|
)
|
|
533
|
+
or (
|
|
534
|
+
status
|
|
535
|
+
== "running" ## this status could be too short for polling could miss it
|
|
536
|
+
and self.status
|
|
537
|
+
in [
|
|
538
|
+
"uploading",
|
|
539
|
+
"finished"
|
|
540
|
+
]
|
|
541
|
+
)
|
|
533
542
|
):
|
|
534
543
|
return self
|
|
535
544
|
elif self.status == "failed":
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
class ProjectMembers(object):
|
|
6
|
+
def __init__(self, trainml, project_id):
|
|
7
|
+
self.trainml = trainml
|
|
8
|
+
self.project_id = project_id
|
|
9
|
+
|
|
10
|
+
async def list(self, **kwargs):
|
|
11
|
+
resp = await self.trainml._query(
|
|
12
|
+
f"/project/{self.project_id}/access", "GET", kwargs
|
|
13
|
+
)
|
|
14
|
+
members = [ProjectMember(self.trainml, **member) for member in resp]
|
|
15
|
+
return members
|
|
16
|
+
|
|
17
|
+
async def add(self, email: str, job: Literal["all", "read"], dataset: Literal["all", "read"], model: Literal["all", "read"], checkpoint: Literal["all", "read"], volume: Literal["all", "read"], **kwargs):
|
|
18
|
+
data = dict(
|
|
19
|
+
email=email,
|
|
20
|
+
job=job,
|
|
21
|
+
dataset=dataset,
|
|
22
|
+
model=model,
|
|
23
|
+
checkpoint=checkpoint,
|
|
24
|
+
volume=volume,
|
|
25
|
+
)
|
|
26
|
+
payload = {k: v for k, v in data.items() if v is not None}
|
|
27
|
+
resp = await self.trainml._query(
|
|
28
|
+
f"/project/{self.project_id}/access", "POST",kwargs, payload)
|
|
29
|
+
member = ProjectMember(self.trainml, **resp)
|
|
30
|
+
logging.info(f"Added Project Member {email} to project {self.project_id}")
|
|
31
|
+
return member
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def remove(self, email, **kwargs):
|
|
35
|
+
await self.trainml._query(
|
|
36
|
+
f"/project/{self.project_id}/access", "DELETE", dict(**kwargs, email=email)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectMember:
|
|
41
|
+
def __init__(self, trainml, **kwargs):
|
|
42
|
+
self.trainml = trainml
|
|
43
|
+
self._entity = kwargs
|
|
44
|
+
self._id = self._entity.get("email")
|
|
45
|
+
self._project_uuid = self._entity.get("project_uuid")
|
|
46
|
+
self._owner = self._entity.get("owner")
|
|
47
|
+
self._job = self._entity.get("job")
|
|
48
|
+
self._dataset = self._entity.get("dataset")
|
|
49
|
+
self._model = self._entity.get("model")
|
|
50
|
+
self._checkpoint = self._entity.get("checkpoint")
|
|
51
|
+
self._volume = self._entity.get("volume")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def id(self) -> str:
|
|
55
|
+
return self._id
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def project_uuid(self) -> str:
|
|
59
|
+
return self._project_uuid
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def email(self) -> str:
|
|
63
|
+
return self._id
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def owner(self) -> bool:
|
|
67
|
+
return self._owner
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def job(self) -> str:
|
|
71
|
+
return self._job
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def dataset(self) -> str:
|
|
75
|
+
return self._dataset
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def model(self) -> str:
|
|
79
|
+
return self._model
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def checkpoint(self) -> str:
|
|
83
|
+
return self._checkpoint
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def volume(self) -> str:
|
|
87
|
+
return self._volume
|
|
88
|
+
|
|
89
|
+
def __str__(self):
|
|
90
|
+
return json.dumps({k: v for k, v in self._entity.items()})
|
|
91
|
+
|
|
92
|
+
def __repr__(self):
|
|
93
|
+
return f"ProjectMember( trainml , **{self._entity.__repr__()})"
|
|
94
|
+
|
|
95
|
+
def __bool__(self):
|
|
96
|
+
return bool(self._id)
|
|
97
|
+
|
|
98
|
+
|
|
@@ -5,6 +5,7 @@ from .data_connectors import ProjectDataConnectors
|
|
|
5
5
|
from .services import ProjectServices
|
|
6
6
|
from .credentials import ProjectCredentials
|
|
7
7
|
from .secrets import ProjectSecrets
|
|
8
|
+
from .members import ProjectMembers
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class Projects(object):
|
|
@@ -56,6 +57,7 @@ class Project:
|
|
|
56
57
|
self.services = ProjectServices(self.trainml, self._id)
|
|
57
58
|
self.credentials = ProjectCredentials(self.trainml, self._id)
|
|
58
59
|
self.secrets = ProjectSecrets(self.trainml, self._id)
|
|
60
|
+
self.members = ProjectMembers(self.trainml, self._id)
|
|
59
61
|
|
|
60
62
|
@property
|
|
61
63
|
def id(self) -> str:
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import aiohttp
|
|
5
5
|
import logging
|
|
6
6
|
import traceback
|
|
7
|
+
import random
|
|
7
8
|
from importlib.metadata import version
|
|
8
9
|
|
|
9
10
|
from trainml.auth import Auth
|
|
@@ -48,7 +49,7 @@ class TrainML(object):
|
|
|
48
49
|
kwargs.get("domain_suffix")
|
|
49
50
|
or os.environ.get("TRAINML_DOMAIN_SUFFIX")
|
|
50
51
|
or env.get("domain_suffix")
|
|
51
|
-
or "
|
|
52
|
+
or "proximl.ai"
|
|
52
53
|
)
|
|
53
54
|
self.auth = Auth(
|
|
54
55
|
config_dir=CONFIG_DIR,
|
|
@@ -91,7 +92,7 @@ class TrainML(object):
|
|
|
91
92
|
def project(self) -> str:
|
|
92
93
|
return self.active_project
|
|
93
94
|
|
|
94
|
-
async def _query(self, path, method, params=None, data=None, headers=None):
|
|
95
|
+
async def _query(self, path, method, params=None, data=None, headers=None,max_retries=3, backoff_factor=0.5):
|
|
95
96
|
try:
|
|
96
97
|
tokens = self.auth.get_tokens()
|
|
97
98
|
except TrainMLException as e:
|
|
@@ -142,24 +143,40 @@ class TrainML(object):
|
|
|
142
143
|
logging.debug(
|
|
143
144
|
f"Request - Url: {url}, Method: {method}, Params: {params}, Body: {data}, Headers: {headers}"
|
|
144
145
|
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
146
|
+
for attempt in range(max_retries):
|
|
147
|
+
try:
|
|
148
|
+
async with aiohttp.ClientSession() as session:
|
|
149
|
+
async with session.request(
|
|
150
|
+
method,
|
|
151
|
+
url,
|
|
152
|
+
data=json.dumps(data),
|
|
153
|
+
headers=headers,
|
|
154
|
+
params=params,
|
|
155
|
+
) as resp:
|
|
156
|
+
if (resp.status // 100) in [4, 5]:
|
|
157
|
+
if resp.status == 502 and attempt < max_retries - 1:
|
|
158
|
+
wait_time = (2 ** attempt) * backoff_factor * (random.random() + 0.5)
|
|
159
|
+
await asyncio.sleep(wait_time)
|
|
160
|
+
continue
|
|
161
|
+
else:
|
|
162
|
+
what = await resp.read()
|
|
163
|
+
content_type = resp.headers.get("content-type", "")
|
|
164
|
+
resp.close()
|
|
165
|
+
if content_type == "application/json":
|
|
166
|
+
raise ApiError(resp.status, json.loads(what.decode("utf8")))
|
|
167
|
+
else:
|
|
168
|
+
raise ApiError(resp.status, {"message": what.decode("utf8")})
|
|
169
|
+
results = await resp.json()
|
|
170
|
+
return results
|
|
171
|
+
except aiohttp.ClientResponseError as e:
|
|
172
|
+
if e.status == 502 and attempt < max_retries - 1:
|
|
173
|
+
wait_time = (2 ** attempt) * backoff_factor * (random.random() + 0.5)
|
|
174
|
+
await asyncio.sleep(wait_time)
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
raise ApiError(e.status, f"Error {e.message}")
|
|
178
|
+
|
|
179
|
+
raise TrainMLException("Unexpected API failure")
|
|
163
180
|
|
|
164
181
|
async def _ws_subscribe(self, entity, project_uuid, id, msg_handler):
|
|
165
182
|
headers = {
|
|
@@ -23,6 +23,7 @@ tests/integration/projects/test_projects_credentials_integration.py
|
|
|
23
23
|
tests/integration/projects/test_projects_data_connectors_integration.py
|
|
24
24
|
tests/integration/projects/test_projects_datastores_integration.py
|
|
25
25
|
tests/integration/projects/test_projects_integration.py
|
|
26
|
+
tests/integration/projects/test_projects_members_integration.py
|
|
26
27
|
tests/integration/projects/test_projects_secrets_integration.py
|
|
27
28
|
tests/integration/projects/test_projects_services_integration.py
|
|
28
29
|
tests/unit/__init__.py
|
|
@@ -74,6 +75,7 @@ tests/unit/projects/__init__.py
|
|
|
74
75
|
tests/unit/projects/test_project_credentials_unit.py
|
|
75
76
|
tests/unit/projects/test_project_data_connectors_unit.py
|
|
76
77
|
tests/unit/projects/test_project_datastores_unit.py
|
|
78
|
+
tests/unit/projects/test_project_members_unit.py
|
|
77
79
|
tests/unit/projects/test_project_secrets_unit.py
|
|
78
80
|
tests/unit/projects/test_project_services_unit.py
|
|
79
81
|
tests/unit/projects/test_projects_unit.py
|
|
@@ -134,6 +136,7 @@ trainml/projects/__init__.py
|
|
|
134
136
|
trainml/projects/credentials.py
|
|
135
137
|
trainml/projects/data_connectors.py
|
|
136
138
|
trainml/projects/datastores.py
|
|
139
|
+
trainml/projects/members.py
|
|
137
140
|
trainml/projects/projects.py
|
|
138
141
|
trainml/projects/secrets.py
|
|
139
142
|
trainml/projects/services.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/integration/cloudbender/test_providers_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_datastores_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_secrets_integration.py
RENAMED
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/integration/projects/test_projects_services_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_credential_unit.py
RENAMED
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_data_connector_unit.py
RENAMED
|
File without changes
|
{trainml-0.5.13 → trainml-0.5.14}/tests/unit/cli/projects/test_cli_project_datastore_unit.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|