qBitrr2 4.10.9__py3-none-any.whl → 5.4.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. qBitrr/arss.py +2165 -889
  2. qBitrr/auto_update.py +382 -0
  3. qBitrr/bundled_data.py +3 -2
  4. qBitrr/config.py +20 -3
  5. qBitrr/db_lock.py +79 -0
  6. qBitrr/env_config.py +19 -7
  7. qBitrr/gen_config.py +287 -26
  8. qBitrr/logger.py +87 -3
  9. qBitrr/main.py +453 -101
  10. qBitrr/search_activity_store.py +88 -0
  11. qBitrr/static/assets/ArrView.js +2 -0
  12. qBitrr/static/assets/ArrView.js.map +1 -0
  13. qBitrr/static/assets/ConfigView.js +4 -0
  14. qBitrr/static/assets/ConfigView.js.map +1 -0
  15. qBitrr/static/assets/LogsView.js +230 -0
  16. qBitrr/static/assets/LogsView.js.map +1 -0
  17. qBitrr/static/assets/ProcessesView.js +2 -0
  18. qBitrr/static/assets/ProcessesView.js.map +1 -0
  19. qBitrr/static/assets/app.css +1 -0
  20. qBitrr/static/assets/app.js +11 -0
  21. qBitrr/static/assets/app.js.map +1 -0
  22. qBitrr/static/assets/build.svg +3 -0
  23. qBitrr/static/assets/check-mark.svg +5 -0
  24. qBitrr/static/assets/close.svg +4 -0
  25. qBitrr/static/assets/download.svg +5 -0
  26. qBitrr/static/assets/gear.svg +5 -0
  27. qBitrr/static/assets/lidarr.svg +1 -0
  28. qBitrr/static/assets/live-streaming.svg +8 -0
  29. qBitrr/static/assets/log.svg +3 -0
  30. qBitrr/static/assets/plus.svg +4 -0
  31. qBitrr/static/assets/process.svg +15 -0
  32. qBitrr/static/assets/react-select.esm.js +14 -0
  33. qBitrr/static/assets/react-select.esm.js.map +1 -0
  34. qBitrr/static/assets/refresh-arrow.svg +3 -0
  35. qBitrr/static/assets/table.js +23 -0
  36. qBitrr/static/assets/table.js.map +1 -0
  37. qBitrr/static/assets/trash.svg +8 -0
  38. qBitrr/static/assets/up-arrow.svg +3 -0
  39. qBitrr/static/assets/useInterval.js +2 -0
  40. qBitrr/static/assets/useInterval.js.map +1 -0
  41. qBitrr/static/assets/vendor.js +33 -0
  42. qBitrr/static/assets/vendor.js.map +1 -0
  43. qBitrr/static/assets/visibility.svg +9 -0
  44. qBitrr/static/index.html +47 -0
  45. qBitrr/static/manifest.json +23 -0
  46. qBitrr/static/sw.js +105 -0
  47. qBitrr/static/vite.svg +1 -0
  48. qBitrr/tables.py +44 -0
  49. qBitrr/utils.py +82 -15
  50. qBitrr/versioning.py +136 -0
  51. qBitrr/webui.py +2612 -0
  52. qbitrr2-5.4.5.dist-info/METADATA +1116 -0
  53. qbitrr2-5.4.5.dist-info/RECORD +61 -0
  54. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
  55. qBitrr2-4.10.9.dist-info/METADATA +0 -233
  56. qBitrr2-4.10.9.dist-info/RECORD +0 -19
  57. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
  58. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
  59. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/top_level.txt +0 -0
qBitrr/gen_config.py CHANGED
@@ -14,6 +14,56 @@ from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH
14
14
  T = TypeVar("T")
15
15
 
16
16
 
