codeplain 0.3.9__py3-none-any.whl → 0.3.10.dev2__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.
plain_modules.py CHANGED
@@ -2,15 +2,24 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import shutil
5
+ import tempfile
6
+ import zipfile
5
7
  from functools import cached_property
6
8
 
7
- from plain2code_exceptions import GitNotInstalledError, MissingPreviousFunctionalitiesError, ModuleDoesNotExistError
9
+ from plain2code_exceptions import (
10
+ GitNotInstalledError,
11
+ InvalidModuleArchiveError,
12
+ MissingPreviousFunctionalitiesError,
13
+ ModuleDoesNotExistError,
14
+ )
8
15
 
9
16
  try:
17
+ from git import Repo
10
18
  from git.exc import NoSuchPathError
11
19
  except ImportError:
12
20
  raise GitNotInstalledError("git is not installed. Please install git and try again.")
13
21
 
22
+ import file_utils
14
23
  import git_utils
15
24
  import metadata_utils
16
25
  import plain_file
@@ -28,6 +37,11 @@ CODEPLAIN_METADATA_FOLDER = ".codeplain"
28
37
  MODULE_CODE_SUBFOLDER = "code"
29
38
  MODULE_TESTS_SUBFOLDER = "tests"
30
39
 
40
+ # A module's build output may be shipped as a single zip archive named
41
+ # "<module>.module" instead of an unpacked "<module>/" folder. See PlainModule.materialize
42
+ # (read/consume) and PlainModule.ensure_module_unpacked (unpack-on-change).
43
+ MODULE_ARCHIVE_EXTENSION = ".module"
44
+
31
45
 
32
46
  def get_module_code_folder(modules_base_folder: str, module_name: str) -> str:
33
47
  return os.path.join(modules_base_folder, module_name, MODULE_CODE_SUBFOLDER)
@@ -37,6 +51,81 @@ def get_module_tests_folder(modules_base_folder: str, module_name: str) -> str:
37
51
  return os.path.join(modules_base_folder, module_name, MODULE_TESTS_SUBFOLDER)
38
52
 
39
53
 
54
+ def _validate_module_repo(repo_path: str, subfolder: str, archive_path: str) -> None:
55
+ """Validate one extracted git repo (code/ or tests/) inside a module archive."""
56
+ if not os.path.isdir(os.path.join(repo_path, ".git")):
57
+ raise InvalidModuleArchiveError(
58
+ f"Module archive '{archive_path}' has no git repository in '{subfolder}/' "
59
+ "(the archive must include the '.git' directory)."
60
+ )
61
+ try:
62
+ repo = Repo(repo_path)
63
+ if repo.bare or repo.head.is_detached:
64
+ raise InvalidModuleArchiveError(
65
+ f"The git repository in '{subfolder}/' of module archive '{archive_path}' "
66
+ "must be a working tree checked out on a branch."
67
+ )
68
+ except InvalidModuleArchiveError:
69
+ raise
70
+ except Exception as e:
71
+ raise InvalidModuleArchiveError(
72
+ f"The git repository in '{subfolder}/' of module archive '{archive_path}' is invalid: {e}"
73
+ ) from e
74
+
75
+
76
+ def _validate_module_tree(root: str, archive_path: str) -> None:
77
+ """Verify an extracted module tree. ``code/`` is required. ``tests/`` is optional: a module
78
+ rendered without a conformance-tests script has no tests folder, so its archive has none either.
79
+ When ``tests/`` is present it must be a valid git repo."""
80
+ code_path = os.path.join(root, MODULE_CODE_SUBFOLDER)
81
+ if not os.path.isdir(code_path):
82
+ raise InvalidModuleArchiveError(
83
+ f"Module archive '{archive_path}' is missing the '{MODULE_CODE_SUBFOLDER}/' folder at its root. "
84
+ "The archive must contain the module folder's contents (code/, optionally tests/, ...) at its "
85
+ "root, not nested under a top-level directory."
86
+ )
87
+ _validate_module_repo(code_path, MODULE_CODE_SUBFOLDER, archive_path)
88
+
89
+ tests_path = os.path.join(root, MODULE_TESTS_SUBFOLDER)
90
+ if os.path.isdir(tests_path):
91
+ _validate_module_repo(tests_path, MODULE_TESTS_SUBFOLDER, archive_path)
92
+
93
+
94
+ def _extract_module_archive(archive_path: str, dest: str) -> None:
95
+ """Extract a "<module>.module" zip into dest and validate the module layout.
96
+
97
+ Guards against zip-slip, restores unix mode bits (zipfile drops them), and validates
98
+ that dest contains valid code/ and tests/ git repositories. Raises
99
+ InvalidModuleArchiveError on any problem.
100
+ """
101
+ if not zipfile.is_zipfile(archive_path):
102
+ raise InvalidModuleArchiveError(f"Module archive '{archive_path}' is not a valid zip file.")
103
+
104
+ os.makedirs(dest, exist_ok=True)
105
+ dest_root = os.path.realpath(dest)
106
+
107
+ try:
108
+ with zipfile.ZipFile(archive_path) as zf:
109
+ for member in zf.namelist():
110
+ target = os.path.realpath(os.path.join(dest, member))
111
+ if target != dest_root and not target.startswith(dest_root + os.sep):
112
+ raise InvalidModuleArchiveError(
113
+ f"Module archive '{archive_path}' contains an unsafe path: {member}"
114
+ )
115
+ zf.extractall(dest)
116
+ for info in zf.infolist():
117
+ mode = (info.external_attr >> 16) & 0o777
118
+ if not mode:
119
+ continue
120
+ member_path = os.path.join(dest, info.filename)
121
+ if os.path.exists(member_path) and not os.path.islink(member_path):
122
+ os.chmod(member_path, mode)
123
+ except zipfile.BadZipFile as e:
124
+ raise InvalidModuleArchiveError(f"Module archive '{archive_path}' is corrupt: {e}") from e
125
+
126
+ _validate_module_tree(dest, archive_path)
127
+
128
+
40
129
  def _strip_functional_requirements(plain_source_tree: dict) -> dict:
