olas-operate-middleware 0.1.0rc59__py3-none-any.whl → 0.13.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.
Files changed (98) hide show
  1. olas_operate_middleware-0.13.2.dist-info/METADATA +75 -0
  2. olas_operate_middleware-0.13.2.dist-info/RECORD +101 -0
  3. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/WHEEL +1 -1
  4. operate/__init__.py +17 -0
  5. operate/account/user.py +35 -9
  6. operate/bridge/bridge_manager.py +470 -0
  7. operate/bridge/providers/lifi_provider.py +377 -0
  8. operate/bridge/providers/native_bridge_provider.py +677 -0
  9. operate/bridge/providers/provider.py +469 -0
  10. operate/bridge/providers/relay_provider.py +457 -0
  11. operate/cli.py +1565 -417
  12. operate/constants.py +60 -12
  13. operate/data/README.md +19 -0
  14. operate/data/contracts/{service_staking_token → dual_staking_token}/__init__.py +2 -2
  15. operate/data/contracts/dual_staking_token/build/DualStakingToken.json +443 -0
  16. operate/data/contracts/dual_staking_token/contract.py +132 -0
  17. operate/data/contracts/dual_staking_token/contract.yaml +23 -0
  18. operate/{ledger/base.py → data/contracts/foreign_omnibridge/__init__.py} +2 -19
  19. operate/data/contracts/foreign_omnibridge/build/ForeignOmnibridge.json +1372 -0
  20. operate/data/contracts/foreign_omnibridge/contract.py +130 -0
  21. operate/data/contracts/foreign_omnibridge/contract.yaml +23 -0
  22. operate/{ledger/solana.py → data/contracts/home_omnibridge/__init__.py} +2 -20
  23. operate/data/contracts/home_omnibridge/build/HomeOmnibridge.json +1421 -0
  24. operate/data/contracts/home_omnibridge/contract.py +80 -0
  25. operate/data/contracts/home_omnibridge/contract.yaml +23 -0
  26. operate/data/contracts/l1_standard_bridge/__init__.py +20 -0
  27. operate/data/contracts/l1_standard_bridge/build/L1StandardBridge.json +831 -0
  28. operate/data/contracts/l1_standard_bridge/contract.py +158 -0
  29. operate/data/contracts/l1_standard_bridge/contract.yaml +23 -0
  30. operate/data/contracts/l2_standard_bridge/__init__.py +20 -0
  31. operate/data/contracts/l2_standard_bridge/build/L2StandardBridge.json +626 -0
  32. operate/data/contracts/l2_standard_bridge/contract.py +130 -0
  33. operate/data/contracts/l2_standard_bridge/contract.yaml +23 -0
  34. operate/data/contracts/mech_activity/__init__.py +20 -0
  35. operate/data/contracts/mech_activity/build/MechActivity.json +111 -0
  36. operate/data/contracts/mech_activity/contract.py +44 -0
  37. operate/data/contracts/mech_activity/contract.yaml +23 -0
  38. operate/data/contracts/optimism_mintable_erc20/__init__.py +20 -0
  39. operate/data/contracts/optimism_mintable_erc20/build/OptimismMintableERC20.json +491 -0
  40. operate/data/contracts/optimism_mintable_erc20/contract.py +45 -0
  41. operate/data/contracts/optimism_mintable_erc20/contract.yaml +23 -0
  42. operate/data/contracts/recovery_module/__init__.py +20 -0
  43. operate/data/contracts/recovery_module/build/RecoveryModule.json +811 -0
  44. operate/data/contracts/recovery_module/contract.py +61 -0
  45. operate/data/contracts/recovery_module/contract.yaml +23 -0
  46. operate/data/contracts/requester_activity_checker/__init__.py +20 -0
  47. operate/data/contracts/requester_activity_checker/build/RequesterActivityChecker.json +111 -0
  48. operate/data/contracts/requester_activity_checker/contract.py +33 -0
  49. operate/data/contracts/requester_activity_checker/contract.yaml +23 -0
  50. operate/data/contracts/staking_token/__init__.py +20 -0
  51. operate/data/contracts/staking_token/build/StakingToken.json +1336 -0
  52. operate/data/contracts/{service_staking_token → staking_token}/contract.py +27 -13
  53. operate/data/contracts/staking_token/contract.yaml +23 -0
  54. operate/data/contracts/uniswap_v2_erc20/contract.yaml +3 -1
  55. operate/data/contracts/uniswap_v2_erc20/tests/__init__.py +20 -0
  56. operate/data/contracts/uniswap_v2_erc20/tests/test_contract.py +363 -0
  57. operate/keys.py +118 -33
  58. operate/ledger/__init__.py +159 -56
  59. operate/ledger/profiles.py +321 -18
  60. operate/migration.py +555 -0
  61. operate/{http → operate_http}/__init__.py +3 -2
  62. operate/{http → operate_http}/exceptions.py +6 -4
  63. operate/operate_types.py +544 -0
  64. operate/pearl.py +13 -1
  65. operate/quickstart/analyse_logs.py +118 -0
  66. operate/quickstart/claim_staking_rewards.py +104 -0
  67. operate/quickstart/reset_configs.py +106 -0
  68. operate/quickstart/reset_password.py +70 -0
  69. operate/quickstart/reset_staking.py +145 -0
  70. operate/quickstart/run_service.py +726 -0
  71. operate/quickstart/stop_service.py +72 -0
  72. operate/quickstart/terminate_on_chain_service.py +83 -0
  73. operate/quickstart/utils.py +298 -0
  74. operate/resource.py +62 -3
  75. operate/services/agent_runner.py +202 -0
  76. operate/services/deployment_runner.py +868 -0
  77. operate/services/funding_manager.py +929 -0
  78. operate/services/health_checker.py +280 -0
  79. operate/services/manage.py +2356 -620
  80. operate/services/protocol.py +1246 -340
  81. operate/services/service.py +756 -391
  82. operate/services/utils/mech.py +103 -0
  83. operate/services/utils/tendermint.py +86 -12
  84. operate/settings.py +70 -0
  85. operate/utils/__init__.py +135 -0
  86. operate/utils/gnosis.py +407 -80
  87. operate/utils/single_instance.py +226 -0
  88. operate/utils/ssl.py +133 -0
  89. operate/wallet/master.py +708 -123
  90. operate/wallet/wallet_recovery_manager.py +507 -0
  91. olas_operate_middleware-0.1.0rc59.dist-info/METADATA +0 -304
  92. olas_operate_middleware-0.1.0rc59.dist-info/RECORD +0 -41
  93. operate/data/contracts/service_staking_token/build/ServiceStakingToken.json +0 -1273
  94. operate/data/contracts/service_staking_token/contract.yaml +0 -23
  95. operate/ledger/ethereum.py +0 -48
  96. operate/types.py +0 -260
  97. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info}/entry_points.txt +0 -0
  98. {olas_operate_middleware-0.1.0rc59.dist-info → olas_operate_middleware-0.13.2.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,103 @@
1
+ # ------------------------------------------------------------------------------
2
+ #
3
+ # Copyright 2023-2025 Valory AG
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # ------------------------------------------------------------------------------
18
+ """Utilities for the Mech service."""
19
+
20
+
21
+ from logging import getLogger
22
+ from typing import Tuple
23
+
24
+ import requests
25
+ from aea_ledger_ethereum import Web3
26
+
27
+ from operate.constants import MECH_MARKETPLACE_JSON_URL
28
+ from operate.operate_types import Chain
29
+ from operate.quickstart.utils import print_section
30
+ from operate.services.protocol import EthSafeTxBuilder
31
+ from operate.services.service import Service
32
+ from operate.utils.gnosis import SafeOperation
33
+
34
+
35
+ MECH_FACTORY_ADDRESS = {
36
+ Chain.GNOSIS: {
37
+ "0xad380C51cd5297FbAE43494dD5D407A2a3260b58": {
38
+ "Native": "0x42f43be9E5E50df51b86C5c6427223ff565f40C6",
39
+ "Token": "0x161b862568E900Dd9d8c64364F3B83a43792e50f",
40
+ "Nevermined": "0xCB26B91B0E21ADb04FFB6e5f428f41858c64936A",
41
+ },
42
+ "0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB": {
43
+ "Native": "0x8b299c20F87e3fcBfF0e1B86dC0acC06AB6993EF",
44
+ "Token": "0x31ffDC795FDF36696B8eDF7583A3D115995a45FA",
45
+ "Nevermined": "0x65fd74C29463afe08c879a3020323DD7DF02DA57",
46
+ },
47
+ }
48
+ }
49
+
50
+ logger = getLogger(__name__)
51
+
52
+
53
+ def deploy_mech(sftxb: EthSafeTxBuilder, service: Service) -> Tuple[str, str]:
54
+ """Deploy the Mech service."""
55
+ print_section("Creating a new Mech On Chain")
56
+
57
+ # Get the mech type from service config
58
+ mech_type = service.env_variables.get("MECH_TYPE", {}).get("value", "Native")
59
+
60
+ abi = requests.get(MECH_MARKETPLACE_JSON_URL).json()["abi"]
61
+ chain = Chain.from_string(service.home_chain)
62
+ mech_marketplace_address = service.env_variables["MECH_MARKETPLACE_ADDRESS"][
63
+ "value"
64
+ ]
65
+ # Get factory address based on mech type
66
+ if mech_marketplace_address not in MECH_FACTORY_ADDRESS[chain]:
67
+ logger.warning(
68
+ f"The given {mech_marketplace_address=} is not supported. "
69
+ f"Defaulting back to 0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB."
70
+ )
71
+ mech_marketplace_address = "0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB"
72
+
73
+ mech_factory_address = MECH_FACTORY_ADDRESS[chain][mech_marketplace_address][
74
+ mech_type
75
+ ]
76
+
77
+ mech_request_price = int(
78
+ service.env_variables.get("MECH_REQUEST_PRICE", {}).get(
79
+ "value", 10000000000000000
80
+ )
81
+ )
82
+ contract = sftxb.ledger_api.api.eth.contract(
83
+ address=Web3.to_checksum_address(mech_marketplace_address), abi=abi
84
+ )
85
+ data = contract.encodeABI(
86
+ "create",
87
+ args=[
88
+ service.chain_configs[service.home_chain].chain_data.token,
89
+ Web3.to_checksum_address(mech_factory_address),
90
+ mech_request_price.to_bytes(32, byteorder="big"),
91
+ ],
92
+ )
93
+ tx_dict = {
94
+ "to": mech_marketplace_address,
95
+ "data": data,
96
+ "value": 0,
97
+ "operation": SafeOperation.CALL,
98
+ }
99
+ receipt = sftxb.new_tx().add(tx_dict).settle()
100
+ event = contract.events.CreateMech().process_receipt(receipt)[0]
101
+ mech_address = event["args"]["mech"]
102
+ agent_id = sftxb.info(token_id=event["args"]["serviceId"])["canonical_agents"][0]
103
+ return mech_address, agent_id
@@ -18,8 +18,10 @@
18
18
  # ------------------------------------------------------------------------------
19
19
 
20
20
  """Tendermint manager."""
21
+ import contextlib
21
22
  import json
22
23
  import logging
24
+ import multiprocessing
23
25
  import os
24
26
  import platform
25
27
  import re
@@ -29,9 +31,11 @@ import stat
29
31
  import subprocess # nosec:
30
32
  import sys
31
33
  import traceback
34
+ from http import HTTPStatus
32
35
  from logging import Logger
33
36
  from pathlib import Path
34
37
  from threading import Event, Thread
38
+ from time import sleep
35
39
  from typing import Any, Callable, Dict, List, Optional, Tuple, cast
36
40
 
37
41
  import requests
@@ -306,12 +310,12 @@ class TendermintNode:
306
310
  """Stop a monitoring process."""
307
311
  if self._monitoring is not None:
308
312
  self._monitoring.stop() # set stop event
309
- self._monitoring.join()
313
+ self._monitoring.join(timeout=20)
310
314
 
311
315
  def stop(self) -> None:
312
316
  """Stop a Tendermint node process."""
313
- self._stop_tm_process()
314
317
  self._stop_monitoring_thread()
318
+ self._stop_tm_process()
315
319
 
316
320
  @staticmethod
317
321
  def _write_to_console(line: str) -> None:
@@ -503,6 +507,9 @@ def create_app( # pylint: disable=too-many-statements
503
507
  )
504
508
 
505
509
  app = Flask(__name__) # pylint: disable=redefined-outer-name
510
+ app._is_on_exit = ( # pylint: disable=protected-access
511
+ False # ugly but better than global ver
512
+ )
506
513
  period_dumper = PeriodDumper(
507
514
  logger=app.logger,
508
515
  dump_dir=Path(os.environ["TMSTATE"]),
@@ -570,12 +577,20 @@ def create_app( # pylint: disable=too-many-statements
570
577
  @app.route("/gentle_reset")
571
578
  def gentle_reset() -> Tuple[Any, int]:
572
579
  """Reset the tendermint node gently."""
580
+ if app._is_on_exit: # pylint: disable=protected-access
581
+ raise RuntimeError("server exit now")
573
582
  try:
574
583
  tendermint_node.stop()
575
584
  tendermint_node.start()
576
- return jsonify({"message": "Reset successful.", "status": True}), 200
585
+ return (
586
+ jsonify({"message": "Reset successful.", "status": True}),
587
+ HTTPStatus.OK,
588
+ )
577
589
  except Exception as e: # pylint: disable=W0703
578
- return jsonify({"message": f"Reset failed: {e}", "status": False}), 200
590
+ return (
591
+ jsonify({"message": f"Reset failed: {e}", "status": False}),
592
+ HTTPStatus.OK,
593
+ )
579
594
 
580
595
  @app.route("/app_hash")
581
596
  def app_hash() -> Tuple[Any, int]:
@@ -597,6 +612,8 @@ def create_app( # pylint: disable=too-many-statements
597
612
  @app.route("/hard_reset")
598
613
  def hard_reset() -> Tuple[Any, int]:
599
614
  """Reset the node forcefully, and prune the blocks"""
615
+ if app._is_on_exit: # pylint: disable=protected-access
616
+ raise RuntimeError("server exit now")
600
617
  try:
601
618
  tendermint_node.stop()
602
619
  if IS_DEV_MODE:
@@ -614,21 +631,33 @@ def create_app( # pylint: disable=too-many-statements
614
631
  request.args.get("period_count", "0"),
615
632
  )
616
633
  tendermint_node.start()
617
- return jsonify({"message": "Reset successful.", "status": True}), 200
634
+ return (
635
+ jsonify({"message": "Reset successful.", "status": True}),
636
+ HTTPStatus.OK,
637
+ )
618
638
  except Exception as e: # pylint: disable=W0703
619
- return jsonify({"message": f"Reset failed: {e}", "status": False}), 200
639
+ return (
640
+ jsonify({"message": f"Reset failed: {e}", "status": False}),
641
+ HTTPStatus.OK,
642
+ )
620
643
 
621
- @app.errorhandler(404) # type: ignore
644
+ @app.errorhandler(HTTPStatus.NOT_FOUND) # type: ignore
622
645
  def handle_notfound(e: NotFound) -> Response:
623
646
  """Handle server error."""
624
647
  cast(logging.Logger, app.logger).info(e) # pylint: disable=E
625
- return Response("Not Found", status=404, mimetype="application/json")
648
+ return Response(
649
+ "Not Found", status=HTTPStatus.NOT_FOUND, mimetype="application/json"
650
+ )
626
651
 
627
- @app.errorhandler(500) # type: ignore
652
+ @app.errorhandler(HTTPStatus.INTERNAL_SERVER_ERROR) # type: ignore
628
653
  def handle_server_error(e: InternalServerError) -> Response:
629
654
  """Handle server error."""
630
655
  cast(logging.Logger, app.logger).info(e) # pylint: disable=E
631
- return Response("Error Closing Node", status=500, mimetype="application/json")
656
+ return Response(
657
+ "Error Closing Node",
658
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
659
+ mimetype="application/json",
660
+ )
632
661
 
633
662
  return app, tendermint_node
634
663
 
@@ -639,7 +668,52 @@ def create_server() -> Any:
639
668
  return flask_app
640
669
 
641
670
 
642
- if __name__ == "__main__":
643
- # Start the Flask server programmatically
671
+ def run_app_in_subprocess(q: multiprocessing.Queue) -> None:
672
+ """Run flask app in a subprocess to kill it when needed."""
673
+ print("app in subprocess")
674
+ app, tendermint_node = create_app()
675
+
676
+ @app.route("/exit")
677
+ def handle_server_exit() -> Response:
678
+ """Handle server exit."""
679
+ app._is_on_exit = True # pylint: disable=protected-access
680
+ try:
681
+ tendermint_node.stop()
682
+ finally:
683
+ q.put(True)
684
+ return {"node": "stopped"}
685
+
686
+ app.run(host="localhost", port=8080)
687
+
688
+
689
+ def run_stoppable_main() -> None:
690
+ """Main to spawn flask in a subprocess."""
691
+ print("run stoppable main!")
692
+ q: multiprocessing.Queue = multiprocessing.Queue()
693
+ p = multiprocessing.Process(target=run_app_in_subprocess, args=(q,))
694
+ p.start()
695
+ # wait for stop marker
696
+ try:
697
+ q.get(block=True)
698
+ sleep(1)
699
+ finally:
700
+ p.terminate()
701
+ with contextlib.suppress(Exception):
702
+ p.join(timeout=10)
703
+ p.terminate()
704
+
705
+
706
+ def main() -> None:
707
+ """Main entrance."""
644
708
  app = create_server()
645
709
  app.run(host="localhost", port=8080)
710
+
711
+
712
+ if __name__ == "__main__":
713
+ # Start the Flask server programmatically
714
+
715
+ with contextlib.suppress(Exception):
716
+ # support for pyinstaller multiprocessing
717
+ multiprocessing.freeze_support()
718
+
719
+ run_stoppable_main()
operate/settings.py ADDED
@@ -0,0 +1,70 @@
1
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------------------------
3
+ #
4
+ # Copyright 2024 Valory AG
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ # ------------------------------------------------------------------------------
19
+ """Settings for operate."""
20
+
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ from operate.constants import SETTINGS_JSON
25
+ from operate.ledger.profiles import DEFAULT_EOA_TOPUPS
26
+ from operate.operate_types import ChainAmounts
27
+ from operate.resource import LocalResource
28
+
29
+
30
+ SETTINGS_JSON_VERSION = 1
31
+ DEFAULT_SETTINGS = {
32
+ "version": SETTINGS_JSON_VERSION,
33
+ "eoa_topups": DEFAULT_EOA_TOPUPS,
34
+ }
35
+
36
+
37
+ class Settings(LocalResource):
38
+ """Settings for operate."""
39
+
40
+ _file = SETTINGS_JSON
41
+
42
+ version: int
43
+ eoa_topups: Dict[str, Dict[str, int]]
44
+
45
+ def __init__(self, path: Optional[Path] = None, **kwargs: Any) -> None:
46
+ """Initialize settings."""
47
+ super().__init__(path=path)
48
+ if path is not None and (path / self._file).exists():
49
+ self.load(path)
50
+
51
+ for key, default_value in DEFAULT_SETTINGS.items():
52
+ value = kwargs.get(key, default_value)
53
+ if not hasattr(self, key):
54
+ setattr(self, key, value)
55
+
56
+ if self.version != SETTINGS_JSON_VERSION:
57
+ raise ValueError(
58
+ f"Settings version {self.version} is not supported. Expected version {SETTINGS_JSON_VERSION}."
59
+ )
60
+
61
+ def get_eoa_topups(self, with_safe: bool = False) -> ChainAmounts:
62
+ """Get the EOA topups."""
63
+ return (
64
+ self.eoa_topups
65
+ if with_safe
66
+ else {
67
+ chain: {asset: amount * 2 for asset, amount in asset_amount.items()}
68
+ for chain, asset_amount in self.eoa_topups.items()
69
+ }
70
+ )
operate/utils/__init__.py CHANGED
@@ -18,3 +18,138 @@
18
18
  # ------------------------------------------------------------------------------
19
19
 
20
20
  """Helper utilities."""
21
+
22
+ import os
23
+ import platform
24
+ import shutil
25
+ import time
26
+ import typing as t
27
+ from pathlib import Path
28
+ from threading import Lock
29
+
30
+
31
+ class SingletonMeta(type):
32
+ """A metaclass for creating thread-safe singleton classes."""
33
+
34
+ _instances: t.Dict[t.Type, t.Any] = {}
35
+ _lock: Lock = Lock()
36
+
37
+ def __call__(cls, *args: t.Any, **kwargs: t.Any) -> t.Any:
38
+ """Override the __call__ method to control instance creation."""
39
+ with cls._lock:
40
+ if cls not in cls._instances:
41
+ cls._instances[cls] = super().__call__(*args, **kwargs)
42
+ return cls._instances[cls]
43
+
44
+
45
+ def create_backup(path: Path) -> Path:
46
+ """Creates a backup of the specified path.
47
+
48
+ This function creates a backup of a file or directory by copying it and appending
49
+ the current UNIX timestamp followed by the '.bak' suffix.
50
+ """
51
+
52
+ path = path.resolve()
53
+
54
+ if not path.exists():
55
+ raise FileNotFoundError(f"{path} does not exist")
56
+
57
+ timestamp = int(time.time())
58
+ backup_path = path.with_name(f"{path.name}.{timestamp}.bak")
59
+
60
+ if path.is_dir():
61
+ shutil.copytree(path, backup_path)
62
+ else:
63
+ shutil.copy2(path, backup_path)
64
+
65
+ return backup_path
66
+
67
+
68
+ NestedDict = t.Union[int, t.Dict[str, "NestedDict"]]
69
+
70
+
71
+ def merge_sum_dicts(*dicts: t.Dict[str, NestedDict]) -> t.Dict[str, NestedDict]:
72
+ """
73
+ Merge a list of nested dicts by summing all innermost `int` values.
74
+
75
+ Supports arbitrary depth; keys not in all dicts are still included.
76
+ Missing values are treated as 0.
77
+ All `dicts` must follow the same nesting structure.
78
+ """
79
+
80
+ result: t.Dict[str, NestedDict] = {}
81
+ for d in dicts:
82
+ for k, v in d.items(): # type: ignore
83
+ if isinstance(v, dict):
84
+ result[k] = merge_sum_dicts(result.get(k, {}), v) # type: ignore
85
+ elif isinstance(v, int):
86
+ result[k] = result.get(k, 0) + v # type: ignore
87
+ return result
88
+
89
+
90
+ def subtract_dicts(
91
+ a: t.Dict[str, NestedDict], b: t.Dict[str, NestedDict]
92
+ ) -> t.Dict[str, NestedDict]:
93
+ """
94
+ Recursively subtract values in `b` from `a`. Negative results are upper bounded at 0.
95
+
96
+ Supports arbitrary depth; keys not in all dicts are still included.
97
+ Missing values are treated as 0.
98
+ All `dicts` must follow the same nesting structure.
99
+ """
100
+
101
+ result: t.Dict[str, NestedDict] = {}
102
+ for key in a.keys() | b.keys(): # type: ignore
103
+ va = a.get(key) # type: ignore
104
+ vb = b.get(key) # type: ignore
105
+ if isinstance(va, dict) or isinstance(vb, dict):
106
+ result[key] = subtract_dicts(
107
+ va if isinstance(va, dict) else {}, vb if isinstance(vb, dict) else {}
108
+ )
109
+ else:
110
+ result[key] = max((va or 0) - (vb or 0), 0) # type: ignore
111
+ return result
112
+
113
+
114
+ def safe_file_operation(operation: t.Callable, *args: t.Any, **kwargs: t.Any) -> None:
115
+ """Safely perform file operation with retries on Windows."""
116
+ max_retries = 3 if platform.system() == "Windows" else 1
117
+
118
+ for attempt in range(max_retries):
119
+ try:
120
+ operation(*args, **kwargs)
121
+ return
122
+ except (PermissionError, FileNotFoundError, OSError) as e:
123
+ if attempt == max_retries - 1:
124
+ raise e
125
+
126
+ if platform.system() == "Windows":
127
+ # On Windows, wait a bit and retry
128
+ time.sleep(0.1)
129
+
130
+
131
+ def unrecoverable_delete(file_path: Path, passes: int = 3) -> None:
132
+ """Delete a file unrecoverably."""
133
+ if not file_path.exists():
134
+ return
135
+
136
+ if not file_path.is_file():
137
+ raise ValueError(f"{file_path} is not a file")
138
+
139
+ try:
140
+ file_size = os.path.getsize(file_path)
141
+
142
+ with open(file_path, "r+b") as f:
143
+ for _ in range(passes):
144
+ # Overwrite with random bytes
145
+ f.seek(0)
146
+ random_data = os.urandom(file_size)
147
+ f.write(random_data)
148
+ f.flush() # Ensure data is written to disk
149
+
150
+ # Finally, delete the file
151
+ safe_file_operation(os.remove, file_path)
152
+ except PermissionError:
153
+ print(f"Permission denied to securely delete file '{file_path}'.")
154
+ except Exception as e: # pylint: disable=broad-except
155
+ print(f"Error during secure deletion of '{file_path}': {e}")