stouputils 1.2.15__tar.gz → 1.2.17__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 (25) hide show
  1. {stouputils-1.2.15 → stouputils-1.2.17}/PKG-INFO +4 -1
  2. {stouputils-1.2.15 → stouputils-1.2.17}/pyproject.toml +4 -1
  3. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/all_doctests.py +2 -2
  4. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/applications/automatic_docs.py +7 -7
  5. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/archive.py +2 -2
  6. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/backup.py +7 -7
  7. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/collections.py +1 -1
  8. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/continuous_delivery/cd_utils.py +4 -4
  9. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/continuous_delivery/github.py +26 -26
  10. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/continuous_delivery/pyproject.py +2 -2
  11. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/ctx.py +87 -11
  12. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/decorators.py +4 -4
  13. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/image.py +4 -4
  14. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/io.py +2 -2
  15. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/parallel.py +2 -2
  16. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/print.py +8 -9
  17. {stouputils-1.2.15 → stouputils-1.2.17}/.gitignore +0 -0
  18. {stouputils-1.2.15 → stouputils-1.2.17}/LICENSE +0 -0
  19. {stouputils-1.2.15 → stouputils-1.2.17}/README.md +0 -0
  20. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/__init__.py +0 -0
  21. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/applications/__init__.py +0 -0
  22. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/continuous_delivery/__init__.py +0 -0
  23. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/continuous_delivery/pypi.py +0 -0
  24. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/dont_look/zip_file_override.py +0 -0
  25. {stouputils-1.2.15 → stouputils-1.2.17}/stouputils/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stouputils
3
- Version: 1.2.15
3
+ Version: 1.2.17
4
4
  Summary: Stouputils is a collection of utility modules designed to simplify and enhance the development process. It includes a range of tools for tasks such as execution of doctests, display utilities, decorators, as well as context managers, and many more.
5
5
  Project-URL: Homepage, https://github.com/Stoupy51/stouputils
6
6
  Project-URL: Issues, https://github.com/Stoupy51/stouputils/issues
@@ -14,6 +14,9 @@ Requires-Dist: furo>=2024.8.6
14
14
  Requires-Dist: hatch>=1.14.0
15
15
  Requires-Dist: m2r2>=0.3.3.post2
16
16
  Requires-Dist: myst-parser>=4.0.1
17
+ Requires-Dist: numpy>=2.2.4
18
+ Requires-Dist: opencv-python>=4.8.1.78
19
+ Requires-Dist: pillow>=11.1.0
17
20
  Requires-Dist: pyyaml>=6.0.0
18
21
  Requires-Dist: requests>=2.30.0
19
22
  Requires-Dist: sphinx-rtd-theme>=3.0.2
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "stouputils"
8
- version = "1.2.15"
8
+ version = "1.2.17"
9
9
  description = "Stouputils is a collection of utility modules designed to simplify and enhance the development process. It includes a range of tools for tasks such as execution of doctests, display utilities, decorators, as well as context managers, and many more."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -25,6 +25,9 @@ dependencies = [
25
25
  "myst-parser>=4.0.1",
26
26
  "furo>=2024.8.6",
27
27
  "hatch>=1.14.0",
28
+ "pillow>=11.1.0",
29
+ "numpy>=2.2.4",
30
+ "opencv-python>=4.8.1.78",
28
31
  ]
29
32
  [[project.authors]]
30
33
  name = "Stoupy51"