17
+ def _add_web_settings_section(config: TOMLDocument):
18
+ web_settings = table()
19
+ _gen_default_line(
20
+ web_settings,
21
+ "WebUI listen host (default 0.0.0.0)",
22
+ "Host",
23
+ "0.0.0.0",
24
+ )
25
+ _gen_default_line(
26
+ web_settings,
27
+ "WebUI listen port (default 6969)",
28
+ "Port",
29
+ 6969,
30
+ )
31
+ _gen_default_line(
32
+ web_settings,
33
+ [
34
+ "Optional bearer token to secure WebUI/API.",
35
+ "Set a non-empty value to require Authorization: Bearer <token>.",
36
+ ],
37
+ "Token",
38
+ "",
39
+ )
40
+ _gen_default_line(
41
+ web_settings,
42
+ "Enable live updates for Arr views",
43
+ "LiveArr",
44
+ True,
45
+ )
46
+ _gen_default_line(
47
+ web_settings,
48
+ "Group Sonarr episodes by series in views",
49
+ "GroupSonarr",
50
+ True,
51
+ )
52
+ _gen_default_line(
53
+ web_settings,
54
+ "Group Lidarr albums by artist in views",
55
+ "GroupLidarr",
56
+ True,
57
+ )
58
+ _gen_default_line(
59
+ web_settings,
60
+ "WebUI theme (Light or Dark)",
61
+ "Theme",
62
+ "Dark",
63
+ )
64
+ config.add("WebUI", web_settings)
65
+
66
+
17
67
  def generate_doc() -> TOMLDocument:
18
68
  config = document()
19
69
  config.add(
@@ -25,6 +75,7 @@ def generate_doc() -> TOMLDocument:
25
75
  config.add(comment('This is a config file should be moved to "' f'{HOME_PATH}".'))
26
76
  config.add(nl())
27
77
  _add_settings_section(config)
78
+ _add_web_settings_section(config)
28
79
  _add_qbit_section(config)
29
80
  _add_category_sections(config)
30
81
  return config
@@ -129,6 +180,28 @@ def _add_settings_section(config: TOMLDocument):
129
180
  "FFprobeAutoUpdate",
130
181
  True if ENVIRO_CONFIG.settings.ping_urls is None else ENVIRO_CONFIG.settings.ping_urls,
131
182
  )
183
+ _gen_default_line(
184
+ settings,
185
+ [
186
+ "Automatically attempt to update qBitrr on a schedule",
187
+ "Set to true to enable the auto-update worker.",
188
+ ],
189
+ "AutoUpdateEnabled",
190
+ (
191
+ ENVIRO_CONFIG.settings.auto_update_enabled
192
+ if ENVIRO_CONFIG.settings.auto_update_enabled is not None
193
+ else False
194
+ ),
195
+ )
196
+ _gen_default_line(
197
+ settings,
198
+ [
199
+ "Cron expression describing when to check for updates",
200
+ "Default is weekly Sunday at 03:00 (0 3 * * 0).",
201
+ ],
202
+ "AutoUpdateCron",
203
+ ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0",
204
+ )
132
205
  config.add("Settings", settings)
133
206
 
134
207
 
@@ -146,19 +219,19 @@ def _add_qbit_section(config: TOMLDocument):
146
219
  )
147
220
  _gen_default_line(
148
221
  qbit,
149
- 'qBit WebUI URL/IP - Can be found in Options > Web UI (called "IP Address")',
222
+ 'qbittorrent WebUI URL/IP - Can be found in Options > Web UI (called "IP Address")',
150
223
  "Host",
151
224
  ENVIRO_CONFIG.qbit.host or "CHANGE_ME",
152
225
  )
153
226
  _gen_default_line(
154
227
  qbit,
155
- 'qBit WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window)',
228
+ 'qbittorrent WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window)',
156
229
  "Port",
157
230
  ENVIRO_CONFIG.qbit.port or 8080,
158
231
  )
159
232
  _gen_default_line(
160
233
  qbit,
161
- "qBit WebUI Authentication - Can be found in Options > Web UI > Authentication",
234
+ "qbittorrent WebUI Authentication - Can be found in Options > Web UI > Authentication",
162
235
  "UserName",
163
236
  ENVIRO_CONFIG.qbit.username or "CHANGE_ME",
164
237
  )
@@ -168,17 +241,11 @@ def _add_qbit_section(config: TOMLDocument):
168
241
  "Password",
169
242
  ENVIRO_CONFIG.qbit.password or "CHANGE_ME",
170
243
  )
171
- _gen_default_line(
172
- qbit,
173
- "Set to true to allow abittorrent v5 (Some API calls will not work as expected due to qbittorrent API issues not qBitrr)",
174
- "v5",
175
- ENVIRO_CONFIG.qbit.v5 or False,
176
- )
177
244
  config.add("qBit", qbit)
178
245
 
179
246
 
180
247
  def _add_category_sections(config: TOMLDocument):