41
130
  stripped = {k: v for k, v in plain_source_tree.items() if k != plain_spec.FUNCTIONAL_REQUIREMENTS}
42
131
  if "sections" in stripped:
@@ -49,6 +138,10 @@ class PlainModule:
49
138
  self.filename = filename
50
139
  self.build_folder = build_folder
51
140
  self.template_dirs = template_dirs
141
+ # When the module exists only as a "<module>.module" archive, these hold the
142
+ # scratch extraction used for read-only consumption. See materialize().
143
+ self._resolved_module_folder: str | None = None
144
+ self._scratch_dir: str | None = None
52
145
  module_name, plain_source, required_modules_names = plain_file.plain_file_parser(
53
146
  self.filename, self.template_dirs
54
147
  )
@@ -81,16 +174,28 @@ class PlainModule:
81
174
  return all_required_modules
82
175
 
83
176
  @property
84
- def module_folder(self):
177
+ def _default_module_folder(self) -> str:
85
178
  return os.path.join(self.build_folder, self.module_name)
86
179
 
180
+ @property
181
+ def module_folder(self):
182
+ # When the module was materialized from a "<module>.module" archive, all subpaths
183
+ # resolve against the scratch extraction; otherwise against the real folder.
184
+ if self._resolved_module_folder is not None:
185
+ return self._resolved_module_folder
186
+ return self._default_module_folder
187
+
188
+ @property
189
+ def module_archive_path(self) -> str:
190
+ return self._default_module_folder + MODULE_ARCHIVE_EXTENSION
191
+
87
192
  @property
88
193
  def module_conformance_tests_folder(self):
89
- return get_module_tests_folder(self.build_folder, self.module_name)
194
+ return os.path.join(self.module_folder, MODULE_TESTS_SUBFOLDER)
90
195
 
91
196
  @property
92
197
  def module_build_folder(self):
93
- return get_module_code_folder(self.build_folder, self.module_name)
198
+ return os.path.join(self.module_folder, MODULE_CODE_SUBFOLDER)
94
199
 
95
200
  @property
96
201
  def module_memory_folder(self):
@@ -99,6 +204,84 @@ class PlainModule:
99
204
  def get_codeplain_folder(self):
100
205
  return os.path.join(self.module_folder, CODEPLAIN_METADATA_FOLDER)
101
206
 
