pybiolib 1.2.883__py3-none-any.whl → 1.2.1890__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.
Files changed (124) hide show
  1. biolib/__init__.py +33 -10
  2. biolib/_data_record/data_record.py +220 -126
  3. biolib/_index/index.py +55 -0
  4. biolib/_index/query_result.py +103 -0
  5. biolib/_internal/add_copilot_prompts.py +24 -11
  6. biolib/_internal/add_gui_files.py +81 -0
  7. biolib/_internal/data_record/__init__.py +1 -1
  8. biolib/_internal/data_record/data_record.py +1 -18
  9. biolib/_internal/data_record/push_data.py +65 -16
  10. biolib/_internal/data_record/remote_storage_endpoint.py +18 -13
  11. biolib/_internal/file_utils.py +48 -0
  12. biolib/_internal/lfs/cache.py +4 -2
  13. biolib/_internal/push_application.py +95 -24
  14. biolib/_internal/runtime.py +2 -0
  15. biolib/_internal/string_utils.py +13 -0
  16. biolib/_internal/{llm_instructions → templates/copilot_template}/.github/instructions/style-general.instructions.md +5 -0
  17. biolib/_internal/templates/copilot_template/.github/instructions/style-react-ts.instructions.md +47 -0
  18. biolib/_internal/templates/copilot_template/.github/prompts/biolib_onboard_repo.prompt.md +19 -0
  19. biolib/_internal/templates/dashboard_template/.biolib/config.yml +5 -0
  20. biolib/_internal/templates/{init_template → github_workflow_template}/.github/workflows/biolib.yml +7 -2
  21. biolib/_internal/templates/gitignore_template/.gitignore +10 -0
  22. biolib/_internal/templates/gui_template/.yarnrc.yml +1 -0
  23. biolib/_internal/templates/gui_template/App.tsx +53 -0
  24. biolib/_internal/templates/gui_template/Dockerfile +27 -0
  25. biolib/_internal/templates/gui_template/biolib-sdk.ts +82 -0
  26. biolib/_internal/templates/gui_template/dev-data/output.json +7 -0
  27. biolib/_internal/templates/gui_template/index.css +5 -0
  28. biolib/_internal/templates/gui_template/index.html +13 -0
  29. biolib/_internal/templates/gui_template/index.tsx +10 -0
  30. biolib/_internal/templates/gui_template/package.json +27 -0
  31. biolib/_internal/templates/gui_template/tsconfig.json +24 -0
  32. biolib/_internal/templates/gui_template/vite-plugin-dev-data.ts +50 -0
  33. biolib/_internal/templates/gui_template/vite.config.mts +10 -0
  34. biolib/_internal/templates/init_template/.biolib/config.yml +1 -0
  35. biolib/_internal/templates/init_template/Dockerfile +5 -1
  36. biolib/_internal/templates/init_template/run.py +6 -15
  37. biolib/_internal/templates/init_template/run.sh +1 -0
  38. biolib/_internal/templates/templates.py +21 -1
  39. biolib/_internal/utils/__init__.py +47 -0
  40. biolib/_internal/utils/auth.py +46 -0
  41. biolib/_internal/utils/job_url.py +33 -0
  42. biolib/_internal/utils/multinode.py +12 -14
  43. biolib/_runtime/runtime.py +15 -2
  44. biolib/_session/session.py +7 -5
  45. biolib/_shared/__init__.py +0 -0
  46. biolib/_shared/types/__init__.py +74 -0
  47. biolib/_shared/types/account.py +12 -0
  48. biolib/_shared/types/account_member.py +8 -0
  49. biolib/{_internal → _shared}/types/experiment.py +1 -0
  50. biolib/_shared/types/resource.py +37 -0
  51. biolib/_shared/types/resource_deploy_key.py +11 -0
  52. biolib/{_internal → _shared}/types/resource_version.py +8 -2
  53. biolib/_shared/types/user.py +19 -0
  54. biolib/_shared/utils/__init__.py +7 -0
  55. biolib/_shared/utils/resource_uri.py +75 -0
  56. biolib/api/client.py +5 -48
  57. biolib/app/app.py +97 -55
  58. biolib/biolib_api_client/api_client.py +3 -47
  59. biolib/biolib_api_client/app_types.py +1 -1
  60. biolib/biolib_api_client/biolib_app_api.py +31 -6
  61. biolib/biolib_api_client/biolib_job_api.py +1 -1
  62. biolib/biolib_api_client/user_state.py +34 -2
  63. biolib/biolib_binary_format/module_input.py +8 -0
  64. biolib/biolib_binary_format/remote_endpoints.py +3 -3
  65. biolib/biolib_binary_format/remote_stream_seeker.py +39 -25
  66. biolib/biolib_logging.py +1 -1
  67. biolib/cli/__init__.py +2 -2
  68. biolib/cli/auth.py +4 -16
  69. biolib/cli/data_record.py +82 -0
  70. biolib/cli/index.py +32 -0
  71. biolib/cli/init.py +393 -71
  72. biolib/cli/lfs.py +1 -1
  73. biolib/cli/run.py +9 -6
  74. biolib/cli/start.py +14 -1
  75. biolib/compute_node/job_worker/executors/docker_executor.py +31 -9
  76. biolib/compute_node/job_worker/executors/docker_types.py +1 -1
  77. biolib/compute_node/job_worker/executors/types.py +6 -5
  78. biolib/compute_node/job_worker/job_storage.py +2 -1
  79. biolib/compute_node/job_worker/job_worker.py +155 -90
  80. biolib/compute_node/job_worker/large_file_system.py +2 -6
  81. biolib/compute_node/job_worker/network_alloc.py +99 -0
  82. biolib/compute_node/job_worker/network_buffer.py +240 -0
  83. biolib/compute_node/job_worker/utilization_reporter_thread.py +2 -2
  84. biolib/compute_node/remote_host_proxy.py +163 -79
  85. biolib/compute_node/utils.py +2 -0
  86. biolib/compute_node/webserver/compute_node_results_proxy.py +189 -0
  87. biolib/compute_node/webserver/proxy_utils.py +28 -0
  88. biolib/compute_node/webserver/webserver.py +64 -19
  89. biolib/experiments/experiment.py +111 -16
  90. biolib/jobs/job.py +128 -31
  91. biolib/jobs/job_result.py +74 -34
  92. biolib/jobs/types.py +1 -0
  93. biolib/sdk/__init__.py +28 -3
  94. biolib/typing_utils.py +1 -1
  95. biolib/utils/cache_state.py +8 -5
  96. biolib/utils/multipart_uploader.py +24 -18
  97. biolib/utils/seq_util.py +1 -1
  98. pybiolib-1.2.1890.dist-info/METADATA +41 -0
  99. pybiolib-1.2.1890.dist-info/RECORD +177 -0
  100. {pybiolib-1.2.883.dist-info → pybiolib-1.2.1890.dist-info}/WHEEL +1 -1
  101. pybiolib-1.2.1890.dist-info/entry_points.txt +2 -0
  102. biolib/_internal/llm_instructions/.github/instructions/style-react-ts.instructions.md +0 -22
  103. biolib/_internal/templates/init_template/.gitignore +0 -2
  104. biolib/_internal/types/__init__.py +0 -6
  105. biolib/_internal/types/resource.py +0 -18
  106. biolib/biolib_download_container.py +0 -38
  107. biolib/cli/download_container.py +0 -14
  108. biolib/utils/app_uri.py +0 -57
  109. pybiolib-1.2.883.dist-info/METADATA +0 -50
  110. pybiolib-1.2.883.dist-info/RECORD +0 -148
  111. pybiolib-1.2.883.dist-info/entry_points.txt +0 -3
  112. /biolib/{_internal/llm_instructions → _index}/__init__.py +0 -0
  113. /biolib/_internal/{llm_instructions → templates/copilot_template}/.github/instructions/general-app-knowledge.instructions.md +0 -0
  114. /biolib/_internal/{llm_instructions → templates/copilot_template}/.github/instructions/style-python.instructions.md +0 -0
  115. /biolib/_internal/{llm_instructions → templates/copilot_template}/.github/prompts/biolib_app_inputs.prompt.md +0 -0
  116. /biolib/_internal/{llm_instructions → templates/copilot_template}/.github/prompts/biolib_run_apps.prompt.md +0 -0
  117. /biolib/{_internal → _shared}/types/app.py +0 -0
  118. /biolib/{_internal → _shared}/types/data_record.py +0 -0
  119. /biolib/{_internal → _shared}/types/file_node.py +0 -0
  120. /biolib/{_internal → _shared}/types/push.py +0 -0
  121. /biolib/{_internal → _shared}/types/resource_permission.py +0 -0
  122. /biolib/{_internal → _shared}/types/result.py +0 -0
  123. /biolib/{_internal → _shared}/types/typing.py +0 -0
  124. {pybiolib-1.2.883.dist-info → pybiolib-1.2.1890.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,189 @@
1
+ import io
2
+ import os
3
+ import tarfile
4
+ import threading
5
+ import time
6
+
7
+ from docker.models.containers import Container
8
+
9
+ from biolib.biolib_docker_client import BiolibDockerClient
10
+ from biolib.biolib_errors import BioLibError
11
+ from biolib.biolib_logging import logger, logger_no_user_data
12
+ from biolib.compute_node.utils import BIOLIB_PROXY_NETWORK_NAME
13
+ from biolib.compute_node.webserver.proxy_utils import get_biolib_nginx_proxy_image
14
+ from biolib.typing_utils import Optional
15
+
16
+
17
+ class LogStreamingThread(threading.Thread):
18
+ def __init__(self, container, container_name: str):
19
+ super().__init__(daemon=True)
20
+ self._container = container
21
+ self._container_name = container_name
22
+ self._stop_event = threading.Event()
23
+
24
+ def run(self) -> None:
25
+ try:
26
+ logger_no_user_data.debug(f'Starting log streaming for container "{self._container_name}"')
27
+ log_stream = self._container.logs(follow=True, stream=True)
28
+ for log_line in log_stream:
29
+ if self._stop_event.is_set():
30
+ break
31
+ if log_line:
32
+ logger.debug(f'ComputeNodeResultsProxy | {log_line.decode("utf-8", errors="replace").rstrip()}')
33
+ except Exception as error:
34
+ logger_no_user_data.debug(f'Log streaming for container "{self._container_name}" ended: {error}')
35
+
36
+ def stop(self) -> None:
37
+ self._stop_event.set()
38
+
39
+
40
+ class ComputeNodeResultsProxy:
41
+ _instance: Optional['ComputeNodeResultsProxy'] = None
42
+
43
+ def __init__(self, tls_pem_certificate_path: str, tls_pem_key_path: str):
44
+ assert tls_pem_certificate_path, 'tls_pem_certificate_path is required'
45
+ assert tls_pem_key_path, 'tls_pem_key_path is required'
46
+ self._name = 'biolib-compute-node-results-proxy'
47
+ self._container: Optional[Container] = None
48
+ self._docker = BiolibDockerClient().get_docker_client()
49
+ self._tls_pem_certificate_path = tls_pem_certificate_path
50
+ self._tls_pem_key_path = tls_pem_key_path
51
+ self._log_streaming_thread: Optional[LogStreamingThread] = None
52
+
53
+ @staticmethod
54
+ def start_proxy(tls_pem_certificate_path: str, tls_pem_key_path: str) -> None:
55
+ abs_cert_path = os.path.abspath(tls_pem_certificate_path)
56
+ abs_key_path = os.path.abspath(tls_pem_key_path)
57
+ assert os.path.exists(abs_cert_path), f'TLS certificate file does not exist: {abs_cert_path}'
58
+ assert os.path.exists(abs_key_path), f'TLS key file does not exist: {abs_key_path}'
59
+
60
+ if ComputeNodeResultsProxy._instance is None:
61
+ logger_no_user_data.debug(
62
+ f'Creating ComputeNodeResultsProxy instance with cert: {abs_cert_path}, key: {abs_key_path}'
63
+ )
64
+ ComputeNodeResultsProxy._instance = ComputeNodeResultsProxy(abs_cert_path, abs_key_path)
65
+ ComputeNodeResultsProxy._instance._start() # pylint: disable=protected-access
66
+
67
+ @staticmethod
68
+ def stop_proxy() -> None:
69
+ if ComputeNodeResultsProxy._instance is not None:
70
+ ComputeNodeResultsProxy._instance._terminate() # pylint: disable=protected-access
71
+ ComputeNodeResultsProxy._instance = None
72
+
73
+ def _start(self) -> None:
74
+ docker = BiolibDockerClient.get_docker_client()
75
+
76
+ for index in range(3):
77
+ logger_no_user_data.debug(
78
+ f'Attempt {index} at creating ComputeNodeResultsProxy container "{self._name}"...'
79
+ )
80
+ try:
81
+ self._container = docker.containers.create(
82
+ detach=True,
83
+ image=get_biolib_nginx_proxy_image(),
84
+ name=self._name,
85
+ network=BIOLIB_PROXY_NETWORK_NAME,
86
+ ports={'443/tcp': 20443},
87
+ volumes={
88
+ self._tls_pem_certificate_path: {'bind': '/etc/ssl/certs/certificate.pem', 'mode': 'ro'},
89
+ self._tls_pem_key_path: {'bind': '/etc/ssl/private/private_key.pem', 'mode': 'ro'},
90
+ },
91
+ )
92
+ break
93
+ except Exception as error:
94
+ logger_no_user_data.exception(f'Failed to create container "{self._name}" hit error: {error}')
95
+
96
+ logger_no_user_data.debug('Sleeping before re-trying container creation...')
97
+ time.sleep(3)
98
+
99
+ if not self._container or not self._container.id:
100
+ raise BioLibError(f'Exceeded re-try limit for creating container {self._name}')
101
+
102
+ self._write_nginx_config_to_container()
103
+ self._container.start()
104
+
105
+ logger_no_user_data.debug(f'Waiting for container "{self._name}" to be ready...')
106
+ proxy_is_ready = False
107
+ for retry_count in range(1, 5):
108
+ time.sleep(0.5 * retry_count)
109
+ container_logs = self._container.logs()
110
+ if b'ready for start up\n' in container_logs or b'start worker process ' in container_logs:
111
+ proxy_is_ready = True
112
+ break
113
+
114
+ if not proxy_is_ready:
115
+ logger_no_user_data.error('ComputeNodeResultsProxy did not start properly.')
116
+ self._terminate()
117
+ raise Exception('ComputeNodeResultsProxy did not start properly')
118
+
119
+ self._container.reload()
120
+ logger.debug(f'ComputeNodeResultsProxy container "{self._name}" started with ID: {self._container.id}')
121
+
122
+ self._log_streaming_thread = LogStreamingThread(self._container, self._name)
123
+ self._log_streaming_thread.start()
124
+ logger_no_user_data.debug(f'Started log streaming for container "{self._name}"')
125
+
126
+ def _terminate(self):
127
+ logger_no_user_data.debug(f'Terminating ComputeNodeResultsProxy container "{self._name}"')
128
+ if self._log_streaming_thread:
129
+ self._log_streaming_thread.stop()
130
+ self._log_streaming_thread = None
131
+
132
+ logger.debug(f'Docker container removal temporarily disabled for debugging purposes (container "{self._name}")')
133
+ # TODO: Figure if we need to remove the container or keep it for debugging purposes
134
+ # if self._container:
135
+ # self._container.remove(force=True)
136
+
137
+ def _write_nginx_config_to_container(self) -> None:
138
+ if not self._container:
139
+ raise Exception('ComputeNodeResultsProxy container not defined when attempting to write NGINX config')
140
+
141
+ docker = BiolibDockerClient.get_docker_client()
142
+
143
+ nginx_config = """
144
+ events {
145
+ worker_connections 1024;
146
+ }
147
+
148
+ http {
149
+ client_max_body_size 0;
150
+ resolver 127.0.0.11 ipv6=off valid=30s;
151
+
152
+ server {
153
+ listen 443 ssl http2 default_server;
154
+
155
+ ssl_certificate /etc/ssl/certs/certificate.pem;
156
+ ssl_certificate_key /etc/ssl/private/private_key.pem;
157
+ ssl_protocols TLSv1.2 TLSv1.3;
158
+
159
+ location / {
160
+ if ($http_biolib_result_uuid = "") {
161
+ return 400 "Missing biolib-result-uuid header";
162
+ }
163
+
164
+ if ($http_biolib_result_port = "") {
165
+ return 400 "Missing biolib-result-port header";
166
+ }
167
+
168
+ set $target_host "biolib-app-caller-proxy-$http_biolib_result_uuid";
169
+ proxy_pass http://$target_host:1080$request_uri;
170
+ proxy_set_header Host $http_host;
171
+ proxy_set_header biolib-result-uuid $http_biolib_result_uuid;
172
+ proxy_set_header biolib-result-port $http_biolib_result_port;
173
+ proxy_pass_request_headers on;
174
+ }
175
+ }
176
+ }
177
+ """
178
+
179
+ nginx_config_bytes = nginx_config.encode()
180
+ tarfile_in_memory = io.BytesIO()
181
+ with tarfile.open(fileobj=tarfile_in_memory, mode='w:gz') as tar:
182
+ info = tarfile.TarInfo('/nginx.conf')
183
+ info.size = len(nginx_config_bytes)
184
+ tar.addfile(info, io.BytesIO(nginx_config_bytes))
185
+
186
+ tarfile_bytes = tarfile_in_memory.getvalue()
187
+ tarfile_in_memory.close()
188
+ logger_no_user_data.debug('Writing NGINX configuration to ComputeNodeResultsProxy container')
189
+ docker.api.put_archive(self._container.id, '/etc/nginx', tarfile_bytes)
@@ -0,0 +1,28 @@
1
+ from docker.errors import ImageNotFound
2
+ from docker.models.images import Image
3
+
4
+ from biolib import utils
5
+ from biolib.biolib_docker_client import BiolibDockerClient
6
+ from biolib.biolib_logging import logger_no_user_data
7
+ from biolib.typing_utils import cast
8
+
9
+
10
+ def get_biolib_nginx_proxy_image() -> Image:
11
+ docker = BiolibDockerClient().get_docker_client()
12
+
13
+ if utils.IS_RUNNING_IN_CLOUD:
14
+ try:
15
+ logger_no_user_data.debug('Getting local Docker image for nginx proxy')
16
+ return cast(Image, docker.images.get('biolib-remote-host-proxy:latest'))
17
+ except ImageNotFound:
18
+ logger_no_user_data.debug(
19
+ 'Local Docker image for nginx proxy not available. Falling back to public image...'
20
+ )
21
+
22
+ public_image_uri = 'public.ecr.aws/h5y4b3l1/biolib-remote-host-proxy:latest'
23
+ try:
24
+ logger_no_user_data.debug('Getting public Docker image for nginx proxy')
25
+ return cast(Image, docker.images.get(public_image_uri))
26
+ except ImageNotFound:
27
+ logger_no_user_data.debug('Pulling public Docker image for nginx proxy')
28
+ return cast(Image, docker.images.pull(public_image_uri))
@@ -1,19 +1,24 @@
1
1
  # pylint: disable=unsubscriptable-object
2
2
 
3
3
  import json
4
+ import logging
4
5
  import os
5
- import time
6
6
  import tempfile
7
- import logging
8
- from flask import Flask, request, Response, jsonify
7
+ import time
8
+
9
+ from docker.models.networks import Network # type: ignore
10
+ from flask import Flask, Response, jsonify, request
9
11
 
10
12
  from biolib import utils
11
13
  from biolib.biolib_api_client import BiolibApiClient
12
14
  from biolib.biolib_binary_format import SavedJob
13
- from biolib.compute_node.webserver import webserver_utils
15
+ from biolib.biolib_docker_client import BiolibDockerClient
16
+ from biolib.biolib_logging import TRACE, logger, logger_no_user_data
14
17
  from biolib.compute_node.cloud_utils.cloud_utils import CloudUtils
18
+ from biolib.compute_node.utils import BIOLIB_PROXY_NETWORK_NAME
19
+ from biolib.compute_node.webserver import webserver_utils
20
+ from biolib.compute_node.webserver.compute_node_results_proxy import ComputeNodeResultsProxy
15
21
  from biolib.compute_node.webserver.gunicorn_flask_application import GunicornFlaskApplication
16
- from biolib.biolib_logging import logger, TRACE
17
22
  from biolib.compute_node.webserver.webserver_utils import get_job_compute_state_or_404
18
23
  from biolib.typing_utils import Optional
19
24
 
@@ -102,15 +107,14 @@ def status(job_id):
102
107
  response_data['is_completed'] = compute_state['is_completed']
103
108
 
104
109
  if current_status['stdout_and_stderr_packages_b64']:
105
- compute_state['streamed_logs_packages_b64'] = compute_state['streamed_logs_packages_b64'] + \
106
- current_status['stdout_and_stderr_packages_b64']
110
+ compute_state['streamed_logs_packages_b64'] = (
111
+ compute_state['streamed_logs_packages_b64'] + current_status['stdout_and_stderr_packages_b64']
112
+ )
107
113
 
108
114
  compute_state['status']['stdout_and_stderr_packages_b64'] = []
109
115
 
110
116
  if current_status['status_updates']:
111
- compute_state['previous_status_updates'].extend(
112
- current_status['status_updates']
113
- )
117
+ compute_state['previous_status_updates'].extend(current_status['status_updates'])
114
118
  compute_state['status']['status_updates'] = []
115
119
 
116
120
  if return_full_logs:
@@ -132,21 +136,37 @@ def send_package_to_compute_process(job_id, package_bytes):
132
136
 
133
137
 
134
138
  def start_webserver(
135
- host: str,
136
- port: int,
137
- tls_pem_certificate_path: Optional[str] = None,
138
- tls_pem_key_path: Optional[str] = None,
139
+ host: str,
140
+ port: int,
141
+ tls_pem_certificate_path: Optional[str] = None,
142
+ tls_pem_key_path: Optional[str] = None,
139
143
  ) -> None:
140
144
  def worker_exit(server, worker): # pylint: disable=unused-argument
141
- active_compute_states = list(
142
- webserver_utils.JOB_ID_TO_COMPUTE_STATE_DICT.values()) + webserver_utils.UNASSIGNED_COMPUTE_PROCESSES
145
+ active_compute_states = (
146
+ list(webserver_utils.JOB_ID_TO_COMPUTE_STATE_DICT.values()) + webserver_utils.UNASSIGNED_COMPUTE_PROCESSES
147
+ )
143
148
  logger.debug(f'Sending terminate signal to {len(active_compute_states)} compute processes')
144
149
  if active_compute_states:
145
150
  for compute_state in active_compute_states:
146
151
  if compute_state['worker_thread']:
147
152
  compute_state['worker_thread'].terminate()
148
153
  time.sleep(2)
149
- return
154
+
155
+ if utils.IS_RUNNING_IN_CLOUD:
156
+ try:
157
+ logger_no_user_data.debug('Stopping ComputeNodeResultsProxy...')
158
+ ComputeNodeResultsProxy.stop_proxy()
159
+ except BaseException:
160
+ logger_no_user_data.exception('Failed to stop ComputeNodeResultsProxy')
161
+
162
+ try:
163
+ logger_no_user_data.debug(f'Removing Docker network {BIOLIB_PROXY_NETWORK_NAME}')
164
+ docker_client = BiolibDockerClient.get_docker_client()
165
+ biolib_proxy_network: Network = docker_client.networks.get(BIOLIB_PROXY_NETWORK_NAME)
166
+ biolib_proxy_network.remove()
167
+ logger_no_user_data.debug(f'Successfully removed Docker network {BIOLIB_PROXY_NETWORK_NAME}')
168
+ except BaseException:
169
+ logger_no_user_data.exception(f'Failed to clean up network {BIOLIB_PROXY_NETWORK_NAME}')
150
170
 
151
171
  def post_fork(server, worker): # pylint: disable=unused-argument
152
172
  logger.info('Started compute node')
@@ -157,8 +177,33 @@ def start_webserver(
157
177
  utils.IS_DEV = config['is_dev']
158
178
  BiolibApiClient.initialize(config['base_url'])
159
179
 
160
- CloudUtils.initialize()
161
- webserver_utils.start_auto_shutdown_timer()
180
+ biolib_proxy_network: Optional[Network] = None
181
+ try:
182
+ logger_no_user_data.debug(f'Creating Docker network {BIOLIB_PROXY_NETWORK_NAME}')
183
+ docker_client = BiolibDockerClient.get_docker_client()
184
+ biolib_proxy_network = docker_client.networks.create(
185
+ name=BIOLIB_PROXY_NETWORK_NAME,
186
+ internal=False,
187
+ driver='bridge',
188
+ )
189
+ logger_no_user_data.debug(f'Successfully created Docker network {BIOLIB_PROXY_NETWORK_NAME}')
190
+ except BaseException:
191
+ logger_no_user_data.exception(f'Failed to create Docker network {BIOLIB_PROXY_NETWORK_NAME}')
192
+
193
+ if biolib_proxy_network:
194
+ try:
195
+ logger_no_user_data.debug('Starting ComputeNodeResultsProxy...')
196
+ ComputeNodeResultsProxy.start_proxy(tls_pem_certificate_path, tls_pem_key_path)
197
+ except BaseException:
198
+ logger_no_user_data.exception('Failed to start ComputeNodeResultsProxy')
199
+
200
+ CloudUtils.initialize()
201
+ webserver_utils.start_auto_shutdown_timer()
202
+ else:
203
+ logger_no_user_data.error(
204
+ f'Docker network {BIOLIB_PROXY_NETWORK_NAME} was not created, shutting down...'
205
+ )
206
+ CloudUtils.deregister_and_shutdown()
162
207
 
163
208
  if logger.level == TRACE:
164
209
  gunicorn_log_level_name = 'DEBUG'
@@ -1,13 +1,15 @@
1
1
  import time
2
2
  from collections import OrderedDict
3
+ from pathlib import Path
3
4
 
4
5
  from biolib import api
5
- from biolib._internal.types.experiment import DeprecatedExperimentDict, ExperimentDict
6
- from biolib._internal.types.resource import ResourceDetailedDict
7
6
  from biolib._internal.utils import open_browser_window_from_notebook
7
+ from biolib._shared.types import DeprecatedExperimentDict, ExperimentDict, ResourceDetailedDict
8
+ from biolib.api.client import ApiClient
8
9
  from biolib.biolib_api_client import BiolibApiClient
9
10
  from biolib.biolib_errors import BioLibError
10
11
  from biolib.jobs.job import Job
12
+ from biolib.jobs.job_result import PathFilter
11
13
  from biolib.jobs.types import JobsPaginatedResponse
12
14
  from biolib.tables import BioLibTable
13
15
  from biolib.typing_utils import Dict, List, Optional, Union
@@ -26,7 +28,13 @@ class Experiment:
26
28
  }
27
29
  )