181
- for c in ["Sonarr-TV", "Sonarr-Anime", "Radarr-1080", "Radarr-4K"]:
248
+ for c in ["Sonarr-TV", "Sonarr-Anime", "Radarr-1080", "Radarr-4K", "Lidarr-Music"]:
182
249
  _gen_default_cat(c, config)
183
250
 
184
251
 
@@ -245,6 +312,14 @@ def _gen_default_cat(category: str, config: TOMLDocument):
245
312
  "Unable to determine if file is a sample",
246
313
  ]
247
314
  )
315
+ elif "lidarr" in category.lower():
316
+ messages.extend(
317
+ [
318
+ "Not a preferred word upgrade for existing track file(s)",
319
+ "Not an upgrade for existing track file(s)",
320
+ "Unable to determine if file is a sample",
321
+ ]
322
+ )
248
323
  _gen_default_line(
249
324
  cat_default,
250
325
  [
@@ -357,11 +432,11 @@ def _gen_default_torrent_table(category: str, cat_default: Table):
357
432
  torrent_table,
358
433
  "Maximum allowed time for allowed stalled torrents in minutes (-1 = Disabled, 0 = Infinite)",
359
434
  "StalledDelay",
360
- -1,
435
+ 15,
361
436
  )
362
437
  _gen_default_line(
363
438
  torrent_table,
364
- "Re-search stalled torrents when StalledDelay is enabled and you want to re-search before removing the stalled torrent",
439
+ "Re-search stalled torrents when StalledDelay is enabled and you want to re-search before removing the stalled torrent, or only after the torrent is removed.",
365
440
  "ReSearchStalled",
366
441
  False,
367
442
  )
@@ -579,12 +654,14 @@ def _gen_default_search_table(category: str, cat_default: Table):
579
654
  "SearchLimit",
580
655
  5,
581
656
  )
582
- _gen_default_line(
583
- search_table,
584
- "It will order searches by the year the EPISODE was first aired",
585
- "SearchByYear",
586
- True,
587
- )
657
+ # SearchByYear doesn't apply to Lidarr (music albums)
658
+ if "lidarr" not in category.lower():
659
+ _gen_default_line(
660
+ search_table,
661
+ "It will order searches by the year the EPISODE was first aired",
662
+ "SearchByYear",
663
+ True,
664
+ )
588
665
  _gen_default_line(
589
666
  search_table,
590
667
  "Reverse search order (Start searching oldest to newest)",
@@ -627,6 +704,7 @@ def _gen_default_search_table(category: str, cat_default: Table):
627
704
  True,
628
705
  )
629
706
  _gen_default_line(search_table, "Use Temp profile for missing", "UseTempForMissing", False)