207
+ def has_module_archive(self) -> bool:
208
+ return os.path.isfile(self.module_archive_path)
209
+
210
+ def is_archived_only(self) -> bool:
211
+ return not os.path.isdir(self._default_module_folder) and self.has_module_archive()
212
+
213
+ def archive_has_conformance_tests(self) -> bool:
214
+ """True if the "<module>.module" archive contains a tests/ folder. A module rendered without
215
+ a conformance-tests script has no tests/, so its archive has none either."""
216
+ if not self.has_module_archive() or not zipfile.is_zipfile(self.module_archive_path):
217
+ return False
218
+ prefix = MODULE_TESTS_SUBFOLDER + "/"
219
+ with zipfile.ZipFile(self.module_archive_path) as zf:
220
+ return any(name.startswith(prefix) for name in zf.namelist())
221
+
222
+ def materialize(self) -> None:
223
+ """Make an archive-only module readable without populating plain_modules/<module>/.
224
+
225
+ If the real folder is absent but a "<module>.module" archive exists, extract it to a
226
+ scratch directory and point this module's paths there. No-op if the real folder exists
227
+ or the module is already materialized. The archive file is preserved.
228
+ """
229
+ if self._resolved_module_folder is not None:
230
+ return
231
+ if os.path.isdir(self._default_module_folder):
232
+ return
233
+ if not self.has_module_archive():
234
+ return
235
+
236
+ scratch_dir = tempfile.mkdtemp(prefix=f"codeplain-module-{self.module_name}-")
237
+ try:
238
+ _extract_module_archive(self.module_archive_path, scratch_dir)
239
+ except BaseException:
240
+ shutil.rmtree(scratch_dir, ignore_errors=True)
241
+ raise
242
+ self._scratch_dir = scratch_dir
243
+ self._resolved_module_folder = scratch_dir
244
+
245
+ def ensure_module_unpacked(self) -> None:
246
+ """Unpack an archive-only module into the real plain_modules/<module>/ in place.
247
+
248
+ Called before a module is (re)rendered. Idempotent. If the real folder already exists,
249
+ only a stray archive is removed. Extraction is atomic (extract to a temp sibling under
250
+ the build folder, then os.replace), and the archive is deleted only after success.
251
+ """
252
+ if os.path.isdir(self._default_module_folder):
253
+ if self.has_module_archive():
254
+ os.remove(self.module_archive_path)
255
+ self._reset_scratch()
256
+ self._resolved_module_folder = None
257
+ return
258
+
259
+ if not self.has_module_archive():
260
+ return
261
+
262
+ os.makedirs(self.build_folder, exist_ok=True)
263
+ staging_dir = tempfile.mkdtemp(prefix=f".{self.module_name}-unpacking-", dir=self.build_folder)
264
+ try:
265
+ _extract_module_archive(self.module_archive_path, staging_dir)
266
+ os.replace(staging_dir, self._default_module_folder)
267
+ except BaseException:
268
+ shutil.rmtree(staging_dir, ignore_errors=True)
269
+ raise
270
+
271
+ os.remove(self.module_archive_path)
272
+ self._reset_scratch()
273
+ self._resolved_module_folder = None
274
+
275
+ def _reset_scratch(self) -> None:
276
+ if self._scratch_dir is not None:
277
+ shutil.rmtree(self._scratch_dir, ignore_errors=True)
278
+ self._scratch_dir = None
279
+
280
+ def cleanup_scratch(self) -> None:
281
+ """Remove any scratch extraction created by materialize(). Safe to call multiple times."""
282
+ self._reset_scratch()
283
+ self._resolved_module_folder = None
284
+
102
285
  def get_module_render_status(self) -> tuple[str | None, str | None]:
103
286
  module_name, frid = git_utils.get_last_rendered_functionality(self.module_build_folder)
104
287
  if module_name is not None and module_name == self.module_name:
@@ -149,6 +332,9 @@ class PlainModule:
149
332
  return plain_spec.get_hash_value([stripped] + self.resources_list)
150
333
 
151
334
  def get_module_code_hash(self) -> str:
335
+ # Content-only hash (see calculate_build_folder_hash): reading from the resolved (possibly
336
+ # scratch) folder yields the same hash as the in-place folder and the same hash across
337
+ # locations, so archived modules stay portable.
152
338
  return ImplementationCodeHelpers.calculate_build_folder_hash(self.module_build_folder)
153
339
 