28
30
 
29
- def __init__(self, uri: str, _resource_dict: Optional[ResourceDetailedDict] = None):
31
+ def __init__(
32
+ self,
33
+ uri: str,
34
+ _resource_dict: Optional[ResourceDetailedDict] = None,
35
+ _api_client: Optional[ApiClient] = None,
36
+ ):
37
+ self._api_client = _api_client or api.client
30
38
  self._resource_dict: ResourceDetailedDict = _resource_dict or self._get_or_create_resource_dict(uri)
31
39
 
32
40
  def __enter__(self):
@@ -111,7 +119,7 @@ class Experiment:
111
119
  job_id = job
112
120
  elif job is None and job_id is None:
113
121
  raise BioLibError('A job ID or job object must be provided to add job')
114
- api.client.post(
122
+ self._api_client.post(
115
123
  path=f'/experiments/{self.uuid}/jobs/',
116
124
  data={'job_uuid': job_id},
117
125
  )
@@ -124,7 +132,7 @@ class Experiment:
124
132
  else:
125
133
  raise BioLibError('A job ID or job object must be provided to remove job')
126
134
 
127
- api.client.delete(path=f'/experiments/{self.uuid}/jobs/{job_id}/')
135
+ self._api_client.delete(path=f'/experiments/{self.uuid}/jobs/{job_id}/')
128
136
 
129
137
  def mount_files(self, mount_path: str) -> None:
130
138
  try:
@@ -173,7 +181,7 @@ class Experiment:
173
181
 
174
182
  # Prints a table listing info about the jobs in this experiment
175
183
  def show_jobs(self) -> None:
176
- response: JobsPaginatedResponse = api.client.get(
184
+ response: JobsPaginatedResponse = self._api_client.get(
177
185
  path=f'/experiments/{self.uuid}/jobs/',
178
186
  params=dict(page_size=10),
179
187
  ).json()
@@ -195,11 +203,13 @@ class Experiment:
195
203
  if status:
196
204
  params['status'] = status
197
205
 
198
- response: JobsPaginatedResponse = api.client.get(url, params=params).json()
206
+ response: JobsPaginatedResponse = self._api_client.get(url, params=params).json()
199
207
  jobs: List[Job] = [Job(job_dict) for job_dict in response['results']]
200
208
 
201
209
  for page_number in range(2, response['page_count'] + 1):
202
- page_response: JobsPaginatedResponse = api.client.get(url, params=dict(**params, page=page_number)).json()
210
+ page_response: JobsPaginatedResponse = self._api_client.get(
211
+ url, params=dict(**params, page=page_number)
212
+ ).json()
203
213
  jobs.extend([Job(job_dict) for job_dict in page_response['results']])
204
214
 
205
215
  return jobs
@@ -223,22 +233,107 @@ class Experiment:
223
233
  """
224
234
  return self.get_jobs(status=status)
225
235
 
236
+ def save_completed_results(
237
+ self,
238
+ output_dir: Optional[str] = None,
239
+ path_filter: Optional[PathFilter] = None,
240
+ skip_file_if_exists: bool = False,
241
+ overwrite: bool = False,
242
+ ) -> None:
243
+ r"""Save all completed results in this experiment to local folders.
244
+
245
+ Creates a folder structure with the experiment name as the root directory,
246
+ containing a subfolder for each completed result. Only results with
247
+ 'completed' status will be saved.
248
+
249
+ Args:
250
+ output_dir (str, optional): Base directory where the experiment folder
251
+ will be created. If None, uses the current working directory.
252
+ path_filter (PathFilter, optional): Filter to select which files in the results to save.
253
+ Can be a glob pattern string or a callable function.
254
+ skip_file_if_exists (bool, optional): Whether to skip files that already exist
255
+ locally instead of raising an error. Defaults to False.
256
+ overwrite (bool, optional): Whether to overwrite existing files.
257
+ Defaults to False.
258
+
259
+ Example::
260
+
261
+ >>> # Save all completed results to current directory
262
+ >>> experiment.save_completed_results()
263
+ >>> # This creates: ./experiment_name/result_1/, ./experiment_name/result_2/, etc.
264
+
265
+ >>> # Save to specific directory
266
+ >>> experiment.save_completed_results(output_dir="/path/to/save")
267
+ >>> # This creates: /path/to/save/experiment_name/result_1/, etc.
268
+ """
269
+ base_dir = Path(output_dir) if output_dir else Path.cwd()
270
+
271
+ if base_dir == Path('/'):
272
+ raise BioLibError("Cannot save experiment results to root directory '/'")
273
+
274
+ experiment_folder = base_dir / self.name
275
+ experiment_folder.mkdir(parents=True, exist_ok=True)
276
+
277
+ completed_results: List[Job] = []
278
+ failed_results = False
279
+ print('Getting experiment status...')
280
+ for result in self.get_results():
281
+ if result.get_status() == 'completed':
282
+ completed_results.append(result)
283
+ elif result.get_status() != 'in_progress':
284
+ failed_results = True
285
+
286
+ if failed_results:
287
+ print(
288
+ 'WARNING: Found failed or cancelled results in the experiment. '
289
+ 'Please verify you have all your results, and consider removing the failed ones.'
290
+ )
291
+ if not completed_results:
292
+ print(f"No completed results found in experiment '{self.name}'")
293
+ return
294
+
295
+ print(f"Saving {len(completed_results)} completed results from experiment '{self.name}' to {experiment_folder}")
296
+
297
+ for result in completed_results:
298
+ result_name = result.get_name()
299
+ result_folder = experiment_folder / result_name
300
+
301
+ result_folder.mkdir(parents=True, exist_ok=True)
302
+
303
+ result.save_files(
304
+ output_dir=str(result_folder),
305
+ path_filter=path_filter,
306
+ skip_file_if_exists=skip_file_if_exists,
307
+ overwrite=overwrite,
308
+ )
309
+
226
310
  def rename(self, destination: str) -> None:
227
- api.client.patch(f'/resources/{self.uuid}/', data={'uri': destination})
311
+ r"""Rename this experiment to a new URI.
312
+
313
+ Args:
314
+ destination (str): The new URI for the experiment
315
+ (e.g., 'username/new-experiment-name').
316
+
317
+ Example::
318
+
319
+ >>> experiment = biolib.get_experiment(uri='username/my-experiment')
320
+ >>> experiment.rename('username/my-renamed-experiment')
321
+ >>> print(experiment.uri)
322
+ 'username/my-renamed-experiment'
323
+ """
324
+ self._api_client.patch(f'/resources/{self.uuid}/', data={'uri': destination})
228
325
  self._refetch()
229
326
 
230
- @staticmethod
231
- def _get_resource_dict_by_uuid(uuid: str) -> ResourceDetailedDict:
232
- resource_dict: ResourceDetailedDict = api.client.get(f'/resources/{uuid}/').json()
327
+ def _get_resource_dict_by_uuid(self, uuid: str) -> ResourceDetailedDict:
328
+ resource_dict: ResourceDetailedDict = self._api_client.get(f'/resources/{uuid}/').json()
233
329
  if not resource_dict['experiment']:
234
330
  raise ValueError('Resource from URI is not an experiment')
235
331
 
236
332
  return resource_dict
237
333
 
238
- @staticmethod
239
- def _get_or_create_resource_dict(uri: str) -> ResourceDetailedDict:
240
- response_dict = api.client.post(path='/experiments/', data={'uri' if '/' in uri else 'name': uri}).json()
241
- return Experiment._get_resource_dict_by_uuid(uuid=response_dict['uuid'])
334
+ def _get_or_create_resource_dict(self, uri: str) -> ResourceDetailedDict:
335
+ response_dict = self._api_client.post(path='/experiments/', data={'uri' if '/' in uri else 'name': uri}).json()
336
+ return self._get_resource_dict_by_uuid(uuid=response_dict['uuid'])
242
337
 
243
338
  def _refetch(self) -> None:
244
339
  self._resource_dict = self._get_resource_dict_by_uuid(uuid=self._resource_dict['uuid'])