python-chi 1.2.6__tar.gz → 1.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. python_chi-1.2.7/ChangeLog +7 -0
  2. {python_chi-1.2.6 → python_chi-1.2.7}/PKG-INFO +1 -1
  3. {python_chi-1.2.6 → python_chi-1.2.7}/chi/container.py +62 -45
  4. {python_chi-1.2.6 → python_chi-1.2.7}/chi/server.py +18 -10
  5. {python_chi-1.2.6 → python_chi-1.2.7}/chi/storage.py +2 -1
  6. {python_chi-1.2.6 → python_chi-1.2.7}/chi/util.py +40 -1
  7. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/PKG-INFO +1 -1
  8. python_chi-1.2.7/python_chi.egg-info/pbr.json +1 -0
  9. python_chi-1.2.7/setup.py +5 -0
  10. python_chi-1.2.7/tests/test_container.py +275 -0
  11. python_chi-1.2.6/ChangeLog +0 -7
  12. python_chi-1.2.6/python_chi.egg-info/pbr.json +0 -1
  13. python_chi-1.2.6/setup.py +0 -5
  14. python_chi-1.2.6/tests/test_container.py +0 -112
  15. {python_chi-1.2.6 → python_chi-1.2.7}/.github/CODEOWNERS +0 -0
  16. {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/linting.yml +0 -0
  17. {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/pypi-publish.yml +0 -0
  18. {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/test.yml +0 -0
  19. {python_chi-1.2.6 → python_chi-1.2.7}/.mailmap +0 -0
  20. {python_chi-1.2.6 → python_chi-1.2.7}/.readthedocs.yml +0 -0
  21. {python_chi-1.2.6 → python_chi-1.2.7}/AUTHORS +0 -0
  22. {python_chi-1.2.6 → python_chi-1.2.7}/DEVELOPMENT.rst +0 -0
  23. {python_chi-1.2.6 → python_chi-1.2.7}/LICENSE +0 -0
  24. {python_chi-1.2.6 → python_chi-1.2.7}/Makefile +0 -0
  25. {python_chi-1.2.6 → python_chi-1.2.7}/README.rst +0 -0
  26. {python_chi-1.2.6 → python_chi-1.2.7}/chi/__init__.py +0 -0
  27. {python_chi-1.2.6 → python_chi-1.2.7}/chi/clients.py +0 -0
  28. {python_chi-1.2.6 → python_chi-1.2.7}/chi/context.py +0 -0
  29. {python_chi-1.2.6 → python_chi-1.2.7}/chi/exception.py +0 -0
  30. {python_chi-1.2.6 → python_chi-1.2.7}/chi/hardware.py +0 -0
  31. {python_chi-1.2.6 → python_chi-1.2.7}/chi/image.py +0 -0
  32. {python_chi-1.2.6 → python_chi-1.2.7}/chi/jupyterhub.py +0 -0
  33. {python_chi-1.2.6 → python_chi-1.2.7}/chi/keypair.py +0 -0
  34. {python_chi-1.2.6 → python_chi-1.2.7}/chi/lease.py +0 -0
  35. {python_chi-1.2.6 → python_chi-1.2.7}/chi/magic.py +0 -0
  36. {python_chi-1.2.6 → python_chi-1.2.7}/chi/network.py +0 -0
  37. {python_chi-1.2.6 → python_chi-1.2.7}/chi/share.py +0 -0
  38. {python_chi-1.2.6 → python_chi-1.2.7}/chi/ssh.py +0 -0
  39. {python_chi-1.2.6 → python_chi-1.2.7}/docs/__init__.py +0 -0
  40. {python_chi-1.2.6 → python_chi-1.2.7}/docs/_templates/page.html +0 -0
  41. {python_chi-1.2.6 → python_chi-1.2.7}/docs/conf.py +0 -0
  42. {python_chi-1.2.6 → python_chi-1.2.7}/docs/examples.rst +0 -0
  43. {python_chi-1.2.6 → python_chi-1.2.7}/docs/generate_notebook.py +0 -0
  44. {python_chi-1.2.6 → python_chi-1.2.7}/docs/index.rst +0 -0
  45. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/clients.rst +0 -0
  46. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/container.rst +0 -0
  47. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/context.rst +0 -0
  48. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/exception.rst +0 -0
  49. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/hardware.rst +0 -0
  50. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/image.rst +0 -0
  51. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/lease.rst +0 -0
  52. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/magic.rst +0 -0
  53. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/network.rst +0 -0
  54. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/server.rst +0 -0
  55. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/share.rst +0 -0
  56. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/ssh.rst +0 -0
  57. {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/storage.rst +0 -0
  58. {python_chi-1.2.6 → python_chi-1.2.7}/docs/requirements.txt +0 -0
  59. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/SOURCES.txt +0 -0
  60. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/dependency_links.txt +0 -0
  61. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/not-zip-safe +0 -0
  62. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/requires.txt +0 -0
  63. {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/top_level.txt +0 -0
  64. {python_chi-1.2.6 → python_chi-1.2.7}/requirements.txt +0 -0
  65. {python_chi-1.2.6 → python_chi-1.2.7}/ruff.toml +0 -0
  66. {python_chi-1.2.6 → python_chi-1.2.7}/setup.cfg +0 -0
  67. {python_chi-1.2.6 → python_chi-1.2.7}/test-requirements.txt +0 -0
  68. {python_chi-1.2.6 → python_chi-1.2.7}/tests/__init__.py +0 -0
  69. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_context.py +0 -0
  70. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_lease.py +0 -0
  71. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_network.py +0 -0
  72. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_server.py +0 -0
  73. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_share.py +0 -0
  74. {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_ssh.py +0 -0
  75. {python_chi-1.2.6 → python_chi-1.2.7}/tox.ini +0 -0
@@ -0,0 +1,7 @@
1
+ CHANGES
2
+ =======
3
+
4
+ v1.2.7
5
+ ------
6
+
7
+ * Fix volume and server errors (#98)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-chi
3
- Version: 1.2.6
3
+ Version: 1.2.7
4
4
  Summary: Helper library for Chameleon Infrastructure (CHI) testbed
5
5
  Home-page: https://www.chameleoncloud.org
6
6
  Author: University of Chicago
@@ -18,6 +18,7 @@ import os
18
18
  import tarfile
19
19
  import time
20
20
  from typing import Dict, List, Optional, Tuple
21
+ from warnings import warn
21
22
 
22
23
  from IPython.display import HTML, display
23
24
  from packaging.version import Version
@@ -28,7 +29,7 @@ from chi import network as chi_network
28
29
 
29
30
  from .clients import connection, zun
30
31
  from .context import session
31
- from .exception import ResourceError, ServiceError
32
+ from .exception import ServiceError
32
33
  from .network import bind_floating_ip, get_free_floating_ip
33
34
 
34
35
  DEFAULT_IMAGE_DRIVER = "docker"
@@ -80,12 +81,17 @@ class Container:
80
81
  environment: Dict[str, str] = {},
81
82
  device_profiles: List[str] = [],
82
83
  ):
84
+
85
+ # check if values are not the defaults.
86
+ if not start or start_timeout != 0:
87
+ warn(
88
+ "start and start_timeout are deprecated. Containers always start immmediately."
89
+ )
90
+
83
91
  self.name = name
84
92
  self.image_ref = image_ref
85
93
  self.exposed_ports = exposed_ports
86
94
  self.reservation_id = reservation_id
87
- self.start = start
88
- self.start_timeout = start_timeout
89
95
  self.runtime = runtime
90
96
  self.id = None
91
97
  self.created_at = None
@@ -101,18 +107,20 @@ class Container:
101
107
  name=zun_container.name,
102
108
  image_ref=zun_container.image,
103
109
  exposed_ports=zun_container.ports if zun_container.ports else [],
104
- start=True, # Assuming the container is already created
105
110
  )
106
111
  container.id = zun_container.uuid
107
112
  container._status = zun_container.status
108
113
  return container
109
114
 
110
115
  @property
111
- def status(self):
116
+ def zun_container(self):
112
117
  if self.id:
113
- container = zun().containers.get(self.id)
114
- self._status = container.status
115
- return self._status
118
+ self._zun_container = zun().containers.get(self.id)
119
+ return self._zun_container
120
+
121
+ @property
122
+ def status(self):
123
+ return getattr(self.zun_container, "status")
116
124
 
117
125
  def submit(
118
126
  self,
@@ -155,21 +163,15 @@ class Container:
155
163
  image=self.image_ref,
156
164
  exposed_ports=self.exposed_ports,
157
165
  reservation_id=self.reservation_id,
158
- start=self.start,
159
- start_timeout=self.start_timeout,
160
166
  runtime=self.runtime,
161
167
  environment=self.environment,
162
168
  device_profiles=self.device_profiles,
163
169
  **kwargs,
164
170
  )
171
+ self.id = container.uuid
172
+ self._status = container.status
165
173
 
166
- if container:
167
- self.id = zun().containers.get(self.name).uuid
168
- self._status = zun().containers.get(self.name).status
169
- else:
170
- raise ResourceError("could not create container")
171
-
172
- if wait_for_active and self.status != "Running":
174
+ if wait_for_active and self._status != "Running":
173
175
  self.wait(status="Running", timeout=wait_timeout)
174
176
 
175
177
  if show:
@@ -189,7 +191,7 @@ class Container:
189
191
  None
190
192
  """
191
193
  if self.id:
192
- destroy_container(self.id)
194
+ destroy_container(self.id, force=True)
193
195
  self.id = None
194
196
  self._status = None
195
197
 
@@ -212,13 +214,47 @@ class Container:
212
214
  if show == "widget" and context._is_ipynb():
213
215
  pb.display()
214
216
 
217
+ state = {"status": None, "since": time.perf_counter(), "conditions": {}}
218
+
219
+ def _zun_attr(obj, name):
220
+ val = getattr(obj, name, None)
221
+ return val if val and val != "None" else None
222
+
215
223
  def _callback():
216
- # self.status is a property that refreshes itself
217
- # NOTE: zun statuses are title case
218
- if self.status.upper() == status.upper() or self.status == "Error":
219
- print(f"Container has moved to status {self.status}")
220
- return True
221
- return False
224
+ zun_container = self.zun_container
225
+ now = time.perf_counter()
226
+ elapsed = int(now - state["since"])
227
+
228
+ current_status = getattr(zun_container, "status", None)
229
+ detail = _zun_attr(zun_container, "status_detail")
230
+ reason = _zun_attr(zun_container, "status_reason")
231
+
232
+ if current_status != state["status"]:
233
+ if state["status"]:
234
+ pb.log(f"[{elapsed}s] {state['status']} -> {current_status}")
235
+ state["status"] = current_status
236
+ state["since"] = now
237
+ state["conditions"] = {}
238
+
239
+ if detail:
240
+ entry = state["conditions"].setdefault(detail, {"count": 0})
241
+ entry["count"] += 1
242
+ entry["reason"] = reason
243
+
244
+ parts = [f"{current_status} ({elapsed}s)"]
245
+ for name, entry in state["conditions"].items():
246
+ count = entry["count"]
247
+
248
+ if count > 1:
249
+ line = f"{name} (x{count})"
250
+ else:
251
+ line = name
252
+ if entry.get("reason"):
253
+ line = f"{line} {entry['reason']}"
254
+ parts.append(line)
255
+ pb.update_status("\n".join(parts))
256
+
257
+ return current_status == "Error" or current_status.upper() == status.upper()
222
258
 
223
259
  res = pb.wait(_callback, 2 * 60, timeout)
224
260
  if not res:
@@ -369,9 +405,6 @@ def create_container(
369
405
  image: "str" = None,
370
406
  exposed_ports: "list[str]" = None,
371
407
  reservation_id: "str" = None,
372
- start: "bool" = True,
373
- start_timeout: "int" = None,
374
- platform_version: "int" = 2,
375
408
  **kwargs,
376
409
  ):
377
410
  """
@@ -407,8 +440,6 @@ def create_container(
407
440
  hints = kwargs.setdefault("hints", {})
408
441
  if reservation_id:
409
442
  hints["reservation"] = reservation_id
410
- if platform_version:
411
- hints["platform_version"] = platform_version
412
443
 
413
444
  # Support simpler syntax for exposed_ports
414
445
  if exposed_ports and isinstance(exposed_ports, list):
@@ -429,20 +460,6 @@ def create_container(
429
460
  **kwargs,
430
461
  )
431
462
 
432
- # Wait for a while, the image may need to download. 30 minutes is
433
- # _quite_ a long time, but the user can interrupt or choose a smaller
434
- # timeout.
435
- timeout = start_timeout or (60 * 30)
436
- LOG.info(f"Waiting up to {timeout}s for container creation ...")
437
-
438
- if platform_version == 2:
439
- container = _wait_for_status(container.uuid, "Running", timeout=timeout)
440
- else:
441
- container = _wait_for_status(container.uuid, "Created", timeout=timeout)
442
- if start:
443
- LOG.info("Starting container ...")
444
- zun().containers.start(container.uuid)
445
-
446
463
  return container
447
464
 
448
465
 
@@ -498,7 +515,7 @@ def snapshot_container(
498
515
  return zun().containers.commit(container_ref, repository, tag=tag)["uuid"]
499
516
 
500
517
 
501
- def destroy_container(container_ref: "str"):
518
+ def destroy_container(container_ref: "str", stop=False, force=False):
502
519
  """
503
520
  .. deprecated:: 1.0
504
521
 
@@ -509,7 +526,7 @@ def destroy_container(container_ref: "str"):
509
526
  Args:
510
527
  container_ref (str): The name or ID of the container.
511
528
  """
512
- return zun().containers.delete(container_ref, stop=True)
529
+ return zun().containers.delete(container_ref, stop=stop, force=force)
513
530
 
514
531
 
515
532
  def get_logs(container_ref: "str", stdout=True, stderr=True):
@@ -76,7 +76,7 @@ class Server:
76
76
  Args:
77
77
  name (str): The name of the server.
78
78
  reservation_id (Optional[str]): The reservation ID associated with the server. Defaults to None.
79
- image_name (str): The name of the image to use for the server. Defaults to DEFAULT_IMAGE_NAME.
79
+ image_name (Optional[str]): The name of the image to use for the server. Defaults to DEFAULT_IMAGE_NAME.
80
80
  image (Optional[str]): The image ID or name to use for the server. Defaults to None.
81
81
  flavor_name (str): The name of the flavor to use for the server. Defaults to BAREMETAL_FLAVOR.
82
82
  key_name (str): The name of the keypair to use for the server. Defaults to None.
@@ -86,7 +86,7 @@ class Server:
86
86
  Attributes:
87
87
  name (str): The name of the server.
88
88
  reservation_id (Optional[str]): The reservation ID associated with the server.
89
- image_name (str): The name of the image used for the server.
89
+ image_name (Optional[str]): The name of the image used for the server.
90
90
  flavor_name (str): The name of the flavor used for the server.
91
91
  keypair (Optional[Keypair]): The keypair object used for the server.
92
92
  network_name (str): The name of the network used for the server.
@@ -104,7 +104,7 @@ class Server:
104
104
  self,
105
105
  name: str,
106
106
  reservation_id: Optional[str] = None,
107
- image_name: str = DEFAULT_IMAGE_NAME,
107
+ image_name: Optional[str] = DEFAULT_IMAGE_NAME,
108
108
  image: Optional[Image] = None,
109
109
  flavor_name: str = BAREMETAL_FLAVOR,
110
110
  key_name: str = None,
@@ -114,8 +114,13 @@ class Server:
114
114
  self.name = name
115
115
  self.reservation_id = reservation_id or None
116
116
  # Add this once chi.image is implemented
117
- self.image = image or chi.image.get_image(image_name)
118
- self.image_name = self.image.name
117
+ if image:
118
+ self.image = image
119
+ elif image_name:
120
+ self.image = chi.image.get_image(image_name)
121
+ else:
122
+ self.image = None
123
+ self.image_name = self.image.name if self.image else None
119
124
  self.flavor_name = flavor_name
120
125
 
121
126
  if keypair:
@@ -216,10 +221,13 @@ class Server:
216
221
 
217
222
  @classmethod
218
223
  def _from_nova_server(cls, nova_server):
219
- try:
220
- image_id = nova_server.image["id"]
221
- except Exception:
222
- image_id = nova_server.image_id
224
+ if nova_server.image:
225
+ if isinstance(nova_server.image, dict):
226
+ image_id = nova_server.image.get("id")
227
+ else:
228
+ image_id = nova_server.image
229
+ else:
230
+ image_id = None
223
231
  flavor_name = nova_server.flavor.get("original_name", "")
224
232
 
225
233
  try:
@@ -238,7 +246,7 @@ class Server:
238
246
  server = cls(
239
247
  name=nova_server.name,
240
248
  reservation_id=None,
241
- image_name=get_image_name(image_id),
249
+ image_name=get_image_name(image_id) if image_id else None,
242
250
  flavor_name=flavor_name,
243
251
  key_name=nova_server.key_name,
244
252
  network_name=(
@@ -1,6 +1,7 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import List
3
3
 
4
+ import cinderclient
4
5
  import manilaclient
5
6
  import swiftclient
6
7
 
@@ -295,7 +296,7 @@ def get_volume(ref) -> Volume:
295
296
  """
296
297
  try:
297
298
  volume = cinder().volumes.get(ref)
298
- except cinder.exceptions.NotFound:
299
+ except cinderclient.exceptions.NotFound:
299
300
  volumes = list(cinder().volumes.list(search_opts={"name": ref}))
300
301
  if not volumes:
301
302
  raise CHIValueError(f'No volumes found matching name "{ref}"')
@@ -1,4 +1,5 @@
1
1
  import base64
2
+ import logging
2
3
  import os
3
4
  import time
4
5
  from datetime import datetime, timedelta
@@ -10,6 +11,8 @@ from IPython.display import display
10
11
 
11
12
  from chi.exception import ResourceError
12
13
 
14
+ LOG = logging.getLogger(__name__)
15
+
13
16
 
14
17
  def random_base32(n_bytes):
15
18
  rand_bytes = os.urandom(n_bytes)
@@ -60,9 +63,45 @@ class TimerProgressBar:
60
63
  orientation="horizontal",
61
64
  )
62
65
  self.label = widgets.Label()
66
+ self.status_output = widgets.HTML()
67
+ self._log_lines = []
68
+ self._current_status_text = ""
69
+ self._is_displayed = False
63
70
 
64
71
  def display(self):
65
- display(widgets.HBox([self.label, self.progress]))
72
+ display(
73
+ widgets.VBox(
74
+ [
75
+ widgets.HBox([self.label, self.progress]),
76
+ self.status_output,
77
+ ]
78
+ )
79
+ )
80
+ self._is_displayed = True
81
+
82
+ def log(self, msg):
83
+ self._log_lines.append(msg)
84
+ if self._is_displayed:
85
+ self._render()
86
+ else:
87
+ LOG.info(msg)
88
+
89
+ def update_status(self, msg):
90
+ self._current_status_text = msg
91
+ if self._is_displayed:
92
+ self._render()
93
+
94
+ def _render(self):
95
+ from html import escape
96
+
97
+ lines = list(self._log_lines)
98
+ if self._current_status_text:
99
+ lines.append(self._current_status_text)
100
+ inner = "\n".join(escape(line) for line in lines)
101
+ self.status_output.value = (
102
+ '<pre style="max-height:200px;overflow:auto;margin:4px 0;font-family:inherit;">'
103
+ f"{inner}</pre>"
104
+ )
66
105
 
67
106
  def wait(self, callback, expected_timeout, timeout, interval=5):
68
107
  """Wait and update the progress bar.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-chi
3
- Version: 1.2.6
3
+ Version: 1.2.7
4
4
  Summary: Helper library for Chameleon Infrastructure (CHI) testbed
5
5
  Home-page: https://www.chameleoncloud.org
6
6
  Author: University of Chicago
@@ -0,0 +1 @@
1
+ {"git_version": "dfbd927", "is_release": false}
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env python
2
+
3
+ from setuptools import setup
4
+
5
+ setup(setup_requires=["pbr"], pbr=True, version="v1.2.7")
@@ -0,0 +1,275 @@
1
+ # Copyright 2021 University of Chicago
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import io
15
+ import os
16
+ import tarfile
17
+ import tempfile
18
+ from datetime import datetime
19
+
20
+ import pytest
21
+ from zunclient.exceptions import Conflict
22
+
23
+ from chi.container import Container, download, upload
24
+
25
+
26
+ @pytest.fixture()
27
+ def now():
28
+ return datetime(2021, 1, 1, 0, 0, 0, 0)
29
+
30
+
31
+ def test_container_upload_method(mocker):
32
+ # Arrange
33
+ mock_upload = mocker.patch("chi.container.upload")
34
+ container = Container(
35
+ name="test",
36
+ image_ref="image",
37
+ exposed_ports=[],
38
+ )
39
+ container.id = "fake_id"
40
+ source = "/tmp/sourcefile"
41
+ remote_dest = "/container/path"
42
+
43
+ # Act
44
+ container.upload(source, remote_dest)
45
+
46
+ # Assert
47
+ mock_upload.assert_called_once_with("fake_id", source, remote_dest)
48
+
49
+
50
+ def test_container_download_method(mocker):
51
+ # Arrange
52
+ mock_download = mocker.patch("chi.container.download")
53
+ container = Container(
54
+ name="test",
55
+ image_ref="image",
56
+ exposed_ports=[],
57
+ )
58
+ container.id = "fake_id"
59
+ remote_source = "/container/path"
60
+ dest = "/tmp/destfile"
61
+
62
+ # Act
63
+ container.download(remote_source, dest)
64
+
65
+ # Assert
66
+ mock_download.assert_called_once_with("fake_id", remote_source, dest)
67
+
68
+
69
+ def test_upload_creates_tar_and_calls_put_archive(mocker):
70
+ # Patch zun client
71
+ zun_mock = mocker.patch("chi.container.zun")()
72
+ # Create a temporary file to upload
73
+ with tempfile.NamedTemporaryFile() as tmpfile:
74
+ tmpfile.write(b"hello world")
75
+ tmpfile.flush()
76
+
77
+ upload("container_id", tmpfile.name, "/remote/path")
78
+ # Check that put_archive was called
79
+ assert zun_mock.containers.put_archive.call_count == 1
80
+ args = zun_mock.containers.put_archive.call_args[0]
81
+ assert args[0] == "container_id"
82
+ assert args[1] == "/remote/path"
83
+ # The third argument should be a tar archive containing the file
84
+ tar_bytes = args[2]
85
+ tarfileobj = io.BytesIO(tar_bytes)
86
+ with tarfile.open(fileobj=tarfileobj, mode="r") as tar:
87
+ names = tar.getnames()
88
+ assert os.path.basename(tmpfile.name) in names
89
+
90
+
91
+ def test_download_extracts_tar_and_writes_file(mocker):
92
+ # Patch zun client
93
+ zun_mock = mocker.patch("chi.container.zun")()
94
+ # Create a tar archive in memory with a test file
95
+ file_content = b"test content"
96
+ file_name = "testfile.txt"
97
+ tar_bytes_io = io.BytesIO()
98
+ with tarfile.open(fileobj=tar_bytes_io, mode="w") as tar:
99
+ info = tarfile.TarInfo(name=file_name)
100
+ info.size = len(file_content)
101
+ tar.addfile(info, io.BytesIO(file_content))
102
+ tar_bytes = tar_bytes_io.getvalue()
103
+ zun_mock.containers.get_archive.return_value = {"data": tar_bytes}
104
+
105
+ # Use a temporary directory for extraction
106
+ with tempfile.TemporaryDirectory() as tmpdir:
107
+ dest_path = os.path.join(tmpdir, file_name)
108
+
109
+ download("container_id", file_name, tmpdir)
110
+ # Check that the file was extracted
111
+ assert os.path.exists(dest_path)
112
+ with open(dest_path, "rb") as f:
113
+ assert f.read() == file_content
114
+
115
+
116
+ def test_delete_calls_force(mocker):
117
+ destroy_mock = mocker.patch("chi.container.destroy_container")
118
+ container = Container(name="test", image_ref="img")
119
+ container.id = "fake-id"
120
+
121
+ container.delete()
122
+
123
+ destroy_mock.assert_called_once_with("fake-id", force=True)
124
+ assert container.id is None
125
+ assert container._status is None
126
+
127
+
128
+ def test_submit_idempotent_returns_existing_without_create(mocker):
129
+ # idempotent=true, wait=true
130
+ chi_container = Container(name="dup-name", image_ref="img")
131
+ existing_zun_container = mocker.Mock(uuid="existing-uuid", status="Running")
132
+
133
+ mocker.patch("chi.container.get_container", return_value=existing_zun_container)
134
+ create_mock = mocker.patch("chi.container.create_container")
135
+
136
+ submit_result = chi_container.submit(
137
+ idempotent=True, wait_for_active=True, wait_timeout=123, show="text"
138
+ )
139
+ create_mock.assert_not_called()
140
+ existing_zun_container.wait.assert_called_once_with(status="Running", timeout=123)
141
+ existing_zun_container.show.assert_called_once_with(
142
+ type="text", wait_for_active=True
143
+ )
144
+
145
+ # submit returns only on idempotent=true
146
+ assert submit_result is existing_zun_container
147
+
148
+
149
+ def test_submit_idempotent_returns_existing_without_create_no_wait(mocker):
150
+ # idempotent=true, wait=false
151
+ chi_container = Container(name="dup-name", image_ref="img")
152
+ existing_zun_container = mocker.Mock(uuid="existing-uuid", status="Running")
153
+
154
+ mocker.patch("chi.container.get_container", return_value=existing_zun_container)
155
+ create_mock = mocker.patch("chi.container.create_container")
156
+
157
+ submit_result = chi_container.submit(
158
+ idempotent=True, wait_for_active=False, show=None
159
+ )
160
+ create_mock.assert_not_called()
161
+ existing_zun_container.wait.assert_not_called()
162
+ existing_zun_container.show.assert_not_called()
163
+
164
+ # submit returns only on idempotent=true
165
+ assert submit_result is existing_zun_container
166
+
167
+
168
+ def test_submit_duplicate_name_tracks_created_uuid(mocker):
169
+ """Test the case where we re-run submit after a failure.
170
+
171
+ An "old" container alreday already exists, with name = "dup-name".
172
+ If idempotent=false, it should be possible to make a new container with the same name.
173
+ However, name-based lookups will fail with a 409.
174
+ """
175
+
176
+ chi_container = Container(name="dup-name", image_ref="img")
177
+ new_zun_container = mocker.Mock(uuid="new-uuid", status="Running")
178
+
179
+ def _get_side_effect(ref):
180
+ if ref == "dup-name":
181
+ raise Conflict(
182
+ "Multiple containers exist with same name. Please use the container uuid instead."
183
+ )
184
+ if ref == "new-uuid":
185
+ return new_zun_container
186
+ raise AssertionError(ref)
187
+
188
+ mocker.patch("chi.container.create_container", return_value=new_zun_container)
189
+ zun_mock = mocker.patch("chi.container.zun")()
190
+ zun_mock.containers.get.side_effect = _get_side_effect
191
+
192
+ # disable optional behavor from wait_for_active and show
193
+ chi_container.submit(wait_for_active=False, show=None)
194
+ assert chi_container.id == "new-uuid"
195
+
196
+
197
+ # --- Container.wait() status display tests ---
198
+
199
+
200
+ def _make_zun_state(mocker, status, detail=None, reason=None):
201
+ return mocker.Mock(status=status, status_detail=detail, status_reason=reason)
202
+
203
+
204
+ def _setup_wait(mocker, states):
205
+ """Common setup for wait tests: mock zun, ipynb check, and progress bar."""
206
+ container = Container(name="t", image_ref="img")
207
+ container.id = "fake"
208
+
209
+ zun_mock = mocker.patch("chi.container.zun")()
210
+ zun_mock.containers.get.side_effect = states
211
+ mocker.patch("chi.container.context._is_ipynb", return_value=False)
212
+
213
+ pb_cls = mocker.patch("chi.container.util.TimerProgressBar")
214
+ pb = pb_cls.return_value
215
+
216
+ def fake_wait(cb, expected, timeout, interval=5):
217
+ for _ in states:
218
+ if cb():
219
+ return True
220
+ return False
221
+
222
+ pb.wait.side_effect = fake_wait
223
+ return container, pb
224
+
225
+
226
+ def test_wait_logs_status_transitions(mocker):
227
+ """Status changes produce log lines; same status does not."""
228
+ states = [
229
+ _make_zun_state(mocker, "Creating"),
230
+ _make_zun_state(mocker, "Creating"),
231
+ _make_zun_state(mocker, "Running"),
232
+ ]
233
+ container, pb = _setup_wait(mocker, states)
234
+
235
+ container.wait(status="Running")
236
+
237
+ log_calls = [c.args[0] for c in pb.log.call_args_list]
238
+ assert any("Running" in log for log in log_calls)
239
+ assert len(log_calls) == 1 # one transition
240
+
241
+
242
+ def test_wait_accumulates_events_without_duplicates(mocker):
243
+ """Different details accumulate; same detail is not repeated."""
244
+ states = [
245
+ _make_zun_state(mocker, "Creating", detail="Pulling image"),
246
+ _make_zun_state(mocker, "Creating", detail="Pulling image"),
247
+ _make_zun_state(mocker, "Creating", detail="Configuring net"),
248
+ _make_zun_state(mocker, "Running"),
249
+ ]
250
+ container, pb = _setup_wait(mocker, states)
251
+
252
+ captured_updates = []
253
+ pb.update_status.side_effect = lambda msg: captured_updates.append(msg)
254
+
255
+ container.wait(status="Running")
256
+
257
+ creating_updates = [u for u in captured_updates if "Creating" in u]
258
+ last_creating = creating_updates[-1]
259
+ assert "Pulling image" in last_creating
260
+ assert "Configuring net" in last_creating
261
+ assert last_creating.count("Pulling image") == 1
262
+
263
+
264
+ def test_wait_stops_on_error(mocker):
265
+ """Error status stops the wait loop and is logged."""
266
+ states = [
267
+ _make_zun_state(mocker, "Creating"),
268
+ _make_zun_state(mocker, "Error", detail="OOM", reason="Out of memory"),
269
+ ]
270
+ container, pb = _setup_wait(mocker, states)
271
+
272
+ container.wait(status="Running")
273
+
274
+ log_calls = [c.args[0] for c in pb.log.call_args_list]
275
+ assert any("Error" in log for log in log_calls)
@@ -1,7 +0,0 @@
1
- CHANGES
2
- =======
3
-
4
- v1.2.6
5
- ------
6
-
7
- * Kvm improvments (#90)
@@ -1 +0,0 @@
1
- {"git_version": "f691d6a", "is_release": false}
python_chi-1.2.6/setup.py DELETED
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env python
2
-
3
- from setuptools import setup
4
-
5
- setup(setup_requires=["pbr"], pbr=True, version="v1.2.6")
@@ -1,112 +0,0 @@
1
- # Copyright 2021 University of Chicago
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- import io
15
- import os
16
- import tarfile
17
- import tempfile
18
- from datetime import datetime
19
-
20
- import pytest
21
-
22
- from chi.container import Container, download, upload
23
-
24
-
25
- @pytest.fixture()
26
- def now():
27
- return datetime(2021, 1, 1, 0, 0, 0, 0)
28
-
29
-
30
- def test_container_upload_method(mocker):
31
- # Arrange
32
- mock_upload = mocker.patch("chi.container.upload")
33
- container = Container(
34
- name="test",
35
- image_ref="image",
36
- exposed_ports=[],
37
- )
38
- container.id = "fake_id"
39
- source = "/tmp/sourcefile"
40
- remote_dest = "/container/path"
41
-
42
- # Act
43
- container.upload(source, remote_dest)
44
-
45
- # Assert
46
- mock_upload.assert_called_once_with("fake_id", source, remote_dest)
47
-
48
-
49
- def test_container_download_method(mocker):
50
- # Arrange
51
- mock_download = mocker.patch("chi.container.download")
52
- container = Container(
53
- name="test",
54
- image_ref="image",
55
- exposed_ports=[],
56
- )
57
- container.id = "fake_id"
58
- remote_source = "/container/path"
59
- dest = "/tmp/destfile"
60
-
61
- # Act
62
- container.download(remote_source, dest)
63
-
64
- # Assert
65
- mock_download.assert_called_once_with("fake_id", remote_source, dest)
66
-
67
-
68
- def test_upload_creates_tar_and_calls_put_archive(mocker):
69
- # Patch zun client
70
- zun_mock = mocker.patch("chi.container.zun")()
71
- # Create a temporary file to upload
72
- with tempfile.NamedTemporaryFile() as tmpfile:
73
- tmpfile.write(b"hello world")
74
- tmpfile.flush()
75
-
76
- upload("container_id", tmpfile.name, "/remote/path")
77
- # Check that put_archive was called
78
- assert zun_mock.containers.put_archive.call_count == 1
79
- args = zun_mock.containers.put_archive.call_args[0]
80
- assert args[0] == "container_id"
81
- assert args[1] == "/remote/path"
82
- # The third argument should be a tar archive containing the file
83
- tar_bytes = args[2]
84
- tarfileobj = io.BytesIO(tar_bytes)
85
- with tarfile.open(fileobj=tarfileobj, mode="r") as tar:
86
- names = tar.getnames()
87
- assert os.path.basename(tmpfile.name) in names
88
-
89
-
90
- def test_download_extracts_tar_and_writes_file(mocker):
91
- # Patch zun client
92
- zun_mock = mocker.patch("chi.container.zun")()
93
- # Create a tar archive in memory with a test file
94
- file_content = b"test content"
95
- file_name = "testfile.txt"
96
- tar_bytes_io = io.BytesIO()
97
- with tarfile.open(fileobj=tar_bytes_io, mode="w") as tar:
98
- info = tarfile.TarInfo(name=file_name)
99
- info.size = len(file_content)
100
- tar.addfile(info, io.BytesIO(file_content))
101
- tar_bytes = tar_bytes_io.getvalue()
102
- zun_mock.containers.get_archive.return_value = {"data": tar_bytes}
103
-
104
- # Use a temporary directory for extraction
105
- with tempfile.TemporaryDirectory() as tmpdir:
106
- dest_path = os.path.join(tmpdir, file_name)
107
-
108
- download("container_id", file_name, tmpdir)
109
- # Check that the file was extracted
110
- assert os.path.exists(dest_path)
111
- with open(dest_path, "rb") as f:
112
- assert f.read() == file_content
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