154
340
  def has_required_modules_code_changed(
@@ -408,6 +594,10 @@ class PlainModule:
408
594
  return False
409
595
 
410
596
  def wipe_module(self) -> None:
411
- if os.path.exists(self.module_folder):
412
- console.warning(f"Wiping module {self.module_folder}...")
413
- shutil.rmtree(self.module_folder)
597
+ if os.path.isdir(self._default_module_folder):
598
+ console.warning(f"Wiping module {self._default_module_folder}...")
599
+ file_utils.delete_folder(self._default_module_folder)
600
+ if self.has_module_archive():
601
+ os.remove(self.module_archive_path)
602
+ self._reset_scratch()
603
+ self._resolved_module_folder = None
@@ -32,6 +32,11 @@ class PrepareRepositories(BaseAction):
32
32
  )
33
33
 
34
34
  else:
35
+ if render_context.required_modules:
36
+ # If the required module exists only as a "<module>.module" archive, extract it to scratch first
37
+ # (no-op if it is already an unpacked folder or already materialized).
38
+ render_context.required_modules[-1].materialize()
39
+
35
40
  file_utils.delete_folder(render_context.plain_module.module_folder)
36
41
  render_context.plain_module.seed_module_metadata()
37
42
 
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ from typing import Callable, Optional
3
4
 
4
5
  import file_utils
5
6
  from plain2code_console import console
@@ -16,11 +17,20 @@ class ConformanceTests:
16
17
  self,
17
18
  modules_base_folder: str,
18
19
  conformance_tests_definition_file_name: str,
20
+ resolve_module_tests_folder: Optional[Callable[[str], Optional[str]]] = None,
19
21
  ):
20
22
  self.modules_base_folder = modules_base_folder
21
23
  self.conformance_tests_definition_file_name = conformance_tests_definition_file_name
24
+ # Optional resolver mapping a module name to its resolved tests folder. Lets an
25
+ # archive-only ("<module>.module") required module resolve to its scratch extraction
26
+ # instead of the (non-existent) default plain_modules/<module>/tests path.
27
+ self._resolve_module_tests_folder = resolve_module_tests_folder
22
28
 
23
29
  def get_module_conformance_tests_folder(self, module_name: str) -> str:
30
+ if self._resolve_module_tests_folder is not None:
31
+ resolved = self._resolve_module_tests_folder(module_name)
32
+ if resolved is not None:
33
+ return resolved
24
34
  return get_module_tests_folder(self.modules_base_folder, module_name)
25
35
 
26
36
  def _get_full_conformance_tests_definition_file_name(self, module_name: str) -> str:
@@ -29,21 +39,48 @@ class ConformanceTests:
29
39
  self.conformance_tests_definition_file_name,
30
40
  )
31
41
 
42
+ def _resolve_folder_names(self, module_name: str, conformance_tests_json: dict) -> dict:
43
+ """Turn each entry's on-disk relative ``folder_name`` into an absolute path rooted at the
44
+ module's (resolved) tests folder. An already-absolute value is left as-is, so archives
45
+ written by older builds still load."""
46
+ base = self.get_module_conformance_tests_folder(module_name)
47
+ resolved: dict = {}
48
+ for frid, entry in conformance_tests_json.items():
49
+ if isinstance(entry, dict) and "folder_name" in entry:
50
+ entry = {**entry, "folder_name": os.path.join(base, entry["folder_name"])}
51
+ resolved[frid] = entry
52
+ return resolved
53
+
54
+ def _relativize_folder_names(self, module_name: str, conformance_tests_json: dict) -> dict:
55
+ """Turn each entry's absolute in-memory ``folder_name`` into a path relative to the module's
56
+ tests folder, so the stored definition is location-independent (portable across projects and
57
+ usable from a "<module>.module" archive). Does not mutate the input dict."""
58
+ base = self.get_module_conformance_tests_folder(module_name)
59
+ serializable: dict = {}
60
+ for frid, entry in conformance_tests_json.items():
61
+ if isinstance(entry, dict) and "folder_name" in entry:
62
+ entry = {**entry, "folder_name": os.path.relpath(entry["folder_name"], base)}
63
+ serializable[frid] = entry
64
+ return serializable
65
+
32
66
  def get_conformance_tests_json(self, module_name: str) -> dict:
33
67
  try:
34
68
  with open(self._get_full_conformance_tests_definition_file_name(module_name), "r") as f:
35
- return json.load(f)
69
+ conformance_tests_json = json.load(f)
36
70
  except FileNotFoundError:
37
71
  return {}
72
+ return self._resolve_folder_names(module_name, conformance_tests_json)
38
73
 
39
74
  def dump_conformance_tests_json(self, module_name: str, conformance_tests_json: dict) -> None:
40
- """Dump the conformance tests definition to the file."""
75
+ """Dump the conformance tests definition to the file. Folder names are stored relative to the
76
+ module's tests folder so the definition is portable and works from a scratch extraction."""
41
77
  if os.path.exists(self.get_module_conformance_tests_folder(module_name)):
42
78
  console.debug(
43
79
  f"Storing conformance tests definition to {self._get_full_conformance_tests_definition_file_name(module_name)}"
44
80
  )
81
+ serializable = self._relativize_folder_names(module_name, conformance_tests_json)
45
82
  with open(self._get_full_conformance_tests_definition_file_name(module_name), "w") as f:
46
- json.dump(conformance_tests_json, f, indent=4)
83
+ json.dump(serializable, f, indent=4)
47
84
 
48
85
  def fetch_existing_conformance_test_folder_names(self, module_name: str) -> list[str]:
49
86
  if os.path.isdir(self.get_module_conformance_tests_folder(module_name)):
@@ -8,8 +8,12 @@ import plain_spec
8
8
  class ImplementationCodeHelpers:
9
9
  @staticmethod
10
10
  def calculate_build_folder_hash(build_folder: str) -> str:
11
+ # Hash the code content only (relative paths + file contents), NOT the build folder's
12
+ # absolute path, so the hash is stable across directories and machines. This is required for
13
+ # distributable "<module>.module" archives: a module's code hash must match regardless of
14
+ # where plain_modules/ lives, or consuming an archive elsewhere falsely reports a code change.
11
15
  _, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(build_folder)
12
- return plain_spec.hash_text(f"folder={build_folder}|{json.dumps(existing_files_content)}")
16
+ return plain_spec.hash_text(json.dumps(existing_files_content))
13
17
 
14
18
  @staticmethod
15
19
  def fetch_existing_files(build_folder: str):
@@ -87,10 +87,22 @@ class RenderContext:
87
87
  self.conformance_tests_running_context: Optional[ConformanceTestsRunningContext] = None
88
88
  # Constants that should remain for a single frid, but possible over multiple rerenderings of the same frid
89
89
  self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0
90
- # Initialize conformance tests utilities
90
+
91
+ # Initialize conformance tests utilities. The resolver lets a required module that ships
92
+ # as a "<module>.module" archive resolve to its scratch extraction (via materialize())
93
+ # rather than the non-existent default plain_modules/<module>/tests path.
94
+ def _resolve_module_tests_folder(module_name: str) -> Optional[str]:
95
+ if module_name == plain_module.module_name:
96
+ return plain_module.module_conformance_tests_folder
97
+ for required_module in plain_module.all_required_modules:
98
+ if required_module.module_name == module_name:
99
+ return required_module.module_conformance_tests_folder
100
+ return None
101
+
91
102
  self.conformance_tests = ConformanceTests(
92
103
  modules_base_folder=plain_module.build_folder,
93
104
  conformance_tests_definition_file_name=CONFORMANCE_TESTS_DEFINITION_FILE_NAME,
105
+ resolve_module_tests_folder=_resolve_module_tests_folder,
94
106
  )
95
107
 
96
108
  self.machine = None
system_config.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import importlib.resources
2
2
  import os
3
+ import re
3
4
  import sys
4
5
 
5
6
  import yaml
@@ -10,6 +11,15 @@ ENVIRONMENT_VAR = "CODEPLAIN_ENV"
10
11
  PRODUCTION_ENV = "production"
11
12
  DEVELOPMENT_ENV = "development"
12
13
 
14
+ PRODUCTION_API_URL = "https://api.codeplain.ai"
15
+ TEST_API_URL = "https://api.test.codeplain.ai"
16
+
17
+ # PEP 440 pre-release markers, matched against the end of the release segment:
18
+ # alpha/beta/release-candidate (0.4.0a1, 0.4.0b2, 0.4.0rc1) or a dev release
19
+ # (0.3.10.dev7, published on every merge to main). ".postN" alone is NOT a
20
+ # pre-release and must not match.
21
+ _PRERELEASE_RE = re.compile(r"(?:a|b|rc)\d+$|\.dev\d+$", re.IGNORECASE)
22
+
13
23
 