707
+ _gen_default_line(search_table, "Don't change back to main profile", "KeepTempProfile", False)
630
708
  _gen_default_line(
631
709
  search_table,
632
710
  "Main quality profile (To pair quality profiles, ensure they are in the same order as in the temp profiles)",
@@ -642,9 +720,13 @@ def _gen_default_search_table(category: str, cat_default: Table):
642
720
  if "sonarr" in category.lower():
643
721
  _gen_default_line(
644
722
  search_table,
645
- "Search by series instead of by episode (This ignored the QualityUnmetSearch and CustomFormatUnmetSearch setting)",
723
+ [
724
+ "Search mode: true (always series search), false (always episode search), or 'smart' (automatic)",
725
+ "Smart mode: uses series search for entire seasons/series, episode search for single episodes",
726
+ "(Series search ignores QualityUnmetSearch and CustomFormatUnmetSearch settings)",
727
+ ],
646
728
  "SearchBySeries",
647
- True,
729
+ "smart",
648
730
  )
649
731
  _gen_default_line(
650
732
  search_table,
@@ -653,8 +735,10 @@ def _gen_default_search_table(category: str, cat_default: Table):
653
735
  "PrioritizeTodaysReleases",
654
736
  True,
655
737
  )
656
- _gen_default_ombi_table(category, search_table)
657
- _gen_default_overseerr_table(category, search_table)
738
+ # Ombi and Overseerr don't support music requests
739
+ if "lidarr" not in category.lower():
740
+ _gen_default_ombi_table(category, search_table)
741
+ _gen_default_overseerr_table(category, search_table)
658
742
  cat_default.add("EntrySearch", search_table)
659
743
 
660
744
 
@@ -775,11 +859,188 @@ class MyConfig:
775
859
  return values if values is not ... else default
776
860
 
777
861
 
778
- def _write_config_file(docker=False) -> pathlib.Path:
862
+ def _migrate_webui_config(config: MyConfig) -> bool:
863
+ """
864
+ Migrate WebUI configuration from old location (Settings section) to new location (WebUI section).
865
+ Returns True if any migration was performed, False otherwise.
866
+ """
867
+ migrated = False
868
+
869
+ # Check if WebUI section exists, if not create it
870
+ if "WebUI" not in config.config:
871
+ config.config["WebUI"] = table()
872
+
873
+ webui_section = config.config.get("WebUI", {})
874
+
875
+ # Migrate Host from Settings to WebUI
876
+ if "Host" not in webui_section:
877
+ old_host = config.get("Settings.Host", fallback=None)
878
+ if old_host is not None:
879
+ webui_section["Host"] = old_host
880
+ migrated = True
881
+ print(f"Migrated WebUI Host from Settings to WebUI section: {old_host}")
882
+
883
+ # Migrate Port from Settings to WebUI
884
+ if "Port" not in webui_section:
885
+ old_port = config.get("Settings.Port", fallback=None)
886
+ if old_port is not None:
887
+ webui_section["Port"] = old_port
888
+ migrated = True
889
+ print(f"Migrated WebUI Port from Settings to WebUI section: {old_port}")
890
+
891
+ # Migrate Token from Settings to WebUI
892
+ if "Token" not in webui_section:
893
+ old_token = config.get("Settings.Token", fallback=None)
894
+ if old_token is not None:
895
+ webui_section["Token"] = old_token
896
+ migrated = True
897
+ print(f"Migrated WebUI Token from Settings to WebUI section")
898
+
899
+ return migrated
900
+
901
+
902
+ def _normalize_theme_value(value: Any) -> str:
903
+ """
904
+ Normalize theme value to always be 'Light' or 'Dark' (case insensitive input).
905
+ """
906
+ if value is None:
907
+ return "Dark"
908
+ value_str = str(value).strip().lower()
909
+ if value_str == "light":
910
+ return "Light"
911
+ elif value_str == "dark":
912
+ return "Dark"
913
+ else:
914
+ # Default to Dark if invalid value
915
+ return "Dark"
916
+
917
+
918
+ def _validate_and_fill_config(config: MyConfig) -> bool:
919
+ """
920
+ Validate configuration and fill in missing values with defaults.
921
+ Returns True if any changes were made, False otherwise.
922
+ """
923
+ changed = False
924
+ defaults = config.defaults_config
925
+
926
+ # Helper function to ensure a config section exists
927
+ def ensure_section(section_name: str) -> None:
928
+ """Ensure a config section exists."""
929
+ if section_name not in config.config:
930
+ config.config[section_name] = table()
931
+
932
+ # Helper function to check and fill config values
933
+ def ensure_value(config_section: str, key: str, default_value: Any) -> bool:
934
+ """Ensure a config value exists, setting to default if missing."""
935
+ ensure_section(config_section)
936
+ section = config.config[config_section]
937
+
938
+ if key not in section or section[key] is None:
939
+ # Get the value from defaults if available
940
+ default_section = defaults.get(config_section, {})
941
+ if default_section and key in default_section:
942
+ default = default_section[key]
943
+ else:
944
+ default = default_value
945
+ section[key] = default
946
+ return True
947
+ return False
948
+
949
+ # Validate Settings section
950
+ settings_defaults = [
951
+ ("ConsoleLevel", "INFO"),
952
+ ("Logging", True),
953
+ ("CompletedDownloadFolder", "CHANGE_ME"),
954
+ ("FreeSpace", "-1"),
955
+ ("FreeSpaceFolder", "CHANGE_ME"),
956
+ ("AutoPauseResume", True),
957
+ ("NoInternetSleepTimer", 15),
958
+ ("LoopSleepTimer", 5),
959
+ ("SearchLoopDelay", -1),
960
+ ("FailedCategory", "failed"),
961
+ ("RecheckCategory", "recheck"),
962
+ ("Tagless", False),
963
+ ("IgnoreTorrentsYoungerThan", 600),
964
+ ("PingURLS", ["one.one.one.one", "dns.google.com"]),
965
+ ("FFprobeAutoUpdate", True),
966
+ ("AutoUpdateEnabled", False),
967
+ ("AutoUpdateCron", "0 3 * * 0"),
968
+ ]
969
+
970
+ for key, default in settings_defaults:
971
+ if ensure_value("Settings", key, default):
972
+ changed = True
973
+
974
+ # Validate WebUI section
975
+ webui_defaults = [
976
+ ("Host", "0.0.0.0"),
977
+ ("Port", 6969),
978
+ ("Token", ""),
979
+ ("LiveArr", True),
980
+ ("GroupSonarr", True),
981
+ ("GroupLidarr", True),
982
+ ("Theme", "Dark"),
983
+ ]
984
+
985
+ for key, default in webui_defaults:
986
+ if ensure_value("WebUI", key, default):
987
+ changed = True
988
+
989
+ # Normalize Theme value to always be capitalized (Light or Dark)
990
+ ensure_section("WebUI")
991
+ webui_section = config.config["WebUI"]
992
+ if "Theme" in webui_section:
993
+ current_theme = webui_section["Theme"]
994
+ normalized_theme = _normalize_theme_value(current_theme)
995
+ if current_theme != normalized_theme:
996
+ webui_section["Theme"] = normalized_theme
997
+ changed = True
998
+
999
+ # Validate qBit section
1000
+ qbit_defaults = [
1001
+ ("Disabled", False),
1002
+ ("Host", "localhost"),
1003
+ ("Port", 8105),
1004
+ ("UserName", ""),
1005
+ ("Password", ""),
1006
+ ]
1007
+
1008
+ for key, default in qbit_defaults:
1009
+ if ensure_value("qBit", key, default):
1010
+ changed = True
1011
+
1012
+ return changed
1013
+
1014
+
1015
+ def apply_config_migrations(config: MyConfig) -> None:
1016
+ """
1017
+ Apply all configuration migrations and validations.
1018
+ Saves the config if any changes were made.
1019
+ """
1020
+ changes_made = False
1021
+
1022
+ # Apply migrations
1023
+ if _migrate_webui_config(config):
1024
+ changes_made = True
1025
+
1026
+ # Validate and fill config
1027
+ if _validate_and_fill_config(config):
1028
+ changes_made = True
1029
+
1030
+ # Save if changes were made
1031
+ if changes_made:
1032
+ config.save()
1033
+ print("Configuration has been updated with migrations and defaults.")
1034
+
1035
+
1036
+ def _write_config_file(docker: bool = False) -> pathlib.Path:
779
1037
  doc = generate_doc()
780
- file_name = "config.rename_me.toml" if docker else "config.toml"
781
- config_file = HOME_PATH.joinpath(file_name)
782
- if config_file.exists() and not docker:
1038
+ config_file = HOME_PATH.joinpath("config.toml")
1039
+ if docker:
1040
+ if config_file.exists():
1041
+ print(f"{config_file} already exists, keeping current configuration.")
1042
+ return config_file
1043
+ elif config_file.exists():
783
1044
  print(f"{config_file} already exists, File is not being replaced.")
784
1045
  config_file = pathlib.Path.cwd().joinpath("config_new.toml")
785
1046
  config = MyConfig(config_file, config=doc)
qBitrr/logger.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import pathlib
4
5
  import time
5
6
  from logging import Logger
6
7
 
@@ -35,7 +36,7 @@ SUCCESS = 25
35
36
 
36
37
 
37
38
  class VerboseLogger(Logger):
38
- def _init__(self, *args, **kwargs):
39
+ def __init__(self, *args, **kwargs):
39
40
  super().__init__(*args, **kwargs)
40
41
  if self.name.startswith("qBitrr"):
41
42
  self.set_config_level()
@@ -83,10 +84,11 @@ logger = logging.getLogger("qBitrr.Misc")
83
84
 
84
85
 
85
86
  HAS_RUN = False
87
+ ALL_LOGS_HANDLER = None # Global handler for unified All.log file
86
88
 
87
89
 
88
- def run_logs(logger: Logger) -> None:
89
- global HAS_RUN
90
+ def run_logs(logger: Logger, _name: str = None) -> None:
91
+ global HAS_RUN, ALL_LOGS_HANDLER
90
92
  try:
91
93
  configkeys = {f"qBitrr.{i}" for i in CONFIG.sections()}
92
94
  key_length = max(len(max(configkeys, key=len)), 10)
@@ -118,6 +120,88 @@ def run_logs(logger: Logger) -> None:
118
120
  },
