ansible-core 2.15.0__py3-none-any.whl → 2.15.2__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.

Potentially problematic release.


This version of ansible-core might be problematic. Click here for more details.

Files changed (61) hide show
  1. ansible/config/manager.py +1 -1
  2. ansible/galaxy/collection/__init__.py +17 -30
  3. ansible/galaxy/collection/concrete_artifact_manager.py +12 -6
  4. ansible/galaxy/dependency_resolution/dataclasses.py +6 -3
  5. ansible/module_utils/ansible_release.py +1 -1
  6. ansible/modules/apt_key.py +8 -5
  7. ansible/modules/apt_repository.py +2 -0
  8. ansible/modules/deb822_repository.py +1 -1
  9. ansible/modules/dnf5.py +8 -8
  10. ansible/modules/find.py +3 -0
  11. ansible/modules/uri.py +9 -1
  12. ansible/modules/validate_argument_spec.py +1 -1
  13. ansible/plugins/action/template.py +26 -15
  14. ansible/plugins/connection/paramiko_ssh.py +8 -0
  15. ansible/plugins/connection/psrp.py +3 -3
  16. ansible/plugins/connection/ssh.py +19 -2
  17. ansible/plugins/filter/comment.yml +1 -1
  18. ansible/plugins/filter/split.yml +1 -1
  19. ansible/plugins/filter/to_yaml.yml +1 -1
  20. ansible/plugins/lookup/template.py +11 -6
  21. ansible/plugins/strategy/__init__.py +20 -12
  22. ansible/plugins/test/change.yml +1 -1
  23. ansible/plugins/test/changed.yml +1 -1
  24. ansible/plugins/test/reachable.yml +1 -1
  25. ansible/plugins/test/succeeded.yml +1 -1
  26. ansible/plugins/test/success.yml +1 -1
  27. ansible/plugins/test/successful.yml +1 -1
  28. ansible/plugins/test/unreachable.yml +1 -1
  29. ansible/release.py +1 -1
  30. ansible/template/__init__.py +42 -28
  31. ansible/utils/_junit_xml.py +5 -1
  32. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/METADATA +1 -1
  33. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/RECORD +61 -61
  34. ansible_test/_data/completion/remote.txt +2 -1
  35. ansible_test/_internal/__init__.py +11 -0
  36. ansible_test/_internal/cli/commands/env.py +1 -1
  37. ansible_test/_internal/commands/env/__init__.py +14 -17
  38. ansible_test/_internal/commands/integration/__init__.py +1 -1
  39. ansible_test/_internal/commands/integration/cloud/__init__.py +3 -3
  40. ansible_test/_internal/commands/sanity/pylint.py +4 -4
  41. ansible_test/_internal/commands/sanity/validate_modules.py +2 -2
  42. ansible_test/_internal/containers.py +2 -2
  43. ansible_test/_internal/coverage_util.py +2 -2
  44. ansible_test/_internal/data.py +2 -7
  45. ansible_test/_internal/git.py +1 -1
  46. ansible_test/_internal/junit_xml.py +5 -1
  47. ansible_test/_internal/payload.py +2 -2
  48. ansible_test/_internal/provider/layout/__init__.py +1 -1
  49. ansible_test/_internal/provider/layout/ansible.py +15 -0
  50. ansible_test/_internal/provider/layout/collection.py +9 -1
  51. ansible_test/_internal/provisioning.py +5 -2
  52. ansible_test/_internal/pypi_proxy.py +4 -4
  53. ansible_test/_internal/test.py +3 -5
  54. ansible_test/_internal/timeout.py +56 -19
  55. ansible_test/_internal/util.py +4 -0
  56. ansible_test/_internal/util_common.py +38 -6
  57. {ansible_core-2.15.0.data → ansible_core-2.15.2.data}/scripts/ansible-test +0 -0
  58. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/COPYING +0 -0
  59. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/WHEEL +0 -0
  60. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/entry_points.txt +0 -0
  61. {ansible_core-2.15.0.dist-info → ansible_core-2.15.2.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,6 @@ from .util import (
10
10
  ApplicationError,
11
11
  import_plugins,
12
12
  is_subdir,
13
- is_valid_identifier,
14
13
  ANSIBLE_LIB_ROOT,
15
14
  ANSIBLE_TEST_ROOT,
16
15
  ANSIBLE_SOURCE_ROOT,
@@ -219,12 +218,8 @@ class DataContext:
219
218
  elif 'ansible_collections' not in cwd.split(os.path.sep):
220
219
  blocks.append('No "ansible_collections" parent directory was found.')
221
220
 
222
- if self.content.collection:
223
- if not is_valid_identifier(self.content.collection.namespace):
224
- blocks.append(f'The namespace "{self.content.collection.namespace}" is an invalid identifier or a reserved keyword.')
225
-
226
- if not is_valid_identifier(self.content.collection.name):
227
- blocks.append(f'The name "{self.content.collection.name}" is an invalid identifier or a reserved keyword.')
221
+ if isinstance(self.content.unsupported, list):
222
+ blocks.extend(self.content.unsupported)
228
223
 
229
224
  message = '\n'.join(blocks)
230
225
 
@@ -77,7 +77,7 @@ class Git:
77
77
 
78
78
  def get_branch_fork_point(self, branch: str) -> str:
79
79
  """Return a reference to the point at which the given branch was forked."""
80
- cmd = ['merge-base', '--fork-point', branch]
80
+ cmd = ['merge-base', branch, 'HEAD']
81
81
  return self.run_git(cmd).strip()
82
82
 
83
83
  def is_valid_ref(self, ref: str) -> bool:
@@ -144,6 +144,10 @@ class TestSuite:
144
144
  system_out: str | None = None
145
145
  system_err: str | None = None
146
146
 
147
+ def __post_init__(self):
148
+ if self.timestamp and self.timestamp.tzinfo != datetime.timezone.utc:
149
+ raise ValueError(f'timestamp.tzinfo must be {datetime.timezone.utc!r}')
150
+
147
151
  @property
148
152
  def disabled(self) -> int:
149
153
  """The number of disabled test cases."""
@@ -187,7 +191,7 @@ class TestSuite:
187
191
  skipped=self.skipped,
188
192
  tests=self.tests,
189
193
  time=self.time,
190
- timestamp=self.timestamp.isoformat(timespec='seconds') if self.timestamp else None,
194
+ timestamp=self.timestamp.replace(tzinfo=None).isoformat(timespec='seconds') if self.timestamp else None,
191
195
  )
192
196
 
193
197
  def get_xml_element(self) -> ET.Element:
@@ -1,7 +1,6 @@
1
1
  """Payload management for sending Ansible files and test content to other systems (VMs, containers)."""
2
2
  from __future__ import annotations
3
3
 
4
- import atexit
5
4
  import os
6
5
  import stat
7
6
  import tarfile
@@ -32,6 +31,7 @@ from .data import (
32
31
 
33
32
  from .util_common import (
34
33
  CommonConfig,
34
+ ExitHandler,
35
35
  )
36
36
 
37
37
  # improve performance by disabling uid/gid lookups
@@ -192,7 +192,7 @@ def create_temporary_bin_files(args: CommonConfig) -> tuple[tuple[str, str], ...
192
192
  temp_path = '/tmp/ansible-tmp-bin'
193
193
  else:
194
194
  temp_path = tempfile.mkdtemp(prefix='ansible', suffix='bin')
195
- atexit.register(remove_tree, temp_path)
195
+ ExitHandler.register(remove_tree, temp_path)
196
196
 
197
197
  for name, dest in ANSIBLE_BIN_SYMLINK_MAP.items():
198
198
  path = os.path.join(temp_path, name)
@@ -95,7 +95,7 @@ class ContentLayout(Layout):
95
95
  unit_module_path: str,
96
96
  unit_module_utils_path: str,
97
97
  unit_messages: t.Optional[LayoutMessages],
98
- unsupported: bool = False,
98
+ unsupported: bool | list[str] = False,
99
99
  ) -> None:
100
100
  super().__init__(root, paths)
101
101
 
@@ -8,6 +8,11 @@ from . import (
8
8
  LayoutProvider,
9
9
  )
10
10
 
11
+ from ...util import (
12
+ ANSIBLE_SOURCE_ROOT,
13
+ ANSIBLE_TEST_ROOT,
14
+ )
15
+
11
16
 
12
17
  class AnsibleLayout(LayoutProvider):
13
18
  """Layout provider for Ansible source."""
@@ -26,6 +31,15 @@ class AnsibleLayout(LayoutProvider):
26
31
  module_utils='lib/ansible/module_utils',
27
32
  )
28
33
 
34
+ errors: list[str] = []
35
+
36
+ if root != ANSIBLE_SOURCE_ROOT:
37
+ errors.extend((
38
+ f'Cannot test "{root}" with ansible-test from "{ANSIBLE_TEST_ROOT}".',
39
+ '',
40
+ f'Did you intend to run "{root}/bin/ansible-test" instead?',
41
+ ))
42
+
29
43
  return ContentLayout(
30
44
  root,
31
45
  paths,
@@ -43,4 +57,5 @@ class AnsibleLayout(LayoutProvider):
43
57
  unit_module_path='test/units/modules',
44
58
  unit_module_utils_path='test/units/module_utils',
45
59
  unit_messages=None,
60
+ unsupported=errors,
46
61
  )
@@ -53,6 +53,14 @@ class CollectionLayout(LayoutProvider):
53
53
  integration_targets_path = self.__check_integration_path(paths, integration_messages)
54
54
  self.__check_unit_path(paths, unit_messages)
55
55
 
56
+ errors: list[str] = []
57
+
58
+ if not is_valid_identifier(collection_namespace):
59
+ errors.append(f'The namespace "{collection_namespace}" is an invalid identifier or a reserved keyword.')
60
+
61
+ if not is_valid_identifier(collection_name):
62
+ errors.append(f'The name "{collection_name}" is an invalid identifier or a reserved keyword.')
63
+
56
64
  return ContentLayout(
57
65
  root,
58
66
  paths,
@@ -74,7 +82,7 @@ class CollectionLayout(LayoutProvider):
74
82
  unit_module_path='tests/unit/plugins/modules',
75
83
  unit_module_utils_path='tests/unit/plugins/module_utils',
76
84
  unit_messages=unit_messages,
77
- unsupported=not (is_valid_identifier(collection_namespace) and is_valid_identifier(collection_name)),
85
+ unsupported=errors,
78
86
  )
79
87
 
80
88
  @staticmethod
@@ -1,7 +1,6 @@
1
1
  """Provision hosts for running tests."""
2
2
  from __future__ import annotations
3
3
 
4
- import atexit
5
4
  import collections.abc as c
6
5
  import dataclasses
7
6
  import functools
@@ -27,6 +26,10 @@ from .util import (
27
26
  type_guard,
28
27
  )
29
28
 
29
+ from .util_common import (
30
+ ExitHandler,
31
+ )
32
+
30
33
  from .thread import (
31
34
  WrappedThread,
32
35
  )
@@ -124,7 +127,7 @@ def prepare_profiles(
124
127
 
125
128
  raise PrimeContainers()
126
129
 
127
- atexit.register(functools.partial(cleanup_profiles, host_state))
130
+ ExitHandler.register(functools.partial(cleanup_profiles, host_state))
128
131
 
129
132
  def provision(profile: HostProfile) -> None:
130
133
  """Provision the given profile."""
@@ -1,7 +1,6 @@
1
1
  """PyPI proxy management."""
2
2
  from __future__ import annotations
3
3
 
4
- import atexit
5
4
  import os
6
5
  import urllib.parse
7
6
 
@@ -23,6 +22,7 @@ from .util import (
23
22
  )
24
23
 
25
24
  from .util_common import (
25
+ ExitHandler,
26
26
  process_scoped_temporary_file,
27
27
  )
28
28
 
@@ -128,7 +128,7 @@ def configure_target_pypi_proxy(args: EnvironmentConfig, profile: HostProfile, p
128
128
  run_playbook(args, inventory_path, 'pypi_proxy_prepare.yml', capture=True, variables=dict(
129
129
  pypi_endpoint=pypi_endpoint, pypi_hostname=pypi_hostname, force=force))
130
130
 
131
- atexit.register(cleanup_pypi_proxy)
131
+ ExitHandler.register(cleanup_pypi_proxy)
132
132
 
133
133
 
134
134
  def configure_pypi_proxy_pip(args: EnvironmentConfig, profile: HostProfile, pypi_endpoint: str, pypi_hostname: str) -> None:
@@ -153,7 +153,7 @@ trusted-host = {1}
153
153
 
154
154
  if not args.explain:
155
155
  write_text_file(pip_conf_path, pip_conf, True)
156
- atexit.register(pip_conf_cleanup)
156
+ ExitHandler.register(pip_conf_cleanup)
157
157
 
158
158
 
159
159
  def configure_pypi_proxy_easy_install(args: EnvironmentConfig, profile: HostProfile, pypi_endpoint: str) -> None:
@@ -177,4 +177,4 @@ index_url = {0}
177
177
 
178
178
  if not args.explain:
179
179
  write_text_file(pydistutils_cfg_path, pydistutils_cfg, True)
180
- atexit.register(pydistutils_cfg_cleanup)
180
+ ExitHandler.register(pydistutils_cfg_cleanup)
@@ -114,7 +114,7 @@ class TestResult:
114
114
  junit_xml.TestSuite(
115
115
  name='ansible-test',
116
116
  cases=[test_case],
117
- timestamp=datetime.datetime.utcnow(),
117
+ timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
118
118
  ),
119
119
  ],
120
120
  )
@@ -130,7 +130,7 @@ class TestResult:
130
130
  class TestTimeout(TestResult):
131
131
  """Test timeout."""
132
132
 
133
- def __init__(self, timeout_duration: int) -> None:
133
+ def __init__(self, timeout_duration: int | float) -> None:
134
134
  super().__init__(command='timeout', test='')
135
135
 
136
136
  self.timeout_duration = timeout_duration
@@ -153,13 +153,11 @@ One or more of the following situations may be responsible:
153
153
 
154
154
  output += '\n\nConsult the console log for additional details on where the timeout occurred.'
155
155
 
156
- timestamp = datetime.datetime.utcnow()
157
-
158
156
  suites = junit_xml.TestSuites(
159
157
  suites=[
160
158
  junit_xml.TestSuite(
161
159
  name='ansible-test',
162
- timestamp=timestamp,
160
+ timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
163
161
  cases=[
164
162
  junit_xml.TestCase(
165
163
  name='timeout',
@@ -1,6 +1,7 @@
1
1
  """Timeout management for tests."""
2
2
  from __future__ import annotations
3
3
 
4
+ import dataclasses
4
5
  import datetime
5
6
  import functools
6
7
  import os
@@ -19,7 +20,7 @@ from .config import (
19
20
 
20
21
  from .util import (
21
22
  display,
22
- ApplicationError,
23
+ TimeoutExpiredError,
23
24
  )
24
25
 
25
26
  from .thread import (
@@ -35,15 +36,56 @@ from .test import (
35
36
  )
36
37
 
37
38
 
38
- def get_timeout() -> t.Optional[dict[str, t.Any]]:
39
- """Return details about the currently set timeout, if any, otherwise return None."""
40
- if not os.path.exists(TIMEOUT_PATH):
41
- return None
39
+ @dataclasses.dataclass(frozen=True)
40
+ class TimeoutDetail:
41
+ """Details required to enforce a timeout on test execution."""
42
+
43
+ _DEADLINE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' # format used to maintain backwards compatibility with previous versions of ansible-test
44
+
45
+ deadline: datetime.datetime
46
+ duration: int | float # minutes
47
+
48
+ @property
49
+ def remaining(self) -> datetime.timedelta:
50
+ """The amount of time remaining before the timeout occurs. If the timeout has passed, this will be a negative duration."""
51
+ return self.deadline - datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0)
52
+
53
+ def to_dict(self) -> dict[str, t.Any]:
54
+ """Return timeout details as a dictionary suitable for JSON serialization."""
55
+ return dict(
56
+ deadline=self.deadline.strftime(self._DEADLINE_FORMAT),
57
+ duration=self.duration,
58
+ )
42
59
 
43
- data = read_json_file(TIMEOUT_PATH)
44
- data['deadline'] = datetime.datetime.strptime(data['deadline'], '%Y-%m-%dT%H:%M:%SZ')
60
+ @staticmethod
61
+ def from_dict(value: dict[str, t.Any]) -> TimeoutDetail:
62
+ """Return a TimeoutDetail instance using the value previously returned by to_dict."""
63
+ return TimeoutDetail(
64
+ deadline=datetime.datetime.strptime(value['deadline'], TimeoutDetail._DEADLINE_FORMAT).replace(tzinfo=datetime.timezone.utc),
65
+ duration=value['duration'],
66
+ )
45
67
 
46
- return data
68
+ @staticmethod
69
+ def create(duration: int | float) -> TimeoutDetail | None:
70
+ """Return a new TimeoutDetail instance for the specified duration (in minutes), or None if the duration is zero."""
71
+ if not duration:
72
+ return None
73
+
74
+ if duration == int(duration):
75
+ duration = int(duration)
76
+
77
+ return TimeoutDetail(
78
+ deadline=datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + datetime.timedelta(seconds=int(duration * 60)),
79
+ duration=duration,
80
+ )
81
+
82
+
83
+ def get_timeout() -> TimeoutDetail | None:
84
+ """Return details about the currently set timeout, if any, otherwise return None."""
85
+ try:
86
+ return TimeoutDetail.from_dict(read_json_file(TIMEOUT_PATH))
87
+ except FileNotFoundError:
88
+ return None
47
89
 
48
90
 
49
91
  def configure_timeout(args: CommonConfig) -> None:
@@ -59,27 +101,22 @@ def configure_test_timeout(args: TestConfig) -> None:
59
101
  if not timeout:
60
102
  return
61
103
 
62
- timeout_start = datetime.datetime.utcnow()
63
- timeout_duration = timeout['duration']
64
- timeout_deadline = timeout['deadline']
65
- timeout_remaining = timeout_deadline - timeout_start
104
+ timeout_remaining = timeout.remaining
66
105
 
67
- test_timeout = TestTimeout(timeout_duration)
106
+ test_timeout = TestTimeout(timeout.duration)
68
107
 
69
108
  if timeout_remaining <= datetime.timedelta():
70
109
  test_timeout.write(args)
71
110
 
72
- raise ApplicationError('The %d minute test timeout expired %s ago at %s.' % (
73
- timeout_duration, timeout_remaining * -1, timeout_deadline))
111
+ raise TimeoutExpiredError(f'The {timeout.duration} minute test timeout expired {timeout_remaining * -1} ago at {timeout.deadline}.')
74
112
 
75
- display.info('The %d minute test timeout expires in %s at %s.' % (
76
- timeout_duration, timeout_remaining, timeout_deadline), verbosity=1)
113
+ display.info(f'The {timeout.duration} minute test timeout expires in {timeout_remaining} at {timeout.deadline}.', verbosity=1)
77
114
 
78
115
  def timeout_handler(_dummy1: t.Any, _dummy2: t.Any) -> None:
79
116
  """Runs when SIGUSR1 is received."""
80
117
  test_timeout.write(args)
81
118
 
82
- raise ApplicationError('Tests aborted after exceeding the %d minute time limit.' % timeout_duration)
119
+ raise TimeoutExpiredError(f'Tests aborted after exceeding the {timeout.duration} minute time limit.')
83
120
 
84
121
  def timeout_waiter(timeout_seconds: int) -> None:
85
122
  """Background thread which will kill the current process if the timeout elapses."""
@@ -88,6 +125,6 @@ def configure_test_timeout(args: TestConfig) -> None:
88
125
 
89
126
  signal.signal(signal.SIGUSR1, timeout_handler)
90
127
 
91
- instance = WrappedThread(functools.partial(timeout_waiter, timeout_remaining.seconds))
128
+ instance = WrappedThread(functools.partial(timeout_waiter, timeout_remaining.total_seconds()))
92
129
  instance.daemon = True
93
130
  instance.start()
@@ -920,6 +920,10 @@ class ApplicationWarning(Exception):
920
920
  """General application warning which interrupts normal program flow."""
921
921
 
922
922
 
923
+ class TimeoutExpiredError(SystemExit):
924
+ """Error raised when the test timeout has been reached or exceeded."""
925
+
926
+
923
927
  class SubprocessError(ApplicationError):
924
928
  """Error resulting from failed subprocess execution."""
925
929
 
@@ -1,7 +1,6 @@
1
1
  """Common utility code that depends on CommonConfig."""
2
2
  from __future__ import annotations
3
3
 
4
- import atexit
5
4
  import collections.abc as c
6
5
  import contextlib
7
6
  import json
@@ -64,6 +63,39 @@ from .host_configs import (
64
63
  CHECK_YAML_VERSIONS: dict[str, t.Any] = {}
65
64
 
66
65
 
66
+ class ExitHandler:
67
+ """Simple exit handler implementation."""
68
+ _callbacks: list[tuple[t.Callable, tuple[t.Any, ...], dict[str, t.Any]]] = []
69
+
70
+ @staticmethod
71
+ def register(func: t.Callable, *args, **kwargs) -> None:
72
+ """Register the given function and args as a callback to execute during program termination."""
73
+ ExitHandler._callbacks.append((func, args, kwargs))
74
+
75
+ @staticmethod
76
+ @contextlib.contextmanager
77
+ def context() -> t.Generator[None, None, None]:
78
+ """Run all registered handlers when the context is exited."""
79
+ last_exception: BaseException | None = None
80
+
81
+ try:
82
+ yield
83
+ finally:
84
+ queue = list(ExitHandler._callbacks)
85
+
86
+ while queue:
87
+ func, args, kwargs = queue.pop()
88
+
89
+ try:
90
+ func(*args, **kwargs)
91
+ except BaseException as ex: # pylint: disable=broad-exception-caught
92
+ last_exception = ex
93
+ display.fatal(f'Exit handler failed: {ex}')
94
+
95
+ if last_exception:
96
+ raise last_exception
97
+
98
+
67
99
  class ShellScriptTemplate:
68
100
  """A simple substitution template for shell scripts."""
69
101
 
@@ -211,7 +243,7 @@ def process_scoped_temporary_file(args: CommonConfig, prefix: t.Optional[str] =
211
243
  else:
212
244
  temp_fd, path = tempfile.mkstemp(prefix=prefix, suffix=suffix)
213
245
  os.close(temp_fd)
214
- atexit.register(lambda: os.remove(path))
246
+ ExitHandler.register(lambda: os.remove(path))
215
247
 
216
248
  return path
217
249
 
@@ -222,7 +254,7 @@ def process_scoped_temporary_directory(args: CommonConfig, prefix: t.Optional[st
222
254
  path = os.path.join(tempfile.gettempdir(), f'{prefix or tempfile.gettempprefix()}{generate_name()}{suffix or ""}')
223
255
  else:
224
256
  path = tempfile.mkdtemp(prefix=prefix, suffix=suffix)
225
- atexit.register(lambda: remove_tree(path))
257
+ ExitHandler.register(lambda: remove_tree(path))
226
258
 
227
259
  return path
228
260
 
@@ -296,7 +328,7 @@ def get_injector_path() -> str:
296
328
  """Remove the temporary injector directory."""
297
329
  remove_tree(injector_path)
298
330
 
299
- atexit.register(cleanup_injector)
331
+ ExitHandler.register(cleanup_injector)
300
332
 
301
333
  return injector_path
302
334
 
@@ -354,7 +386,7 @@ def get_python_path(interpreter: str) -> str:
354
386
  verified_chmod(python_path, MODE_DIRECTORY)
355
387
 
356
388
  if not PYTHON_PATHS:
357
- atexit.register(cleanup_python_paths)
389
+ ExitHandler.register(cleanup_python_paths)
358
390
 
359
391
  PYTHON_PATHS[interpreter] = python_path
360
392
 
@@ -364,7 +396,7 @@ def get_python_path(interpreter: str) -> str:
364
396
  def create_temp_dir(prefix: t.Optional[str] = None, suffix: t.Optional[str] = None, base_dir: t.Optional[str] = None) -> str:
365
397
  """Create a temporary directory that persists until the current process exits."""
366
398
  temp_path = tempfile.mkdtemp(prefix=prefix or 'tmp', suffix=suffix or '', dir=base_dir)
367
- atexit.register(remove_tree, temp_path)
399
+ ExitHandler.register(remove_tree, temp_path)
368
400
  return temp_path
369
401
 
370
402