14
24
  def _resolve_version() -> str:
15
25
  """Resolve the client version.
@@ -79,9 +89,33 @@ def _resolve_environment(installed: bool) -> str:
79
89
  return PRODUCTION_ENV if installed else DEVELOPMENT_ENV
80
90
 
81
91
 
92
+ def _is_prerelease(version: str) -> bool:
93
+ """Return True if ``version`` is a PEP 440 pre-release (alpha, beta, rc or dev).
94
+
95
+ Deliberately dependency-free rather than using ``packaging``: that library
96
+ is not a runtime dependency (it only reaches this checkout via dev tools),
97
+ so it is absent from a real user install. The only versions that reach here
98
+ are the ones our own build pipeline produces.
99
+ """
100
+ # Drop any local version segment ("+gb710c298") before inspecting.
101
+ return _PRERELEASE_RE.search(version.split("+", 1)[0]) is not None
102
+
103
+
104
+ def _resolve_default_api_url(version: str) -> str:
105
+ """Resolve the API this client talks to when ``--api`` is not given.
106
+
107
+ Pre-release builds — the dev build published on every merge to main, and
108
+ any alpha/beta/rc release — default to the test API, so an unreleased
109
+ client is exercised against the unreleased backend. Stable releases default
110
+ to production. ``--api`` always wins over this.
111
+ """
112
+ return TEST_API_URL if _is_prerelease(version) else PRODUCTION_API_URL
113
+
114
+
82
115
  __version__ = _resolve_version()
83
116
  __installed__ = _resolve_installed()
84
117
  __environment__ = _resolve_environment(__installed__)
118
+ __default_api_url__ = _resolve_default_api_url(__version__)
85
119
 
86
120
 
87
121
  class SystemConfig:
@@ -95,6 +129,7 @@ class SystemConfig:
95
129
  self.client_version = __version__
96
130
  self.installed = __installed__
97
131
  self.environment = __environment__
132
+ self.default_api_url = __default_api_url__
98
133
  self.error_messages = self.config["error_messages"]
99
134
 
100
135
  def _load_config(self):
tests/test_cli_output.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Unit tests for cli_output module."""
2
2
 
3
+ from datetime import datetime, timezone
3
4
  from unittest.mock import Mock, patch
4
5
 
5
6
  from cli_output.status import (
@@ -10,6 +11,14 @@ from cli_output.status import (
10
11
  print_status,
11
12
  )
12
13
 
14
+ FROZEN_NOW = datetime(2025, 6, 15, 12, 0, tzinfo=timezone.utc)
15
+
16
+
17
+ class FrozenDatetime(datetime):
18
+ @classmethod
19
+ def now(cls, tz=None):
20
+ return FROZEN_NOW if tz is None else FROZEN_NOW.astimezone(tz)
21
+
13
22
 
14
23
  class TestProgressBar:
15
24
  """Tests for _create_progress_bar function."""
@@ -52,6 +61,7 @@ class TestProgressBar:
52
61
  assert bar == "█" * 5 + "░" * 5
53
62
 
54
63
 
64
+ @patch("cli_output.status.datetime", FrozenDatetime)
55
65
  class TestDisplayCreditLine:
56
66
  """Tests for _display_credit_line function."""
57
67
 
@@ -132,6 +142,7 @@ class TestDisplayCreditLine:
132
142
  mock_console.print.assert_called_once()
133
143
 
134
144
 
145
+ @patch("cli_output.status.datetime", FrozenDatetime)
135
146
  class TestDisplayBucketCreditLine:
136
147
  """Tests for _display_bucket_credit_line function."""
137
148
 
@@ -192,6 +203,7 @@ class TestDisplayBucketCreditLine:
192
203
  mock_console.print.assert_called_once()
193
204
 
194
205
 
206
+ @patch("cli_output.status.datetime", FrozenDatetime)
195
207
  class TestDisplayStatusMessage:
196
208
  """Tests for _display_status_message function."""
197
209
 
@@ -271,6 +283,7 @@ class TestDisplayStatusMessage:
271
283
  assert "No rendering credits remaining" in call_args
272
284
 
273
285
 
286
+ @patch("cli_output.status.datetime", FrozenDatetime)
274
287
  class TestPrintStatus:
275
288
  """Tests for print_status function."""
276
289