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,72 @@
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
+ """Quickstop script."""
20
+
21
+ import json
22
+ import warnings
23
+ from typing import TYPE_CHECKING, cast
24
+
25
+ from operate.quickstart.run_service import (
26
+ ask_password_if_needed,
27
+ configure_local_config,
28
+ get_service,
29
+ load_local_config,
30
+ )
31
+ from operate.quickstart.utils import print_section, print_title
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from operate.cli import OperateApp
36
+
37
+ warnings.filterwarnings("ignore", category=UserWarning)
38
+
39
+
40
+ def stop_service(
41
+ operate: "OperateApp", config_path: str, use_binary: bool = False
42
+ ) -> None:
43
+ """Stop service."""
44
+
45
+ with open(config_path, "r") as config_file:
46
+ template = json.load(config_file)
47
+
48
+ print_title(f"Stop {template['name']} Quickstart")
49
+
50
+ # check if agent was started before
51
+ config = load_local_config(
52
+ operate=operate, service_name=cast(str, template["name"])
53
+ )
54
+ if not config.path.exists():
55
+ print("No previous agent setup found. Exiting.")
56
+ return
57
+
58
+ ask_password_if_needed(operate)
59
+ configure_local_config(template, operate)
60
+ manager = operate.service_manager()
61
+ service = get_service(manager, template)
62
+ if use_binary:
63
+ use_docker = False
64
+ else:
65
+ use_docker = True
66
+
67
+ manager.stop_service_locally(
68
+ service_config_id=service.service_config_id, use_docker=use_docker, force=True
69
+ )
70
+
71
+ print()
72
+ print_section(f"{template['name']} service stopped")
@@ -0,0 +1,83 @@
1
+ # ------------------------------------------------------------------------------
2
+ #
3
+ # Copyright 2023-2024 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
+ """Terminate on-chain service."""
19
+
20
+ import json
21
+ from typing import TYPE_CHECKING, cast
22
+
23
+ from operate.operate_types import OnChainState
24
+ from operate.quickstart.run_service import (
25
+ ask_password_if_needed,
26
+ configure_local_config,
27
+ ensure_enough_funds,
28
+ get_service,
29
+ load_local_config,
30
+ )
31
+ from operate.quickstart.utils import ask_yes_or_no, print_section, print_title
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from operate.cli import OperateApp
36
+
37
+
38
+ def terminate_service(operate: "OperateApp", config_path: str) -> None:
39
+ """Terminate service."""
40
+
41
+ with open(config_path, "r") as config_file:
42
+ template = json.load(config_file)
43
+
44
+ print_title(f"Terminate {template['name']} on-chain service")
45
+
46
+ # check if agent was started before
47
+ config = load_local_config(
48
+ operate=operate, service_name=cast(str, template["name"])
49
+ )
50
+ if not config.path.exists():
51
+ print("No previous agent setup found. Exiting.")
52
+ return
53
+
54
+ if not ask_yes_or_no(
55
+ "Please, ensure that your service is stopped (./stop_service.sh) before proceeding. "
56
+ "Do you want to continue?"
57
+ ):
58
+ print("Cancelled.")
59
+ return
60
+
61
+ ask_password_if_needed(operate)
62
+ config = configure_local_config(template, operate)
63
+ manager = operate.service_manager()
64
+ service = get_service(manager, template)
65
+ ensure_enough_funds(operate, service)
66
+ manager.terminate_service_on_chain_from_safe(
67
+ service_config_id=service.service_config_id,
68
+ chain=config.principal_chain,
69
+ )
70
+
71
+ if (
72
+ manager._get_on_chain_state(service, config.principal_chain)
73
+ == OnChainState.PRE_REGISTRATION
74
+ ):
75
+ service_id = service.chain_configs[config.principal_chain].chain_data.token
76
+ print(
77
+ f"\nService {service_id} is now terminated and unbonded (i.e., it is on PRE-REGISTRATION state)."
78
+ f"You can check this on https://registry.olas.network/{config.principal_chain}/services/{service_id}."
79
+ "In order to deploy your on-chain service again, please run the service again'."
80
+ )
81
+
82
+ print()
83
+ print_section(f"{template['name']} service terminated")
@@ -0,0 +1,298 @@
1
+ # -*- coding: utf-8 -*-
2
+ # ------------------------------------------------------------------------------
3
+ #
4
+ # Copyright 2023-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
+ """Common utilities."""
20
+
21
+
22
+ import getpass
23
+ import os
24
+ from dataclasses import dataclass
25
+ from decimal import Decimal, ROUND_UP
26
+ from pathlib import Path
27
+ from typing import Dict, Optional, Union, get_args, get_origin
28
+
29
+ import requests
30
+ from halo import Halo # type: ignore[import] # pylint: disable=import-error
31
+
32
+ from operate.constants import ZERO_ADDRESS
33
+ from operate.ledger.profiles import OLAS, USDC
34
+ from operate.operate_types import Chain
35
+ from operate.resource import LocalResource, deserialize
36
+
37
+
38
+ def print_box(text: str, margin: int = 1, character: str = "=") -> None:
39
+ """Print text centered within a box."""
40
+
41
+ lines = text.split("\n")
42
+ text_length = max(len(line) for line in lines)
43
+ length = text_length + 2 * margin
44
+
45
+ border = character * length
46
+ margin_str = " " * margin
47
+
48
+ print()
49
+ print(border)
50
+ print(f"{margin_str}{text}{margin_str}")
51
+ print(border)
52
+ print()
53
+
54
+
55
+ def print_title(text: str) -> None:
56
+ """Print title."""
57
+ print_box(text, 4, "=")
58
+
59
+
60
+ def print_section(text: str) -> None:
61
+ """Print section."""
62
+ print_box(text, 1, "-")
63
+
64
+
65
+ def unit_to_wei(unit: float) -> int:
66
+ """Convert unit to Wei."""
67
+ return int(unit * 1e18)
68
+
69
+
70
+ CHAIN_TO_METADATA = {
71
+ "gnosis": {
72
+ "name": "Gnosis",
73
+ "gasFundReq": unit_to_wei(0.5), # fund for master EOA
74
+ "staking_bonding_token": OLAS[Chain.GNOSIS],
75
+ "token_data": {
76
+ ZERO_ADDRESS: {
77
+ "symbol": "xDAI",
78
+ "decimals": 18,
79
+ },
80
+ USDC[Chain.GNOSIS]: {
81
+ "symbol": "USDC",
82
+ "decimals": 6,
83
+ },
84
+ OLAS[Chain.GNOSIS]: {
85
+ "symbol": "OLAS",
86
+ "decimals": 18,
87
+ },
88
+ },
89
+ "gasParams": {
90
+ # this means default values will be used
91
+ "MAX_PRIORITY_FEE_PER_GAS": "",
92
+ "MAX_FEE_PER_GAS": "",
93
+ },
94
+ },
95
+ "mode": {
96
+ "name": "Mode",
97
+ "gasFundReq": unit_to_wei(0.005), # fund for master EOA
98
+ "staking_bonding_token": OLAS[Chain.MODE],
99
+ "token_data": {
100
+ ZERO_ADDRESS: {
101
+ "symbol": "ETH",
102
+ "decimals": 18,
103
+ },
104
+ USDC[Chain.MODE]: {
105
+ "symbol": "USDC",
106
+ "decimals": 6,
107
+ },
108
+ OLAS[Chain.MODE]: {
109
+ "symbol": "OLAS",
110
+ "decimals": 18,
111
+ },
112
+ },
113
+ "gasParams": {
114
+ # this means default values will be used
115
+ "MAX_PRIORITY_FEE_PER_GAS": "",
116
+ "MAX_FEE_PER_GAS": "",
117
+ },
118
+ },
119
+ "optimism": {
120
+ "name": "Optimism",
121
+ "gasFundReq": unit_to_wei(0.005), # fund for master EOA
122
+ "staking_bonding_token": OLAS[Chain.OPTIMISM],
123
+ "token_data": {
124
+ ZERO_ADDRESS: {
125
+ "symbol": "ETH",
126
+ "decimals": 18,
127
+ },
128
+ USDC[Chain.OPTIMISM]: {
129
+ "symbol": "USDC",
130
+ "decimals": 6,
131
+ },
132
+ OLAS[Chain.OPTIMISM]: {
133
+ "symbol": "OLAS",
134
+ "decimals": 18,
135
+ },
136
+ },
137
+ "gasParams": {
138
+ # this means default values will be used
139
+ "MAX_PRIORITY_FEE_PER_GAS": "",
140
+ "MAX_FEE_PER_GAS": "",
141
+ },
142
+ },
143
+ "base": {
144
+ "name": "Base",
145
+ "gasFundReq": unit_to_wei(0.005), # fund for master EOA
146
+ "staking_bonding_token": OLAS[Chain.BASE],
147
+ "token_data": {
148
+ ZERO_ADDRESS: {
149
+ "symbol": "ETH",
150
+ "decimals": 18,
151
+ },
152
+ USDC[Chain.BASE]: {
153
+ "symbol": "USDC",
154
+ "decimals": 6,
155
+ },
156
+ OLAS[Chain.BASE]: {
157
+ "symbol": "OLAS",
158
+ "decimals": 18,
159
+ },
160
+ },
161
+ "gasParams": {
162
+ # this means default values will be used
163
+ "MAX_PRIORITY_FEE_PER_GAS": "",
164
+ "MAX_FEE_PER_GAS": "",
165
+ },
166
+ },
167
+ }
168
+
169
+
170
+ def wei_to_unit(wei: int, chain: str, token_address: str = ZERO_ADDRESS) -> Decimal:
171
+ """Convert Wei to unit."""
172
+ unit: Decimal = (
173
+ Decimal(str(wei))
174
+ / 10 ** CHAIN_TO_METADATA[chain]["token_data"][token_address]["decimals"]
175
+ )
176
+ return unit.quantize(Decimal("0.000001"), rounding=ROUND_UP)
177
+
178
+
179
+ def wei_to_token(wei: int, chain: str, token_address: str = ZERO_ADDRESS) -> str:
180
+ """Convert Wei to token."""
181
+ return f"{wei_to_unit(wei, chain, token_address)} {CHAIN_TO_METADATA[chain]['token_data'][token_address]['symbol']}"
182
+
183
+
184
+ def ask_yes_or_no(question: str) -> bool:
185
+ """Ask a yes/no question."""
186
+ if os.environ.get("ATTENDED", "true").lower() != "true":
187
+ return True
188
+ while True:
189
+ response = input(f"{question} (yes/no): ").strip().lower()
190
+ if response.lower() in ("yes", "y"):
191
+ return True
192
+ if response.lower() in ("no", "n"):
193
+ return False
194
+
195
+
196
+ def ask_or_get_from_env(
197
+ prompt: str, is_pass: bool, env_var_name: str, raise_if_missing: bool = True
198
+ ) -> str:
199
+ """Get user input either interactively or from environment variables."""
200
+ if os.getenv("ATTENDED", "true").lower() == "true":
201
+ if is_pass:
202
+ return getpass.getpass(prompt).strip()
203
+ return input(prompt).strip()
204
+ if env_var_name in os.environ:
205
+ return os.environ[env_var_name].strip()
206
+ if raise_if_missing:
207
+ raise ValueError(f"{env_var_name} env var required in unattended mode")
208
+ return ""
209
+
210
+
211
+ def check_rpc(chain: str, rpc_url: Optional[str] = None) -> bool:
212
+ """Check RPC."""
213
+ if rpc_url is None:
214
+ return False
215
+
216
+ spinner = Halo(text=f"[{chain}] Checking RPC...", spinner="dots")
217
+ spinner.start()
218
+
219
+ rpc_data = {
220
+ "jsonrpc": "2.0",
221
+ "method": "eth_newFilter",
222
+ "params": ["invalid"],
223
+ "id": 1,
224
+ }
225
+
226
+ try:
227
+ response = requests.post(
228
+ rpc_url, json=rpc_data, headers={"Content-Type": "application/json"}
229
+ )
230
+ response.raise_for_status()
231
+ rpc_response = response.json()
232
+ except (requests.exceptions.RequestException, ValueError, TypeError) as e:
233
+ spinner.fail(f"Error: Failed to send {chain} RPC request: {e}")
234
+ return False
235
+
236
+ rpc_error_message = (
237
+ rpc_response.get("error", {})
238
+ .get("message", "exception processing rpc response")
239
+ .lower()
240
+ )
241
+
242
+ if rpc_error_message == "exception processing rpc response":
243
+ print(
244
+ "Error: The received rpc response is malformed. Please verify the RPC address and/or rpc behavior."
245
+ )
246
+ print(" Received response:")
247
+ print(" ", rpc_response)
248
+ print("")
249
+ spinner.fail(f"[{chain}] Terminating script.")
250
+ elif rpc_error_message == "out of requests":
251
+ print("Error: The provided rpc is out of requests.")
252
+ spinner.fail(f"[{chain}] Terminating script.")
253
+ elif (
254
+ rpc_error_message == "the method eth_newfilter does not exist/is not available"
255
+ ):
256
+ print("Error: The provided RPC does not support 'eth_newFilter'.")
257
+ spinner.fail(f"[{chain}] Terminating script.")
258
+ elif "invalid" in rpc_error_message or "params" in rpc_error_message:
259
+ spinner.succeed(f"[{chain}] RPC checks passed.")
260
+ return True
261
+ else:
262
+ print("Error: Unknown rpc error.")
263
+ print(" Received response:")
264
+ print(" ", rpc_response)
265
+ print("")
266
+ spinner.fail(f"[{chain}] Terminating script.")
267
+
268
+ return False
269
+
270
+
271
+ @dataclass
272
+ class QuickstartConfig(LocalResource):
273
+ """Local configuration."""
274
+
275
+ path: Path
276
+ rpc: Optional[Dict[str, str]] = None
277
+ staking_program_id: Optional[str] = None
278
+ principal_chain: Optional[str] = None
279
+ user_provided_args: Optional[Dict[str, str]] = None
280
+
281
+ @classmethod
282
+ def from_json(cls, obj: Dict) -> "LocalResource":
283
+ """Load LocalResource from json."""
284
+ kwargs = {}
285
+ for pname, ptype in cls.__annotations__.items():
286
+ if pname.startswith("_"):
287
+ continue
288
+
289
+ # allow for optional types
290
+ is_optional_type = get_origin(ptype) is Union and type(None) in get_args(
291
+ ptype
292
+ )
293
+ value = obj.get(pname, None)
294
+ if is_optional_type and value is None:
295
+ continue
296
+
297
+ kwargs[pname] = deserialize(obj=obj[pname], otype=ptype)
298
+ return cls(**kwargs)
operate/resource.py CHANGED
@@ -21,31 +21,56 @@
21
21
 