119
121
  reconfigure=True,
120
122
  )
123
+ logger.propagate = False
124
+ if ENABLE_LOGS:
125
+ # Initialize unified All.log handler once (first time run_logs is called)
126
+ if ALL_LOGS_HANDLER is None:
127
+ logs_folder = HOME_PATH.joinpath("logs")
128
+ logs_folder.mkdir(parents=True, exist_ok=True)
129
+ logs_folder.chmod(mode=0o777)
130
+ all_logfile = logs_folder.joinpath("All.log")
131
+ # Rotate old All.log if it exists
132
+ if all_logfile.exists():
133
+ all_logold = logs_folder.joinpath("All.log.old")
134
+ if all_logold.exists():
135
+ all_logold.unlink()
136
+ all_logfile.rename(all_logold)
137
+ # Create handler for All.log that all loggers will use
138
+ ALL_LOGS_HANDLER = logging.FileHandler(all_logfile)
139
+ ALL_LOGS_HANDLER.setFormatter(
140
+ coloredlogs.ColoredFormatter(
141
+ fmt="[%(asctime)-15s] " f"%(levelname)-8s: %(name)-{key_length}s: %(message)s",
142
+ level_styles={
143
+ "trace": {"color": "black", "bold": True},
144
+ "debug": {"color": "magenta", "bold": True},
145
+ "verbose": {"color": "blue", "bold": True},
146
+ "info": {"color": "white"},
147
+ "notice": {"color": "cyan"},
148
+ "hnotice": {"color": "cyan", "bold": True},
149
+ "warning": {"color": "yellow", "bold": True},
150
+ "success": {"color": "green", "bold": True},
151
+ "error": {"color": "red"},
152
+ "critical": {"color": "red", "bold": True},
153
+ },
154
+ field_styles={
155
+ "asctime": {"color": "green"},
156
+ "process": {"color": "magenta"},
157
+ "levelname": {"color": "red", "bold": True},
158
+ "name": {"color": "blue", "bold": True},
159
+ "thread": {"color": "cyan"},
160
+ },
161
+ )
162
+ )
163
+
164
+ # Add All.log handler to this logger (since propagate=False, we can't use root)
165
+ logger.addHandler(ALL_LOGS_HANDLER)
166
+
167
+ # Add individual component log file handler
168
+ if _name:
169
+ logs_folder = HOME_PATH.joinpath("logs")
170
+ logs_folder.mkdir(parents=True, exist_ok=True)
171
+ logs_folder.chmod(mode=0o777)
172
+ logfile = logs_folder.joinpath(_name + ".log")
173
+ if pathlib.Path(logfile).is_file():
174
+ logold = logs_folder.joinpath(_name + ".log.old")
175
+ if pathlib.Path(logold).exists():
176
+ logold.unlink()
177
+ logfile.rename(logold)
178
+ fh = logging.FileHandler(logfile)
179
+ # Use ColoredFormatter for file output to include ANSI colors in log files
180
+ fh.setFormatter(
181
+ coloredlogs.ColoredFormatter(
182
+ fmt="[%(asctime)-15s] " f"%(levelname)-8s: %(name)-{key_length}s: %(message)s",
183
+ level_styles={
184
+ "trace": {"color": "black", "bold": True},
185
+ "debug": {"color": "magenta", "bold": True},
186
+ "verbose": {"color": "blue", "bold": True},
187
+ "info": {"color": "white"},
188
+ "notice": {"color": "cyan"},
189
+ "hnotice": {"color": "cyan", "bold": True},
190
+ "warning": {"color": "yellow", "bold": True},
191
+ "success": {"color": "green", "bold": True},
192
+ "error": {"color": "red"},
193
+ "critical": {"color": "red", "bold": True},
194
+ },
195
+ field_styles={
196
+ "asctime": {"color": "green"},
197
+ "process": {"color": "magenta"},
198
+ "levelname": {"color": "red", "bold": True},
199
+ "name": {"color": "blue", "bold": True},
200
+ "thread": {"color": "cyan"},
201
+ },
202
+ )
203
+ )
204
+ logger.addHandler(fh)
121
205
  if HAS_RUN is False:
122
206
  HAS_RUN = True
123
207
  log_debugs(logger)