@@ -30,10 +30,10 @@ def launch_tests(root_dir: str, importing_errors: LogLevels = LogLevels.WARNING_
30
30
  root_dir (str): Root directory to search for modules
31
31
  importing_errors (LogLevels): Log level for the errors when importing modules
32
32
  strict (bool): Modify the force_raise_exception variable to True in the decorators module
33
-
33
+
34
34
  Returns:
35
35
  int: The number of failed tests
36
-
36
+
37
37
  Examples:
38
38
  >>> launch_tests("unknown_dir")
39
39
  Traceback (most recent call last):
@@ -276,7 +276,7 @@ def generate_index_rst(
276
276
  get_versions_function: Callable[[str, str], list[str]] = get_versions_from_github,
277
277
  ) -> None:
278
278
  """ Generate index.rst from README.md content.
279
-
279
+
280
280
  Args:
281
281
  readme_path (str): Path to the README.md file
282
282
  index_path (str): Path where index.rst should be created
@@ -291,10 +291,10 @@ def generate_index_rst(
291
291
 
292
292
  # Generate version selector
293
293
  version_selector: str = "\n\n**Versions**: "
294
-
294
+
295
295
  # Get versions from GitHub
296
296
  version_list: list[str] = get_versions_function(github_user, github_repo)
297
-
297
+
298
298
  # Create version links
299
299
  version_links: list[str] = []
300
300
  for version in version_list:
@@ -303,7 +303,7 @@ def generate_index_rst(
303
303
  else:
304
304
  version_links.append(f"`v{version} <../v{version}/index.html>`_")
305
305
  version_selector += ", ".join(version_links)
306
-
306
+
307
307
  # Generate module documentation section
308
308
  project_module: str = project.lower()
309
309
  module_docs: str = f"""
@@ -325,7 +325,7 @@ def generate_index_rst(
325
325
  {'-' * 100}
326
326
  {module_docs}
327
327
  """
328
-
328
+
329
329
  # Write the RST file
330
330
  with open(index_path, "w", encoding="utf-8") as f:
331
331
  f.write(rst_content)
@@ -444,7 +444,7 @@ def update_documentation(
444
444
  # Modify build directory if version is specified
445
445
  latest_dir: str = f"{html_dir}/latest"
446
446
  build_dir: str = latest_dir if not version else f"{html_dir}/v{version}"
447
-
447
+
448
448
  # Create directories if they don't exist
449
449
  for dir in [modules_dir, static_dir, templates_dir]:
450
450
  os.makedirs(dir, exist_ok=True)
@@ -495,7 +495,7 @@ def update_documentation(
495
495
  project_dir=project_dir if project_dir else f"{root_path}/{project}",
496
496
  build_dir=build_dir,
497
497
  )
498
-
498
+
499
499
  # Add index.html to the build directory that redirects to the latest version
500
500
  generate_redirect_function(f"{html_dir}/index.html")
501
501
 
@@ -86,7 +86,7 @@ def repair_zip_file(file_path: str, destination: str) -> bool:
86
86
  destination (str): Destination of the new file
87
87
  Returns:
88
88
  bool: Always returns True unless any strong error
89
-
89
+
90
90
  Examples:
91
91
 
92
92
  .. code-block:: python
@@ -113,7 +113,7 @@ def repair_zip_file(file_path: str, destination: str) -> bool:
113
113
  new_zip_file.writestr(file_name, zip_file.read(file_name))
114
114
  except KeyboardInterrupt:
115
115
  continue
116
-
116
+
117
117
  return True
118
118
 
119
119
 
@@ -155,11 +155,11 @@ def create_delta_backup(source_path: str, destination_folder: str, exclude_patte
155
155
  for file in files:
156
156
  full_path: str = clean_path(os.path.join(root, file))
157
157
  arcname: str = clean_path(os.path.relpath(full_path, start=os.path.dirname(source_path)))
158
-
158
+
159
159
  # Skip file if it matches any exclude pattern
160
160
  if exclude_patterns and any(fnmatch.fnmatch(arcname, pattern) for pattern in exclude_patterns):
161
161
  continue
162
-
162
+
163
163
  file_hash: str | None = get_file_hash(full_path)
164
164
  if file_hash is None:
165
165
  continue
@@ -170,7 +170,7 @@ def create_delta_backup(source_path: str, destination_folder: str, exclude_patte
170
170
  zip_info: zipfile.ZipInfo = zipfile.ZipInfo(arcname)
171
171
  zip_info.compress_type = zipfile.ZIP_DEFLATED
172
172
  zip_info.comment = file_hash.encode() # Store hash in comment
173
-
173
+
174
174
  # Read and write file in chunks
175
175
  with open(full_path, "rb") as f:
176
176
  with zipf.open(zip_info, "w", force_zip64=True) as zf:
@@ -179,20 +179,20 @@ def create_delta_backup(source_path: str, destination_folder: str, exclude_patte
179
179
  has_changes = True
180
180
  except Exception as e:
181
181
  warning(f"Error writing file {full_path} to backup: {e}")
182
-
182
+
183
183
  # Track current files for deletion detection
184
184
  if arcname in previous_files:
185
185
  previous_files.remove(arcname)
186
186
  else:
187
187
  arcname: str = clean_path(os.path.basename(source_path))
188
188
  file_hash: str | None = get_file_hash(source_path)
189
-
189
+
190
190
  if file_hash is not None and not is_file_in_any_previous_backup(arcname, file_hash, previous_backups):
191
191
  try:
192
192
  zip_info: zipfile.ZipInfo = zipfile.ZipInfo(arcname)
193
193
  zip_info.compress_type = zipfile.ZIP_DEFLATED
194
194
  zip_info.comment = file_hash.encode()
195
-
195
+
196
196
  with open(source_path, "rb") as f:
197
197
  with zipf.open(zip_info, "w", force_zip64=True) as zf:
198
198
  for chunk in iter(lambda: f.read(4096), b""):
@@ -260,7 +260,7 @@ def consolidate_backups(zip_path: str, destination_zip: str) -> None:
260
260
  and filename not in final_files \
261
261
  and filename not in deleted_files:
262
262
  final_files.add(filename)
263
-
263
+
264
264
  # Copy file in chunks
265
265
  with zipf_in.open(inf, "r") as source:
266
266
  with zipf_out.open(inf, "w", force_zip64=True) as target:
@@ -19,7 +19,7 @@ def unique_list(list_to_clean: list[Any], method: Literal["id", "hash", "str"] =
19
19
  method (Literal["id", "hash", "str"]): The method to use to identify duplicates
20
20
  Returns:
21
21
  list[Any]: The cleaned list
22
-
22
+
23
23
  Examples:
24
24
  >>> unique_list([1, 2, 3, 2, 1], method="id")
25
25
  [1, 2, 3]
@@ -50,7 +50,7 @@ def load_credentials(credentials_path: str) -> dict[str, Any]:
50
50
  # Check if the file exists
51
51
  if not os.path.exists(credentials_path):
52
52
  raise FileNotFoundError(f"Credentials file not found at '{credentials_path}'")
53
-
53
+
54
54
  # Load the file if it's a JSON file
55
55
  if credentials_path.endswith(".json"):
56
56
  return super_json_load(credentials_path)
@@ -59,7 +59,7 @@ def load_credentials(credentials_path: str) -> dict[str, Any]:
59
59
  elif credentials_path.endswith((".yml", ".yaml")):
60
60
  with open(credentials_path, "r") as f:
61
61
  return yaml.safe_load(f)
62
-
62
+
63
63
  # Else, raise an error
64
64
  else:
65
65
  raise ValueError("Credentials file must be .json or .yml format")
@@ -87,7 +87,7 @@ def clean_version(version: str, keep: str = "") -> str:
87
87
  keep (str): The characters to keep in the version string
88
88
  Returns:
89
89
  str: The cleaned version string
90
-
90
+
91
91
  >>> clean_version("v1.e0.zfezf0.1.2.3zefz")
92
92
  '1.0.0.1.2.3'
93
93
  >>> clean_version("v1.e0.zfezf0.1.2.3zefz", keep="v")
@@ -107,7 +107,7 @@ def version_to_float(version: str) -> float:
107
107
  version (str): The version string to convert. (e.g. "v1.0.0.1.2.3")
108
108
  Returns:
109
109
  float: The float representation of the version. (e.g. 0)
110
-
110
+
111
111
  >>> version_to_float("v1.0.0")
112
112
  1.0
113
113
  >>> version_to_float("v1.0.0.1")
@@ -41,7 +41,7 @@ def validate_credentials(credentials: dict[str, dict[str, str]]) -> tuple[str, d
41
41
  Returns:
42
42
  tuple[str, dict[str, str]]:
43
43
  str: Owner (the username of the account to use)
44
-
44
+
45
45
  dict[str, str]: Headers (for the requests to the GitHub API)
46
46
  """
47
47
  if "github" not in credentials:
@@ -50,7 +50,7 @@ def validate_credentials(credentials: dict[str, dict[str, str]]) -> tuple[str, d
50
50
  raise ValueError("The credentials file must contain a 'github' key, which is a dictionary containing a 'api_key' key (a PAT for the GitHub API: https://github.com/settings/tokens) and a 'username' key (the username of the account to use)")
51
51
  if "username" not in credentials["github"]:
52
52
  raise ValueError("The credentials file must contain a 'github' key, which is a dictionary containing a 'api_key' key (a PAT for the GitHub API: https://github.com/settings/tokens) and a 'username' key (the username of the account to use)")
53
-
53
+
54
54
  api_key: str = credentials["github"]["api_key"]
55
55
  owner: str = credentials["github"]["username"]
56
56
  headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
@@ -64,11 +64,11 @@ def validate_config(github_config: dict[str, Any]) -> tuple[str, str, str, list[
64
64
  Returns:
65
65
  tuple[str, str, str, list[str]]:
66
66
  str: Project name on GitHub
67
-
67
+
68
68
  str: Version of the project
69
-
69
+
70
70
  str: Build folder path containing zip files to upload to the release
71
-
71
+
72
72
  list[str]: List of zip files to upload to the release
73
73
  """
74
74
  if "project_name" not in github_config:
@@ -182,10 +182,10 @@ def get_commits_since_tag(owner: str, project_name: str, latest_tag_sha: str|Non
182
182
  # Get the commits URL and parameters
183
183
  commits_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/commits"
184
184
  commits_params: dict[str, str] = {"per_page": "100"}
185
-
185
+
186
186
  # Initialize tag_date as None
187
187
  tag_date: str|None = None # type: ignore
188
-
188
+
189
189
  # If there is a latest tag, use it to get the commits since the tag date
190
190
  if latest_tag_sha:
191
191
 
@@ -194,10 +194,10 @@ def get_commits_since_tag(owner: str, project_name: str, latest_tag_sha: str|Non
194
194
  tag_response = requests.get(tag_commit_url, headers=headers)
195
195
  handle_response(tag_response, "Failed to get tag commit")
196
196
  tag_date: str = tag_response.json()["commit"]["committer"]["date"]
197
-
197
+
198
198
  # Use the date as the 'since' parameter to get all commits after that date
199
199
  commits_params["since"] = tag_date
200
-
200
+
201
201
  # Get the commits
202
202
  response = requests.get(commits_url, headers=headers, params=commits_params)
203
203
  handle_response(response, "Failed to get commits")
@@ -210,7 +210,7 @@ def get_commits_since_tag(owner: str, project_name: str, latest_tag_sha: str|Non
210
210
 
211
211
  def generate_changelog(commits: list[dict[str, Any]], owner: str, project_name: str, latest_tag_version: str|None, version: str) -> str:
212
212
  """ Generate changelog from commits. They must follow the conventional commits convention.
213
-
213
+
214
214
  Convention format: <type>: <description>
215
215
 
216
216
  Args:
@@ -226,21 +226,21 @@ def generate_changelog(commits: list[dict[str, Any]], owner: str, project_name:
226
226
  """
227
227
  # Initialize the commit groups
228
228
  commit_groups: dict[str, list[tuple[str, str]]] = {}
229
-
229
+
230
230
  # Iterate over the commits
231
231
  for commit in commits:
232
232
  message: str = commit["commit"]["message"].split("\n")[0]
233
233
  sha: str = commit["sha"]
234
-
234
+
235
235
  # If the message contains a colon, split the message into a type and a description
236
236
  if ":" in message:
237
237
  type_, desc = message.split(":", 1)
238
-
238
+
239
239
  # Clean the type
240
240
  type_ = type_.split('(')[0]
241
241
  type_ = "".join(c for c in type_.lower().strip() if c in "abcdefghijklmnopqrstuvwxyz")
242
242
  type_ = COMMIT_TYPES.get(type_, type_.title())
243
-
243
+
244
244
  # Add the commit to the commit groups
245
245
  if type_ not in commit_groups:
246
246
  commit_groups[type_] = []
@@ -248,7 +248,7 @@ def generate_changelog(commits: list[dict[str, Any]], owner: str, project_name:
248
248
 
249
249
  # Initialize the changelog
250
250
  changelog: str = "## Changelog\n\n"
251
-
251
+
252
252
  # Iterate over the commit groups
253
253
  for type_ in sorted(commit_groups.keys()):
254
254
  changelog += f"### {type_}\n"
@@ -257,10 +257,10 @@ def generate_changelog(commits: list[dict[str, Any]], owner: str, project_name:
257
257
  for desc, sha in commit_groups[type_][::-1]:
258
258
  changelog += f"- {desc} ([{sha[:7]}](https://github.com/{owner}/{project_name}/commit/{sha}))\n"
259
259
  changelog += "\n"
260
-
260
+
261
261
  # Add the full changelog link if there is a latest tag and return the changelog
262
262
  if latest_tag_version:
263
- changelog += f"**Full Changelog**: https://github.com/{owner}/{project_name}/compare/v{latest_tag_version}...v{version}\n"
263
+ changelog += f"**Full Changelog**: https://github.com/{owner}/{project_name}/compare/v{latest_tag_version}...v{version}\n"
264
264
  return changelog
265
265
 
266
266
  def create_tag(owner: str, project_name: str, version: str, headers: dict[str, str]) -> None:
@@ -276,12 +276,12 @@ def create_tag(owner: str, project_name: str, version: str, headers: dict[str, s
276
276
  progress(f"Creating tag v{version}")
277
277
  create_tag_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/git/refs"
278
278
  latest_commit_url: str = f"{PROJECT_ENDPOINT}/{owner}/{project_name}/git/refs/heads/main"
279
-
279
+
280
280
  # Get the latest commit SHA
281
281
  commit_response: requests.Response = requests.get(latest_commit_url, headers=headers)
282
282
  handle_response(commit_response, "Failed to get latest commit")
283
283
  commit_sha: str = commit_response.json()["object"]["sha"]
284
-
284
+
285
285
  # Create the tag
286
286
  tag_data: dict[str, str] = {
287
287
  "ref": f"refs/tags/v{version}",
@@ -312,7 +312,7 @@ def create_release(owner: str, project_name: str, version: str, changelog: str,
312
312
  "draft": False,
313
313
  "prerelease": False
314
314
  }
315
-
315
+
316
316
  # Create the release and return the release ID
317
317
  response: requests.Response = requests.post(release_url, headers=headers, json=release_data)
318
318
  handle_response(response, "Failed to create release")
@@ -335,13 +335,13 @@ def upload_assets(owner: str, project_name: str, release_id: int, build_folder:
335
335
  if not build_folder:
336
336
  return
337
337
  progress("Uploading assets")
338
-
338
+
339
339
  # Get the release details
340
340
  response: requests.Response = requests.get(f"{PROJECT_ENDPOINT}/{owner}/{project_name}/releases/{release_id}", headers=headers)
341
341
  handle_response(response, "Failed to get release details")
342
342
  upload_url_template: str = response.json()["upload_url"]
343
343
  upload_url_base: str = upload_url_template.split("{", maxsplit=1)[0]
344
-
344
+
345
345
  # Iterate over the files in the build folder
346
346
  for file in os.listdir(build_folder):
347
347
  if file.endswith(endswith_tuple):
@@ -354,7 +354,7 @@ def upload_assets(owner: str, project_name: str, release_id: int, build_folder:
354
354
  "Content-Type": "application/zip"
355
355
  }
356
356
  params: dict[str, str] = {"name": file}
357
-
357
+
358
358
  # Upload the file
359
359
  response: requests.Response = requests.post(
360
360
  upload_url_base,
@@ -400,17 +400,17 @@ def upload_to_github(credentials: dict[str, Any], github_config: dict[str, Any])
400
400
 
401
401
  # Handle existing tag
402
402
  can_create: bool = handle_existing_tag(owner, project_name, version, headers)
403
-
403
+
404
404
  # Get the latest tag and commits since the tag
405
405
  latest_tag_sha, latest_tag_version = get_latest_tag(owner, project_name, version, headers)
406
406
  commits: list[dict[str, Any]] = get_commits_since_tag(owner, project_name, latest_tag_sha, headers)
407
407
  changelog: str = generate_changelog(commits, owner, project_name, latest_tag_version, version)
408
-
408
+
409
409
  # Create the tag and release if needed
410
410
  if can_create:
411
411
  create_tag(owner, project_name, version, headers)
412
412
  release_id: int = create_release(owner, project_name, version, changelog, headers)
413
- upload_assets(owner, project_name, release_id, build_folder, headers, endswith)
413
+ upload_assets(owner, project_name, release_id, build_folder, headers, endswith)
414
414
  info(f"Project '{project_name}' updated on GitHub!")
415
415
  return changelog
416
416
 
@@ -52,12 +52,12 @@ def format_toml_lists(content: str) -> str:
52
52
  # Split into key and values parts
53
53
  key, values = line.split("=", 1)
54
54
  values = values.strip()
55
-
55
+
56
56
  # Check if values portion is a list
57
57
  if values.startswith("[") and values.endswith("]"):
58
58
  # Parse list values, removing empty entries
59
59
  values = [v.strip() for v in values[1:-1].split(",") if v.strip()]
60
-
60
+
61
61
  # For lists with multiple items, format across multiple lines
62
62
  if len(values) > 1:
63
63
  formatted_lines.append(f"{key}= [")
@@ -12,15 +12,16 @@ This module provides context managers for temporarily silencing output.
12
12
  import os
13
13
  import sys
14
14
  from typing import IO, TextIO, Callable, Any
15
- from .print import logging_to
15
+ from .print import remove_colors
16
16
  from .io import super_open
17
17
 
18
+
18
19
  # Context manager to temporarily silence output
19
20
  class Muffle:
20
21
  """ Context manager that temporarily silences output.
21
22
 
22
23
  Alternative to stouputils.decorators.silent()
23
-
24
+
24
25
  Examples:
25
26
  >>> with Muffle():
26
27
  ... print("This will not be printed")
@@ -54,6 +55,55 @@ class Muffle:
54
55
  sys.stderr = self.original_stderr
55
56
 
56
57
 
58
+ # TeeMultiOutput class to duplicate output to multiple file-like objects
59
+ class TeeMultiOutput(object):
60
+ """ File-like object that duplicates output to multiple file objects.
61
+
62
+ Args:
63
+ *files (IO[Any]): One or more file-like objects that have write and flush methods
64
+ strip_colors (bool): Whether to strip ANSI color codes from output sent to non-stdout/stderr files
65
+
66
+ Examples:
67
+ >>> f = open("logfile.txt", "w")
68
+ >>> original_stdout = sys.stdout
69
+ >>> sys.stdout = TeeMultiOutput(sys.stdout, f)
70
+ >>> print("Hello World") # Output goes to both console and file
71
+ >>> sys.stdout = original_stdout
72
+ >>> f.close()
73
+ """
74
+ def __init__(self, *files: IO[Any], strip_colors: bool = True) -> None:
75
+ self.files: tuple[IO[Any], ...] = files
76
+ """ File-like objects to write to """
77
+ self.strip_colors: bool = strip_colors
78
+ """ Whether to strip ANSI color codes from output sent to non-stdout/stderr files """
79
+
80
+ def write(self, obj: str) -> None:
81
+ """ Write the object to all files.
82
+
83
+ Args:
84
+ obj (str): String to write
85
+ """
86
+ for f in self.files:
87
+ # Strip colors for files that are not stdout or stderr
88
+ if self.strip_colors and f not in (sys.stdout, sys.stderr):
89
+ f.write(remove_colors(obj))
90
+ else:
91
+ f.write(obj)
92
+
93
+ def flush(self) -> None:
94
+ """ Flush all files. """
95
+ for f in self.files:
96
+ f.flush()
97
+
98
+ # Add other methods that might be expected from a file-like object
99
+ def isatty(self) -> bool:
100
+ """ Return whether the first file is connected to a tty-like device. """
101
+ return hasattr(self.files[0], 'isatty') and self.files[0].isatty()
102
+
103
+ def fileno(self) -> int:
104
+ """ Return the file descriptor of the first file. """
105
+ return self.files[0].fileno()
106
+
57
107
  # Context manager to log to a file
58
108
  class LogToFile:
59
109
  """ Context manager to log to a file.
@@ -65,6 +115,8 @@ class LogToFile:
65
115
  path (str): Path to the log file
66
116
  mode (str): Mode to open the file in (default: "w")
67
117
  encoding (str): Encoding to use for the file (default: "utf-8")
118
+ tee_stdout (bool): Whether to redirect stdout to the file (default: True)
119
+ tee_stderr (bool): Whether to redirect stderr to the file (default: True)
68
120
 
69
121
  Examples:
70
122
  .. code-block:: python
@@ -72,30 +124,54 @@ class LogToFile:
72
124
  > import stouputils as stp
73
125
  > with stp.LogToFile("output.log"):
74
126
  > stp.info("This will be logged to output.log and printed normally")
127
+ > print("This will also be logged")
75
128
  """
76
- def __init__(self, path: str, mode: str = "w", encoding: str = "utf-8") -> None:
129
+ def __init__(
130
+ self,
131
+ path: str,
132
+ mode: str = "w",
133
+ encoding: str = "utf-8",
134
+ tee_stdout: bool = True,
135
+ tee_stderr: bool = True
136
+ ) -> None:
77
137
  self.path: str = path
78
138
  """ Attribute remembering path to the log file """
79
139
  self.mode: str = mode
80
140
  """ Attribute remembering mode to open the file in """
81
141
  self.encoding: str = encoding
82
142
  """ Attribute remembering encoding to use for the file """
143
+ self.tee_stdout: bool = tee_stdout
144
+ """ Whether to redirect stdout to the file """
145
+ self.tee_stderr: bool = tee_stderr
146
+ """ Whether to redirect stderr to the file """
83
147
  self.file: IO[Any] = super_open(self.path, mode=self.mode, encoding=self.encoding)
84
148
  """ Attribute remembering opened file """
149
+ self.original_stdout: TextIO = sys.stdout
150
+ """ Original stdout before redirection """
151
+ self.original_stderr: TextIO = sys.stderr
152
+ """ Original stderr before redirection """
85
153
 
86
154
  def __enter__(self) -> None:
87
- """ Enter context manager which opens the log file and adds it to the logging_to list """
88
- # Add file to logging_to list
89
- logging_to.add(self.file)
155
+ """ Enter context manager which opens the log file and redirects stdout/stderr """
156
+ # Redirect stdout and stderr if requested
157
+ if self.tee_stdout:
158
+ sys.stdout = TeeMultiOutput(self.original_stdout, self.file)
159
+
160
+ if self.tee_stderr:
161
+ sys.stderr = TeeMultiOutput(self.original_stderr, self.file)
90
162
 
91
163
  def __exit__(self, exc_type: type[BaseException]|None, exc_val: BaseException|None, exc_tb: Any|None) -> None:
92
- """ Exit context manager which closes the log file and removes it from the logging_to list """
164
+ """ Exit context manager which closes the log file and restores stdout/stderr """
165
+ # Restore original stdout and stderr
166
+ if self.tee_stdout:
167
+ sys.stdout = self.original_stdout
168
+
169
+ if self.tee_stderr:
170
+ sys.stderr = self.original_stderr
171
+
93
172
  # Close file
94
173
  self.file.close()
95
174
 
96
- # Remove file from logging_to list
97
- logging_to.discard(self.file)
98
-
99
175
  @staticmethod
100
176
  def common(logs_folder: str, filepath: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
101
177
  """ Common code used at the beginning of a program to launch main function
@@ -108,7 +184,7 @@ class LogToFile:
108
184
  **kwargs (dict[str, Any]): Keyword arguments to pass to the main function
109
185
  Returns:
110
186
  Any: Return value of the main function
111
-
187
+
112
188
  Examples:
113
189
  >>> if __name__ == "__main__":
114
190
  ... LogToFile.common(f"{ROOT}/logs", __file__, main)
@@ -86,7 +86,7 @@ def measure_time(
86
86
  perf_counter (bool): Whether to use time.perf_counter_ns or time.time_ns
87
87
  Returns:
88
88
  Callable: Decorator to measure the time of the function.
89
-
89
+
90
90
  Examples:
91
91
  .. code-block:: python
92
92
 
@@ -171,7 +171,7 @@ def handle_error(
171
171
  LogLevels.WARNING_TRACEBACK: Show as warning with traceback
172
172
  LogLevels.ERROR_TRACEBACK: Show as error with traceback
173
173
  LogLevels.RAISE_EXCEPTION: Raise exception (as if the decorator didn't exist)
174
-
174
+
175
175
  Examples:
176
176
  .. code-block:: python
177
177
 
@@ -179,7 +179,7 @@ def handle_error(
179
179
  > def test():
180
180
  > raise ValueError("Let's fail")
181
181
  > test() # [WARNING HH:MM:SS] Error during test: (ValueError) Let's fail
182
- """
182
+ """
183
183
  # Update error_log if needed
184
184
  if force_raise_exception:
185
185
  error_log = LogLevels.RAISE_EXCEPTION
@@ -279,7 +279,7 @@ def deprecated(
279
279
  LogLevels.RAISE_EXCEPTION: Raise exception
280
280
  Returns:
281
281
  Callable[..., Any]: Decorator that marks a function as deprecated
282
-
282
+
283
283
  Examples:
284
284
  .. code-block:: python
285
285
 
@@ -15,7 +15,7 @@ def image_resize(
15
15
  ) -> Any:
16
16
  """ Resize an image while preserving its aspect ratio by default.
17
17
  Scales the image so that its largest dimension equals max_result_size.
18
-
18
+
19
19
  Args:
20
20
  image (Image.Image | np.ndarray): The image to resize.
21
21
  max_result_size (int): Maximum size for the largest dimension.
@@ -54,7 +54,7 @@ def image_resize(
54
54
  # Convert numpy array to PIL Image if needed
55
55
  if isinstance(image, np.ndarray):
56
56
  image = Image.fromarray(image)
57
-
57
+
58
58
  if keep_aspect_ratio:
59
59
 
60
60
  # Get original image dimensions
@@ -66,7 +66,7 @@ def image_resize(
66
66
 
67
67
  # Calculate scaling factor
68
68
  scale: float = max_result_size / max_dimension
69
-
69
+
70
70
  # Calculate new dimensions while preserving aspect ratio
71
71
  new_width: int = int(width * scale)
72
72
  new_height: int = int(height * scale)
@@ -76,7 +76,7 @@ def image_resize(
76
76
  else:
77
77
  # If not keeping aspect ratio, resize to square with max_result_size
78
78
  new_image: Image.Image = image.resize((max_result_size, max_result_size), resampling)
79
-
79
+
80
80
  # Return the image in the requested format
81
81
  if return_type == np.ndarray:
82
82
  return np.array(new_image)
@@ -183,7 +183,7 @@ def super_json_dump(data: Any, file: IO[Any]|None = None, max_level: int = 2, in
183
183
  indent (str): The indentation character (default: '\t')
184
184
  Returns:
185
185
  str: The content of the file in every case
186
-
186
+
187
187
  >>> super_json_dump({"a": [[1,2,3]], "b": 2}, max_level = 2)
188
188
  '{\\n\\t"a": [\\n\\t\\t[1,2,3]\\n\\t],\\n\\t"b": 2\\n}\\n'
189
189
  """
@@ -209,7 +209,7 @@ def super_json_dump(data: Any, file: IO[Any]|None = None, max_level: int = 2, in
209
209
  for char in finishes:
210
210
  to_replace: str = "\n" + indent * max_level + char
211
211
  content = content.replace(to_replace, char)
212
-
212
+
213
213
  # Write file content and return it
214
214
  content += "\n"
215
215
  if file:
@@ -72,7 +72,7 @@ def __handle_parameters(
72
72
  if use_starmap:
73
73
  args = [(func, arg) for arg in args] # type: ignore
74
74
  func = __starmap # type: ignore
75
-
75
+
76
76
  # Prepare delayed function calls if delay_first_calls is set
77
77
  if delay_first_calls > 0:
78
78
  args = [
@@ -80,7 +80,7 @@ def __handle_parameters(
80
80
  for i, arg in enumerate(args)
81
81
  ]
82
82
  func = __delayed_call # type: ignore
83
-
83
+
84
84
  return desc, func, args
85
85
 
86
86
  @handle_error(error_log=LogLevels.ERROR_TRACEBACK)
@@ -10,7 +10,7 @@ If a message is printed multiple times, it will be displayed as "(xN) message" w
10
10
  # Imports
11
11
  import sys
12
12
  import time
13
- from typing import Callable, TextIO, IO, Any
13
+ from typing import Callable, TextIO, Any
14
14
 
15
15
  # Colors constants
16
16
  RESET: str = "\033[0m"
@@ -22,9 +22,6 @@ MAGENTA: str = "\033[95m"
22
22
  CYAN: str = "\033[96m"
23
23
  LINE_UP: str = "\033[1A"
24
24
 
25
- # Logging utilities
26
- logging_to: set[IO[Any]] = set() # Used by LogToFile context manager
27
-
28
25
  def remove_colors(text: str) -> str:
29
26
  """ Remove the colors from a text """
30
27
  for color in [RESET, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, LINE_UP]:
@@ -65,22 +62,24 @@ def info(*values: Any, color: str = GREEN, text: str = "INFO ", prefix: str = ""
65
62
  file (TextIO|list[TextIO]): File(s) to write the message to (default: sys.stdout)
66
63
  print_kwargs (dict): Keyword arguments to pass to the print function
67
64
  """
65
+ # Use stdout if no file is specified
68
66
  if file is None:
69
67
  file = sys.stdout
68
+
69
+ # If file is a list, recursively call info() for each file
70
70
  if isinstance(file, list):
71
71
  for f in file:
72
72
  info(*values, color=color, text=text, prefix=prefix, file=f, **print_kwargs)
73
73
  else:
74
+ # Build the message with prefix, color, text and timestamp
74
75
  message: str = f"{prefix}{color}[{text} {current_time()}]"
76
+
77
+ # If this is a repeated print, add a line up and counter
75
78
  if is_same_print(*values, color=color, text=text, prefix=prefix, **print_kwargs):
76
79
  message = f"{LINE_UP}{message} (x{nb_values})"
77
80
 
78
- # Print normally with colors, and log to any registered logging files without colors
81
+ # Print the message with the values and reset color
79
82
  print(message, *values, RESET, file=file, **print_kwargs)
80
- if logging_to:
81
- print_kwargs["flush"] = True
82
- for log_file in logging_to:
83
- print(remove_colors(message), *(remove_colors(str(v)) for v in values), file=log_file, **print_kwargs)
84
83
 
85
84
  def debug(*values: Any, **print_kwargs: Any) -> None:
86
85
  """ Print a debug message looking like "[DEBUG HH:MM:SS] message" in cyan by default. """
File without changes
File without changes
File without changes