22
22
  import enum
23
23
  import json
24
+ import os
25
+ import platform
26
+ import shutil
27
+ import types
24
28
  import typing as t
25
29
  from dataclasses import asdict, is_dataclass
26
30
  from pathlib import Path
27
31
 
32
+ from operate.utils import safe_file_operation
33
+
28
34
 
29
35
  # pylint: disable=too-many-return-statements,no-member
30
36
 
31
37
 
38
+ N_BACKUPS = 5
39
+
40
+
32
41
  def serialize(obj: t.Any) -> t.Any:
33
42
  """Serialize object."""
34
43
  if is_dataclass(obj):
35
- return asdict(obj)
44
+ return serialize(asdict(obj))
36
45
  if isinstance(obj, Path):
37
46
  return str(obj)
38
47
  if isinstance(obj, dict):
39
- return {key: serialize(obj=value) for key, value in obj.items()}
48
+ return {serialize(key): serialize(obj=value) for key, value in obj.items()}
40
49
  if isinstance(obj, list):
41
50
  return [serialize(obj=value) for value in obj]
42
51
  if isinstance(obj, enum.Enum):
43
52
  return obj.value
53
+ if isinstance(obj, bytes):
54
+ return obj.hex()
44
55
  return obj
45
56
 
46
57
 
47
58
  def deserialize(obj: t.Any, otype: t.Any) -> t.Any:
48
59
  """Desrialize a json object."""
60
+
61
+ origin = getattr(otype, "__origin__", None)
62
+
63
+ # Handle Union and Optional
64
+ if origin is t.Union or isinstance(otype, types.UnionType):
65
+ for arg in t.get_args(otype):
66
+ if arg is type(None): # noqa: E721
67
+ continue
68
+ try:
69
+ return deserialize(obj, arg)
70
+ except Exception: # pylint: disable=broad-except # nosec
71
+ continue
72
+ return None
73
+
49
74
  base = getattr(otype, "__class__") # noqa: B009
50
75
  if base.__name__ == "_GenericAlias": # type: ignore
51
76
  args = otype.__args__ # type: ignore
@@ -65,6 +90,8 @@ def deserialize(obj: t.Any, otype: t.Any) -> t.Any:
65
90
  return Path(obj)
66
91
  if is_dataclass(otype):
67
92
  return otype.from_json(obj)
93
+ if otype is bytes:
94
+ return bytes.fromhex(obj)
68
95
  return obj
69
96
 
70
97
 
@@ -117,10 +144,42 @@ class LocalResource:
117
144
  if self._file is not None:
118
145
  path = path / self._file
119
146
 
120
- path.write_text(
147
+ bak0 = path.with_name(f"{path.name}.0.bak")
148
+
149
+ if path.exists() and not bak0.exists():
150
+ safe_file_operation(shutil.copy2, path, bak0)
151
+
152
+ tmp_path = path.parent / f".{path.name}.tmp"
153
+
154
+ # Clean up any existing tmp file
155
+ if tmp_path.exists():
156
+ safe_file_operation(tmp_path.unlink)
157
+
158
+ tmp_path.write_text(
121
159
  json.dumps(
122
160
  self.json,
123
161
  indent=2,
124
162
  ),
125
163
  encoding="utf-8",
126
164
  )
165
+
166
+ # Atomic replace to avoid corruption
167
+ try:
168
+ safe_file_operation(os.replace, tmp_path, path)
169
+ except (PermissionError, FileNotFoundError):
170
+ # On Windows, if the replace fails, clean up and skip
171
+ if platform.system() == "Windows":
172
+ safe_file_operation(tmp_path.unlink)
173
+
174
+ self.load(self.path) # Validate before making backup
175
+
176
+ # Rotate backup files
177
+ for i in reversed(range(N_BACKUPS - 1)):
178
+ newer = path.with_name(f"{path.name}.{i}.bak")
179
+ older = path.with_name(f"{path.name}.{i + 1}.bak")
180
+ if newer.exists():
181
+ if older.exists():
182
+ safe_file_operation(older.unlink)
183
+ safe_file_operation(newer.rename, older)
184
+
185
+ safe_file_operation(shutil.copy2, path, bak0)