rucio-clients 37.0.0rc1__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.

Potentially problematic release.


This version of rucio-clients might be problematic. Click here for more details.

Files changed (104) hide show
  1. rucio/__init__.py +17 -0
  2. rucio/alembicrevision.py +15 -0
  3. rucio/cli/__init__.py +14 -0
  4. rucio/cli/account.py +216 -0
  5. rucio/cli/bin_legacy/__init__.py +13 -0
  6. rucio/cli/bin_legacy/rucio.py +2825 -0
  7. rucio/cli/bin_legacy/rucio_admin.py +2500 -0
  8. rucio/cli/command.py +272 -0
  9. rucio/cli/config.py +72 -0
  10. rucio/cli/did.py +191 -0
  11. rucio/cli/download.py +128 -0
  12. rucio/cli/lifetime_exception.py +33 -0
  13. rucio/cli/replica.py +162 -0
  14. rucio/cli/rse.py +293 -0
  15. rucio/cli/rule.py +158 -0
  16. rucio/cli/scope.py +40 -0
  17. rucio/cli/subscription.py +73 -0
  18. rucio/cli/upload.py +60 -0
  19. rucio/cli/utils.py +226 -0
  20. rucio/client/__init__.py +15 -0
  21. rucio/client/accountclient.py +432 -0
  22. rucio/client/accountlimitclient.py +183 -0
  23. rucio/client/baseclient.py +983 -0
  24. rucio/client/client.py +120 -0
  25. rucio/client/configclient.py +126 -0
  26. rucio/client/credentialclient.py +59 -0
  27. rucio/client/didclient.py +868 -0
  28. rucio/client/diracclient.py +56 -0
  29. rucio/client/downloadclient.py +1783 -0
  30. rucio/client/exportclient.py +44 -0
  31. rucio/client/fileclient.py +50 -0
  32. rucio/client/importclient.py +42 -0
  33. rucio/client/lifetimeclient.py +90 -0
  34. rucio/client/lockclient.py +109 -0
  35. rucio/client/metaconventionsclient.py +140 -0
  36. rucio/client/pingclient.py +44 -0
  37. rucio/client/replicaclient.py +452 -0
  38. rucio/client/requestclient.py +125 -0
  39. rucio/client/richclient.py +317 -0
  40. rucio/client/rseclient.py +746 -0
  41. rucio/client/ruleclient.py +294 -0
  42. rucio/client/scopeclient.py +90 -0
  43. rucio/client/subscriptionclient.py +173 -0
  44. rucio/client/touchclient.py +82 -0
  45. rucio/client/uploadclient.py +969 -0
  46. rucio/common/__init__.py +13 -0
  47. rucio/common/bittorrent.py +234 -0
  48. rucio/common/cache.py +111 -0
  49. rucio/common/checksum.py +168 -0
  50. rucio/common/client.py +122 -0
  51. rucio/common/config.py +788 -0
  52. rucio/common/constants.py +217 -0
  53. rucio/common/constraints.py +17 -0
  54. rucio/common/didtype.py +237 -0
  55. rucio/common/exception.py +1208 -0
  56. rucio/common/extra.py +31 -0
  57. rucio/common/logging.py +420 -0
  58. rucio/common/pcache.py +1409 -0
  59. rucio/common/plugins.py +185 -0
  60. rucio/common/policy.py +93 -0
  61. rucio/common/schema/__init__.py +200 -0
  62. rucio/common/schema/generic.py +416 -0
  63. rucio/common/schema/generic_multi_vo.py +395 -0
  64. rucio/common/stomp_utils.py +423 -0
  65. rucio/common/stopwatch.py +55 -0
  66. rucio/common/test_rucio_server.py +154 -0
  67. rucio/common/types.py +483 -0
  68. rucio/common/utils.py +1688 -0
  69. rucio/rse/__init__.py +96 -0
  70. rucio/rse/protocols/__init__.py +13 -0
  71. rucio/rse/protocols/bittorrent.py +194 -0
  72. rucio/rse/protocols/cache.py +111 -0
  73. rucio/rse/protocols/dummy.py +100 -0
  74. rucio/rse/protocols/gfal.py +708 -0
  75. rucio/rse/protocols/globus.py +243 -0
  76. rucio/rse/protocols/http_cache.py +82 -0
  77. rucio/rse/protocols/mock.py +123 -0
  78. rucio/rse/protocols/ngarc.py +209 -0
  79. rucio/rse/protocols/posix.py +250 -0
  80. rucio/rse/protocols/protocol.py +361 -0
  81. rucio/rse/protocols/rclone.py +365 -0
  82. rucio/rse/protocols/rfio.py +145 -0
  83. rucio/rse/protocols/srm.py +338 -0
  84. rucio/rse/protocols/ssh.py +414 -0
  85. rucio/rse/protocols/storm.py +195 -0
  86. rucio/rse/protocols/webdav.py +594 -0
  87. rucio/rse/protocols/xrootd.py +302 -0
  88. rucio/rse/rsemanager.py +881 -0
  89. rucio/rse/translation.py +260 -0
  90. rucio/vcsversion.py +11 -0
  91. rucio/version.py +45 -0
  92. rucio_clients-37.0.0rc1.data/data/etc/rse-accounts.cfg.template +25 -0
  93. rucio_clients-37.0.0rc1.data/data/etc/rucio.cfg.atlas.client.template +43 -0
  94. rucio_clients-37.0.0rc1.data/data/etc/rucio.cfg.template +241 -0
  95. rucio_clients-37.0.0rc1.data/data/requirements.client.txt +19 -0
  96. rucio_clients-37.0.0rc1.data/data/rucio_client/merge_rucio_configs.py +144 -0
  97. rucio_clients-37.0.0rc1.data/scripts/rucio +133 -0
  98. rucio_clients-37.0.0rc1.data/scripts/rucio-admin +97 -0
  99. rucio_clients-37.0.0rc1.dist-info/METADATA +54 -0
  100. rucio_clients-37.0.0rc1.dist-info/RECORD +104 -0
  101. rucio_clients-37.0.0rc1.dist-info/WHEEL +5 -0
  102. rucio_clients-37.0.0rc1.dist-info/licenses/AUTHORS.rst +100 -0
  103. rucio_clients-37.0.0rc1.dist-info/licenses/LICENSE +201 -0
  104. rucio_clients-37.0.0rc1.dist-info/top_level.txt +1 -0
rucio/common/utils.py ADDED
@@ -0,0 +1,1688 @@
1
+ # Copyright European Organization for Nuclear Research (CERN) since 2012
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import argparse
16
+ import base64
17
+ import datetime
18
+ import errno
19
+ import getpass
20
+ import ipaddress
21
+ import itertools
22
+ import json
23
+ import logging
24
+ import os
25
+ import os.path
26
+ import re
27
+ import signal
28
+ import socket
29
+ import subprocess
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ from collections import OrderedDict
34
+ from enum import Enum
35
+ from functools import wraps
36
+ from io import StringIO
37
+ from itertools import zip_longest
38
+ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
39
+ from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
40
+ from uuid import uuid4 as uuid
41
+ from xml.etree import ElementTree
42
+
43
+ import requests
44
+
45
+ from rucio.common.config import config_get
46
+ from rucio.common.exception import DIDFilterSyntaxError, DuplicateCriteriaInDIDFilter, InputValidationError, InvalidType, MetalinkJsonParsingError, MissingModuleException, RucioException
47
+ from rucio.common.extra import import_extras
48
+ from rucio.common.plugins import PolicyPackageAlgorithms
49
+ from rucio.common.types import InternalAccount, InternalScope, LFNDict, TraceDict
50
+
51
+ EXTRA_MODULES = import_extras(['paramiko'])
52
+
53
+ if EXTRA_MODULES['paramiko']:
54
+ try:
55
+ from paramiko import RSAKey
56
+ except Exception:
57
+ EXTRA_MODULES['paramiko'] = None
58
+
59
+ if TYPE_CHECKING:
60
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
61
+ T = TypeVar('T')
62
+ HashableKT = TypeVar('HashableKT')
63
+ HashableVT = TypeVar('HashableVT')
64
+ from _typeshed import FileDescriptorOrPath
65
+ from sqlalchemy.orm import Session
66
+
67
+ from rucio.common.types import LoggerFunction
68
+
69
+
70
+ # HTTP code dictionary. Not complete. Can be extended if needed.
71
+ codes = {
72
+ # Informational.
73
+ 200: '200 OK',
74
+ 201: '201 Created',
75
+ 202: '202 Accepted',
76
+
77
+ # Client Error.
78
+ 400: '400 Bad Request',
79
+ 401: '401 Unauthorized',
80
+ 403: '403 Forbidden',
81
+ 404: '404 Not Found',
82
+ 405: '405 Method Not Allowed',
83
+ 406: '406 Not Acceptable',
84
+ 408: '408 Request Timeout',
85
+ 409: '409 Conflict',
86
+ 410: '410 Gone',
87
+
88
+ # Server Error.
89
+ 500: '500 Internal Server Error',
90
+ 501: '501 Not Implemented',
91
+ 502: '502 Bad Gateway',
92
+ 503: '503 Service Unavailable',
93
+ 504: '504 Gateway Timeout'
94
+ }
95
+
96
+ # RFC 1123 (ex RFC 822)
97
+ DATE_FORMAT = '%a, %d %b %Y %H:%M:%S UTC'
98
+
99
+
100
+ def invert_dict(d: "Mapping[HashableKT, HashableVT]") -> "Mapping[HashableVT, HashableKT]":
101
+ """
102
+ Invert the dictionary.
103
+ CAUTION: this function is not deterministic unless the input dictionary is one-to-one mapping.
104
+
105
+ :param d: source dictionary
106
+ :returns: dictionary {value: key for key, value in d.items()}
107
+ """
108
+ return {value: key for key, value in d.items()}
109
+
110
+
111
+ def build_url(
112
+ url: str,
113
+ path: Optional[str] = None,
114
+ params: Optional[Union[str, dict[Any, Any], list[tuple[Any, Any]]]] = None,
115
+ doseq: bool = False
116
+ ) -> str:
117
+ """
118
+ utitily function to build an url for requests to the rucio system.
119
+
120
+ If the optional parameter doseq is evaluates to True, individual key=value pairs
121
+ separated by '&' are generated for each element of the value sequence for the key.
122
+ """
123
+ complete_url = url
124
+ if path is not None:
125
+ complete_url += "/" + path
126
+ if params is not None:
127
+ complete_url += _encode_params_as_url_query_string(params, doseq)
128
+ return complete_url
129
+
130
+
131
+ def _encode_params_as_url_query_string(
132
+ params: Union[str, dict[Any, Any], list[tuple[Any, Any]]],
133
+ doseq: bool
134
+ ) -> str:
135
+ """
136
+ Encode params into a URL query string.
137
+
138
+ :param params: the parameters to encode
139
+ :param doseq: if True, individual key=value pairs separated by '&' are generated for each element of the value sequence for the key
140
+
141
+ :returns: params as a URL query string
142
+ """
143
+ complete_url = "?"
144
+ if isinstance(params, str):
145
+ complete_url += quote(params)
146
+ else:
147
+ complete_url += urlencode(params, doseq=doseq)
148
+ return complete_url
149
+
150
+
151
+ def all_oidc_req_claims_present(
152
+ scope: Optional[Union[str, list[str]]],
153
+ audience: Optional[Union[str, list[str]]],
154
+ required_scope: Optional[Union[str, list[str]]],
155
+ required_audience: Optional[Union[str, list[str]]],
156
+ separator: str = " "
157
+ ) -> bool:
158
+ """
159
+ Checks if both of the following statements are true:
160
+ - all items in required_scope are present in scope string
161
+ - all items in required_audience are present in audience
162
+ returns false otherwise. audience and scope must be both strings
163
+ or both lists. Similarly for required_* variables.
164
+ If this condition is satisfied, False is returned.
165
+ :params scope: list of strings or one string where items are separated by a separator input variable
166
+ :params audience: list of strings or one string where items are separated by a separator input variable
167
+ :params required_scope: list of strings or one string where items are separated by a separator input variable
168
+ :params required_audience: list of strings or one string where items are separated by a separator input variable
169
+ :params separator: separator string, space by default
170
+ :returns : True or False
171
+ """
172
+ if not scope:
173
+ scope = ""
174
+ if not audience:
175
+ audience = ""
176
+ if not required_scope:
177
+ required_scope = ""
178
+ if not required_audience:
179
+ required_audience = ""
180
+ if (isinstance(scope, list) and isinstance(audience, list) and isinstance(required_scope, list) and isinstance(required_audience, list)):
181
+ scope = [str(it) for it in scope]
182
+ audience = [str(it) for it in audience]
183
+ required_scope = [str(it) for it in required_scope]
184
+ required_audience = [str(it) for it in required_audience]
185
+ req_scope_present = all(elem in scope for elem in required_scope)
186
+ req_audience_present = all(elem in audience for elem in required_audience)
187
+ return req_scope_present and req_audience_present
188
+ elif (isinstance(scope, str) and isinstance(audience, str) and isinstance(required_scope, str) and isinstance(required_audience, str)):
189
+ scope = str(scope)
190
+ audience = str(audience)
191
+ required_scope = str(required_scope)
192
+ required_audience = str(required_audience)
193
+ req_scope_present = all(elem in scope.split(separator) for elem in required_scope.split(separator))
194
+ req_audience_present = all(elem in audience.split(separator) for elem in required_audience.split(separator))
195
+ return req_scope_present and req_audience_present
196
+ elif (isinstance(scope, list) and isinstance(audience, list) and isinstance(required_scope, str) and isinstance(required_audience, str)):
197
+ scope = [str(it) for it in scope]
198
+ audience = [str(it) for it in audience]
199
+ required_scope = str(required_scope)
200
+ required_audience = str(required_audience)
201
+ req_scope_present = all(elem in scope for elem in required_scope.split(separator))
202
+ req_audience_present = all(elem in audience for elem in required_audience.split(separator))
203
+ return req_scope_present and req_audience_present
204
+ elif (isinstance(scope, str) and isinstance(audience, str) and isinstance(required_scope, list) and isinstance(required_audience, list)):
205
+ scope = str(scope)
206
+ audience = str(audience)
207
+ required_scope = [str(it) for it in required_scope]
208
+ required_audience = [str(it) for it in required_audience]
209
+ req_scope_present = all(elem in scope.split(separator) for elem in required_scope)
210
+ req_audience_present = all(elem in audience.split(separator) for elem in required_audience)
211
+ return req_scope_present and req_audience_present
212
+ else:
213
+ return False
214
+
215
+
216
+ def generate_uuid() -> str:
217
+ return str(uuid()).replace('-', '').lower()
218
+
219
+
220
+ def generate_uuid_bytes() -> bytes:
221
+ return uuid().bytes
222
+
223
+
224
+ def str_to_date(string: str) -> Optional[datetime.datetime]:
225
+ """ Converts a RFC-1123 string to the corresponding datetime value.
226
+
227
+ :param string: the RFC-1123 string to convert to datetime value.
228
+ """
229
+ return datetime.datetime.strptime(string, DATE_FORMAT) if string else None
230
+
231
+
232
+ def val_to_space_sep_str(vallist: list[str]) -> str:
233
+ """ Converts a list of values into a string of space separated values
234
+
235
+ :param vallist: the list of values to convert into string
236
+ :return: the string of space separated values or the value initially passed as parameter
237
+ """
238
+ try:
239
+ if isinstance(vallist, list):
240
+ return str(" ".join(vallist))
241
+ else:
242
+ return str(vallist)
243
+ except Exception:
244
+ return ''
245
+
246
+
247
+ def date_to_str(date: datetime.datetime) -> Optional[str]:
248
+ """ Converts a datetime value to the corresponding RFC-1123 string.
249
+
250
+ :param date: the datetime value to convert.
251
+ """
252
+ return datetime.datetime.strftime(date, DATE_FORMAT) if date else None
253
+
254
+
255
+ class APIEncoder(json.JSONEncoder):
256
+ """ Propretary JSONEconder subclass used by the json render function.
257
+ This is needed to address the encoding of special values.
258
+ """
259
+
260
+ def default(self, obj): # pylint: disable=E0202
261
+ if isinstance(obj, datetime.datetime):
262
+ # convert any datetime to RFC 1123 format
263
+ return date_to_str(obj)
264
+ elif isinstance(obj, (datetime.time, datetime.date)):
265
+ # should not happen since the only supported date-like format
266
+ # supported at dmain schema level is 'datetime' .
267
+ return obj.isoformat()
268
+ elif isinstance(obj, datetime.timedelta):
269
+ return obj.days * 24 * 60 * 60 + obj.seconds
270
+ elif isinstance(obj, Enum):
271
+ return obj.name
272
+ elif isinstance(obj, (InternalAccount, InternalScope)):
273
+ return obj.external
274
+ return json.JSONEncoder.default(self, obj)
275
+
276
+
277
+ def render_json(*args, **kwargs) -> str:
278
+ """ Render a list or a dict as a JSON-formatted string. """
279
+ if args and isinstance(args[0], list):
280
+ data = args[0]
281
+ elif isinstance(kwargs, dict):
282
+ data = kwargs
283
+ else:
284
+ raise ValueError("Error while serializing object to JSON-formatted string: supported input types are list or dict.")
285
+ return json.dumps(data, cls=APIEncoder)
286
+
287
+
288
+ def datetime_parser(dct: dict[Any, Any]) -> dict[Any, Any]:
289
+ """ datetime parser
290
+ """
291
+ for k, v in list(dct.items()):
292
+ if isinstance(v, str) and re.search(" UTC", v):
293
+ try:
294
+ dct[k] = datetime.datetime.strptime(v, DATE_FORMAT)
295
+ except Exception:
296
+ pass
297
+ return dct
298
+
299
+
300
+ def parse_response(data: Union[str, bytes, bytearray]) -> Any:
301
+ """
302
+ JSON render function
303
+ """
304
+ if isinstance(data, (bytes, bytearray)):
305
+ data = data.decode('utf-8')
306
+
307
+ return json.loads(data, object_hook=datetime_parser)
308
+
309
+
310
+ def execute(cmd: str) -> tuple[int, str, str]:
311
+ """
312
+ Executes a command in a subprocess. Returns a tuple
313
+ of (exitcode, out, err), where out is the string output
314
+ from stdout and err is the string output from stderr when
315
+ executing the command.
316
+
317
+ :param cmd: Command string to execute
318
+ """
319
+
320
+ process = subprocess.Popen(cmd,
321
+ shell=True,
322
+ stdin=subprocess.PIPE,
323
+ stdout=subprocess.PIPE,
324
+ stderr=subprocess.PIPE)
325
+
326
+ result = process.communicate()
327
+ (out, err) = result
328
+ exitcode = process.returncode
329
+ return exitcode, out.decode(encoding='utf-8'), err.decode(encoding='utf-8')
330
+
331
+
332
+ def rse_supported_protocol_domains() -> list[str]:
333
+ """ Returns a list with all supported RSE protocol domains."""
334
+ return ['lan', 'wan']
335
+
336
+
337
+ def grouper(iterable: 'Iterable[Any]', n: int, fillvalue: Optional[object] = None) -> zip_longest:
338
+ """ Collect data into fixed-length chunks or blocks """
339
+ # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
340
+ args = [iter(iterable)] * n
341
+ return zip_longest(*args, fillvalue=fillvalue)
342
+
343
+
344
+ def chunks(iterable, n):
345
+ """
346
+ Yield successive n-sized chunks from l.
347
+ """
348
+ if isinstance(iterable, list):
349
+ for i in range(0, len(iterable), n):
350
+ yield iterable[i:i + n]
351
+ else:
352
+ it = iter(iterable)
353
+ while True:
354
+ chunk = list(itertools.islice(it, n))
355
+ if not chunk:
356
+ return
357
+ yield chunk
358
+
359
+
360
+ def dict_chunks(dict_: dict[Any, Any], n: int) -> 'Iterator[dict[Any, Any]]':
361
+ """
362
+ Iterate over the dictionary in groups of the requested size
363
+ """
364
+ it = iter(dict_)
365
+ for _ in range(0, len(dict_), n):
366
+ yield {k: dict_[k] for k in itertools.islice(it, n)}
367
+
368
+
369
+ def my_key_generator(namespace: str, fn: 'Callable', **kw) -> 'Callable[..., str]':
370
+ """
371
+ Customized key generator for dogpile
372
+ """
373
+ fname = fn.__name__
374
+
375
+ def generate_key(*arg, **kw) -> str:
376
+ return namespace + "_" + fname + "_".join(str(s) for s in filter(None, arg))
377
+
378
+ return generate_key
379
+
380
+
381
+ NonDeterministicPFNAlgorithmsT = TypeVar('NonDeterministicPFNAlgorithmsT', bound='NonDeterministicPFNAlgorithms')
382
+
383
+
384
+ class NonDeterministicPFNAlgorithms(PolicyPackageAlgorithms):
385
+ """
386
+ Handle PFN construction for non-deterministic RSEs, including registration of algorithms
387
+ from policy packages
388
+ """
389
+
390
+ _algorithm_type = 'non_deterministic_pfn'
391
+
392
+ def __init__(self) -> None:
393
+ """
394
+ Initialises a non-deterministic PFN construction object
395
+ """
396
+ super().__init__()
397
+
398
+ def construct_non_deterministic_pfn(self, dsn: str, scope: Optional[str], filename: str, naming_convention: str) -> str:
399
+ """
400
+ Calls the correct algorithm to generate a non-deterministic PFN
401
+ """
402
+ return self.get_algorithm(naming_convention)(dsn, scope, filename)
403
+
404
+ @classmethod
405
+ def supports(cls: type[NonDeterministicPFNAlgorithmsT], naming_convention: str) -> bool:
406
+ """
407
+ Checks whether a non-deterministic PFN algorithm is supported
408
+ """
409
+ return super()._supports(cls._algorithm_type, naming_convention)
410
+
411
+ @classmethod
412
+ def _module_init_(cls: type[NonDeterministicPFNAlgorithmsT]) -> None:
413
+ """
414
+ Registers the included non-deterministic PFN algorithms
415
+ """
416
+ cls.register('def', cls.construct_non_deterministic_pfn_default)
417
+
418
+ @classmethod
419
+ def get_algorithm(cls: type[NonDeterministicPFNAlgorithmsT], naming_convention: str) -> 'Callable[[str, Optional[str], str], str]':
420
+ """
421
+ Looks up a non-deterministic PFN algorithm by name
422
+ """
423
+ return super()._get_one_algorithm(cls._algorithm_type, naming_convention)
424
+
425
+ @classmethod
426
+ def register(cls: type[NonDeterministicPFNAlgorithmsT], name: str, fn_construct_non_deterministic_pfn: 'Callable[[str, Optional[str], str], Optional[str]]') -> None:
427
+ """
428
+ Register a new non-deterministic PFN algorithm
429
+ """
430
+ algorithm_dict = {name: fn_construct_non_deterministic_pfn}
431
+ super()._register(cls._algorithm_type, algorithm_dict)
432
+
433
+ @staticmethod
434
+ def __strip_dsn(dsn: str) -> str:
435
+ """
436
+ Drop the _sub and _dis suffixes for panda datasets from the lfc path
437
+ they will be registered in.
438
+ Method imported from DQ2.
439
+ """
440
+
441
+ suffixes_to_drop = ['_dis', '_sub', '_frag']
442
+ fields = dsn.split('.')
443
+ last_field = fields[-1]
444
+ try:
445
+ for suffix in suffixes_to_drop:
446
+ last_field = re.sub('%s.*$' % suffix, '', last_field)
447
+ except IndexError:
448
+ return dsn
449
+ fields[-1] = last_field
450
+ stripped_dsn = '.'.join(fields)
451
+ return stripped_dsn
452
+
453
+ @staticmethod
454
+ def __strip_tag(tag: str) -> str:
455
+ """
456
+ Drop the _sub and _dis suffixes for panda datasets from the lfc path
457
+ they will be registered in
458
+ Method imported from DQ2.
459
+ """
460
+ suffixes_to_drop = ['_dis', '_sub', '_tid']
461
+ stripped_tag = tag
462
+ try:
463
+ for suffix in suffixes_to_drop:
464
+ stripped_tag = re.sub('%s.*$' % suffix, '', stripped_tag)
465
+ except IndexError:
466
+ return stripped_tag
467
+ return stripped_tag
468
+
469
+ @staticmethod
470
+ def construct_non_deterministic_pfn_default(dsn: str, scope: Optional[str], filename: str) -> str:
471
+ """
472
+ Defines relative PFN for new replicas. This method
473
+ contains DQ2 convention. To be used for non-deterministic sites.
474
+ Method imported from DQ2.
475
+
476
+ @return: relative PFN for new replica.
477
+ @rtype: str
478
+ """
479
+ # check how many dots in dsn
480
+ fields = dsn.split('.')
481
+ nfields = len(fields)
482
+
483
+ if nfields == 0:
484
+ return '/other/other/%s' % (filename)
485
+ elif nfields == 1:
486
+ stripped_dsn = NonDeterministicPFNAlgorithms.__strip_dsn(dsn)
487
+ return '/other/%s/%s' % (stripped_dsn, filename)
488
+ elif nfields == 2:
489
+ project = fields[0]
490
+ stripped_dsn = NonDeterministicPFNAlgorithms.__strip_dsn(dsn)
491
+ return '/%s/%s/%s' % (project, stripped_dsn, filename)
492
+ elif nfields < 5 or re.match('user*|group*', fields[0]):
493
+ project = fields[0]
494
+ f2 = fields[1]
495
+ f3 = fields[2]
496
+ stripped_dsn = NonDeterministicPFNAlgorithms.__strip_dsn(dsn)
497
+ return '/%s/%s/%s/%s/%s' % (project, f2, f3, stripped_dsn, filename)
498
+ else:
499
+ project = fields[0]
500
+ dataset_type = fields[4]
501
+ if nfields == 5:
502
+ tag = 'other'
503
+ else:
504
+ tag = NonDeterministicPFNAlgorithms.__strip_tag(fields[-1])
505
+ stripped_dsn = NonDeterministicPFNAlgorithms.__strip_dsn(dsn)
506
+ return '/%s/%s/%s/%s/%s' % (project, dataset_type, tag, stripped_dsn, filename)
507
+
508
+
509
+ NonDeterministicPFNAlgorithms._module_init_()
510
+
511
+
512
+ def construct_non_deterministic_pfn(dsn: str, scope: Optional[str], filename: str, naming_convention: Optional[str] = None) -> str:
513
+ """
514
+ Applies non-deterministic PFN convention to the given replica.
515
+ use the naming_convention to call the actual function which will do the job.
516
+ Rucio administrators can potentially register additional PFN generation algorithms,
517
+ which are not implemented inside this main rucio repository, so changing the
518
+ argument list must be done with caution.
519
+ """
520
+ pfn_algorithms = NonDeterministicPFNAlgorithms()
521
+ if naming_convention is None or not NonDeterministicPFNAlgorithms.supports(naming_convention):
522
+ naming_convention = 'def'
523
+ return pfn_algorithms.construct_non_deterministic_pfn(dsn, scope, filename, naming_convention)
524
+
525
+
526
+ def clean_pfns(pfns: 'Iterable[str]') -> list[str]:
527
+ res = []
528
+ for pfn in pfns:
529
+ if pfn.startswith('srm'):
530
+ pfn = re.sub(':[0-9]+/', '/', pfn)
531
+ pfn = re.sub(r'/srm/managerv1\?SFN=', '', pfn)
532
+ pfn = re.sub(r'/srm/v2/server\?SFN=', '', pfn)
533
+ pfn = re.sub(r'/srm/managerv2\?SFN=', '', pfn)
534
+ if '?GoogleAccessId' in pfn:
535
+ pfn = pfn.split('?GoogleAccessId')[0]
536
+ if '?X-Amz' in pfn:
537
+ pfn = pfn.split('?X-Amz')[0]
538
+ res.append(pfn)
539
+ res.sort()
540
+ return res
541
+
542
+
543
+ ScopeExtractionAlgorithmsT = TypeVar('ScopeExtractionAlgorithmsT', bound='ScopeExtractionAlgorithms')
544
+
545
+
546
+ class ScopeExtractionAlgorithms(PolicyPackageAlgorithms):
547
+ """
548
+ Handle scope extraction algorithms
549
+ """
550
+
551
+ _algorithm_type = 'scope'
552
+
553
+ def __init__(self) -> None:
554
+ """
555
+ Initialises scope extraction algorithms object
556
+ """
557
+ super().__init__()
558
+
559
+ def extract_scope(self, did: str, scopes: Optional['Sequence[str]'], extract_scope_convention: str) -> 'Sequence[str]':
560
+ """
561
+ Calls the correct algorithm for scope extraction
562
+ """
563
+ return self.get_algorithm(extract_scope_convention)(did, scopes)
564
+
565
+ @classmethod
566
+ def supports(cls: type[ScopeExtractionAlgorithmsT], extract_scope_convention: str) -> bool:
567
+ """
568
+ Checks whether the specified scope extraction algorithm is supported
569
+ """
570
+ return super()._supports(cls._algorithm_type, extract_scope_convention)
571
+
572
+ @classmethod
573
+ def _module_init_(cls: type[ScopeExtractionAlgorithmsT]) -> None:
574
+ """
575
+ Registers the included scope extraction algorithms
576
+ """
577
+ cls.register('def', cls.extract_scope_default)
578
+ cls.register('dirac', cls.extract_scope_dirac)
579
+
580
+ @classmethod
581
+ def get_algorithm(cls: type[ScopeExtractionAlgorithmsT], extract_scope_convention: str) -> 'Callable[[str, Optional[Sequence[str]]], Sequence[str]]':
582
+ """
583
+ Looks up a scope extraction algorithm by name
584
+ """
585
+ return super()._get_one_algorithm(cls._algorithm_type, extract_scope_convention)
586
+
587
+ @classmethod
588
+ def register(cls: type[ScopeExtractionAlgorithmsT], name: str, fn_extract_scope: 'Callable[[str, Optional[Sequence[str]]], Sequence[str]]') -> None:
589
+ """
590
+ Registers a new scope extraction algorithm
591
+ """
592
+ algorithm_dict = {name: fn_extract_scope}
593
+ super()._register(cls._algorithm_type, algorithm_dict)
594
+
595
+ @staticmethod
596
+ def extract_scope_default(did: str, scopes: Optional['Sequence[str]']) -> 'Sequence[str]':
597
+ """
598
+ Default fallback scope extraction algorithm, based on the ATLAS scope extraction algorithm.
599
+
600
+ :param did: The DID to extract the scope from.
601
+
602
+ :returns: A tuple containing the extracted scope and the DID.
603
+ """
604
+ if did.find(':') > -1:
605
+ if len(did.split(':')) > 2:
606
+ raise RucioException('Too many colons. Cannot extract scope and name')
607
+ scope, name = did.split(':')[0], did.split(':')[1]
608
+ if name.endswith('/'):
609
+ name = name[:-1]
610
+ return scope, name
611
+ else:
612
+ scope = did.split('.')[0]
613
+ if did.startswith('user') or did.startswith('group'):
614
+ scope = ".".join(did.split('.')[0:2])
615
+ if did.endswith('/'):
616
+ did = did[:-1]
617
+ return scope, did
618
+
619
+ @staticmethod
620
+ def extract_scope_dirac(did: str, scopes: Optional['Sequence[str]']) -> 'Sequence[str]':
621
+ # Default dirac scope extract algorithm. Scope is the second element in the LFN or the first one (VO name)
622
+ # if only one element is the result of a split.
623
+ elem = did.rstrip('/').split('/')
624
+ if len(elem) > 2:
625
+ scope = elem[2]
626
+ else:
627
+ scope = elem[1]
628
+ return scope, did
629
+
630
+
631
+ ScopeExtractionAlgorithms._module_init_()
632
+
633
+
634
+ def extract_scope(
635
+ did: str,
636
+ scopes: Optional['Sequence[str]'] = None,
637
+ default_extract: str = 'def'
638
+ ) -> 'Sequence[str]':
639
+ scope_extraction_algorithms = ScopeExtractionAlgorithms()
640
+ extract_scope_convention = config_get('common', 'extract_scope', False, None) or config_get('policy', 'extract_scope', False, None)
641
+ if extract_scope_convention is None or not ScopeExtractionAlgorithms.supports(extract_scope_convention):
642
+ extract_scope_convention = default_extract
643
+ return scope_extraction_algorithms.extract_scope(did, scopes, extract_scope_convention)
644
+
645
+
646
+ def pid_exists(pid: int) -> bool:
647
+ """
648
+ Check whether pid exists in the current process table.
649
+ UNIX only.
650
+ """
651
+ if pid < 0:
652
+ return False
653
+ if pid == 0:
654
+ # According to "man 2 kill" PID 0 refers to every process
655
+ # in the process group of the calling process.
656
+ # On certain systems 0 is a valid PID but we have no way
657
+ # to know that in a portable fashion.
658
+ raise ValueError('invalid PID 0')
659
+ try:
660
+ os.kill(pid, 0)
661
+ except OSError as err:
662
+ if err.errno == errno.ESRCH:
663
+ # ESRCH == No such process
664
+ return False
665
+ elif err.errno == errno.EPERM:
666
+ # EPERM clearly means there's a process to deny access to
667
+ return True
668
+ else:
669
+ # According to "man 2 kill" possible error values are
670
+ # (EINVAL, EPERM, ESRCH)
671
+ raise
672
+ else:
673
+ return True
674
+
675
+
676
+ def sizefmt(num: Union[int, float, None], human: bool = True) -> str:
677
+ """
678
+ Print human readable file sizes
679
+ """
680
+ if num is None:
681
+ return '0.0 B'
682
+ try:
683
+ num = int(num)
684
+ if human:
685
+ for unit in ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']:
686
+ if abs(num) < 1000.0:
687
+ return "%3.3f %sB" % (num, unit)
688
+ num /= 1000.0
689
+ return "%.1f %sB" % (num, 'Y')
690
+ else:
691
+ return str(num)
692
+ except OverflowError:
693
+ return 'Inf'
694
+
695
+
696
+ def get_tmp_dir() -> str:
697
+ """
698
+ Get a path where to store temporary files.
699
+
700
+ Rucio searches a standard list of temporary directories. The list is:
701
+
702
+ The directory named by the TMP environment variable.
703
+ The directory named by the TMPDIR environment variable.
704
+ The directory named by the TEMP environment variable.
705
+
706
+ As a last resort, the /tmp/ directory.
707
+
708
+ :return: A path.
709
+ """
710
+ base_dir = os.path.abspath(tempfile.gettempdir())
711
+ try:
712
+ return os.path.join(base_dir, getpass.getuser())
713
+ except Exception:
714
+ pass
715
+
716
+ try:
717
+ return os.path.join(base_dir, str(os.getuid()))
718
+ except Exception:
719
+ pass
720
+
721
+ return base_dir
722
+
723
+
724
+ def is_archive(name: str) -> bool:
725
+ '''
726
+ Check if a file name is an archive file or not.
727
+
728
+ :return: A boolean.
729
+ '''
730
+ regexp = r'^.*\.(zip|zipx|tar.gz|tgz|tar.Z|tar.bz2|tbz2)(\.\d+)*$'
731
+ if re.match(regexp, name, re.I):
732
+ return True
733
+ return False
734
+
735
+
736
+ class Color:
737
+ PURPLE = '\033[95m'
738
+ CYAN = '\033[96m'
739
+ DARKCYAN = '\033[36m'
740
+ BLUE = '\033[94m'
741
+ GREEN = '\033[92m'
742
+ YELLOW = '\033[93m'
743
+ RED = '\033[91m'
744
+ BOLD = '\033[1m'
745
+ UNDERLINE = '\033[4m'
746
+ END = '\033[0m'
747
+
748
+
749
+ def resolve_ips(hostname: str) -> list[str]:
750
+ try:
751
+ ipaddress.ip_address(hostname)
752
+ return [hostname]
753
+ except ValueError:
754
+ pass
755
+ try:
756
+ addrinfo = socket.getaddrinfo(hostname, 0, socket.AF_INET, 0, socket.IPPROTO_TCP)
757
+ return [ai[4][0] for ai in addrinfo]
758
+ except socket.gaierror:
759
+ pass
760
+ return []
761
+
762
+
763
+ def resolve_ip(hostname: str) -> str:
764
+ ips = resolve_ips(hostname)
765
+ if ips:
766
+ return ips[0]
767
+ return hostname
768
+
769
+
770
+ def ssh_sign(private_key: str, message: str) -> str:
771
+ """
772
+ Sign a string message using the private key.
773
+
774
+ :param private_key: The SSH RSA private key as a string.
775
+ :param message: The message to sign as a string.
776
+ :return: Base64 encoded signature as a string.
777
+ """
778
+ encoded_message = message.encode()
779
+ if not EXTRA_MODULES['paramiko']:
780
+ raise MissingModuleException('The paramiko module is not installed or faulty.')
781
+ sio_private_key = StringIO(private_key)
782
+ priv_k = RSAKey.from_private_key(sio_private_key)
783
+ sio_private_key.close()
784
+ signature_stream = priv_k.sign_ssh_data(encoded_message)
785
+ signature_stream.rewind()
786
+ base64_encoded = base64.b64encode(signature_stream.get_remainder())
787
+ base64_encoded = base64_encoded.decode()
788
+ return base64_encoded
789
+
790
+
791
+ def make_valid_did(lfn_dict: LFNDict) -> LFNDict:
792
+ """
793
+ When managing information about a LFN (such as in `rucio upload` or
794
+ the RSE manager's upload), we add the `filename` attribute to record
795
+ the name of the file on the local disk in addition to the remainder
796
+ of the DID information.
797
+
798
+ This function will take that python dictionary, and strip out the
799
+ additional `filename` key. If this is not done, then the dictionary
800
+ will not pass the DID JSON schema validation.
801
+ """
802
+ if 'filename' not in lfn_dict:
803
+ return lfn_dict
804
+
805
+ lfn_copy = dict(lfn_dict)
806
+ lfn_copy['name'] = lfn_copy.get('name', lfn_copy['filename'])
807
+ del lfn_copy['filename']
808
+ return lfn_copy # type: ignore
809
+
810
+
811
+ def send_trace(trace: TraceDict, trace_endpoint: str, user_agent: str, retries: int = 5) -> int:
812
+ """
813
+ Send the given trace to the trace endpoint
814
+
815
+ :param trace: the trace dictionary to send
816
+ :param trace_endpoint: the endpoint where the trace should be send
817
+ :param user_agent: the user agent sending the trace
818
+ :param retries: the number of retries if sending fails
819
+ :return: 0 on success, 1 on failure
820
+ """
821
+ if user_agent.startswith('pilot'):
822
+ return 0
823
+ for dummy in range(retries):
824
+ try:
825
+ requests.post(trace_endpoint + '/traces/', verify=False, data=json.dumps(trace))
826
+ return 0
827
+ except Exception:
828
+ pass
829
+ return 1
830
+
831
+
832
+ def add_url_query(url: str, query: dict[str, str]) -> str:
833
+ """
834
+ Add a new dictionary to URL parameters
835
+
836
+ :param url: The existing URL
837
+ :param query: A dictionary containing key/value pairs to be added to the URL
838
+ :return: The expanded URL with the new query parameters
839
+ """
840
+
841
+ url_parts = list(urlparse(url))
842
+ mod_query = dict(parse_qsl(url_parts[4]))
843
+ mod_query.update(query)
844
+ url_parts[4] = urlencode(mod_query)
845
+ return urlunparse(url_parts)
846
+
847
+
848
+ def get_bytes_value_from_string(input_string: str) -> Union[bool, int]:
849
+ """
850
+ Get bytes from a string that represents a storage value and unit
851
+
852
+ :param input_string: String containing a value and an unit
853
+ :return: Integer value representing the value in bytes
854
+ """
855
+ result = re.findall('^([0-9]+)([A-Za-z]+)$', input_string)
856
+ if result:
857
+ value = int(result[0][0])
858
+ unit = result[0][1].lower()
859
+ if unit == 'b':
860
+ value = value
861
+ elif unit == 'kb':
862
+ value = value * 1000
863
+ elif unit == 'mb':
864
+ value = value * 1000000
865
+ elif unit == 'gb':
866
+ value = value * 1000000000
867
+ elif unit == 'tb':
868
+ value = value * 1000000000000
869
+ elif unit == 'pb':
870
+ value = value * 1000000000000000
871
+ else:
872
+ return False
873
+ return value
874
+ else:
875
+ return False
876
+
877
+
878
+ def parse_did_filter_from_string(input_string: str) -> tuple[dict[str, Any], str]:
879
+ """
880
+ Parse DID filter options in format 'length<3,type=all' from string.
881
+
882
+ :param input_string: String containing the filter options.
883
+ :return: filter dictionary and type as string.
884
+ """
885
+ filters = {}
886
+ type_ = 'collection'
887
+ if input_string:
888
+ filter_options = input_string.replace(' ', '').split(',')
889
+ for option in filter_options:
890
+ value = None
891
+ key = None
892
+
893
+ if '>=' in option:
894
+ key, value = option.split('>=')
895
+ if key == 'length':
896
+ key = 'length.gte'
897
+ elif '>' in option:
898
+ key, value = option.split('>')
899
+ if key == 'length':
900
+ key = 'length.gt'
901
+ elif '<=' in option:
902
+ key, value = option.split('<=')
903
+ if key == 'length':
904
+ key = 'length.lte'
905
+ elif '<' in option:
906
+ key, value = option.split('<')
907
+ if key == 'length':
908
+ key = 'length.lt'
909
+ elif '=' in option:
910
+ key, value = option.split('=')
911
+ if key == 'created_after' or key == 'created_before':
912
+ value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%fZ')
913
+
914
+ if key == 'type':
915
+ if value.upper() in ['ALL', 'COLLECTION', 'CONTAINER', 'DATASET', 'FILE']: # type: ignore
916
+ type_ = value.lower() # type: ignore
917
+ else:
918
+ raise InvalidType('{0} is not a valid type. Valid types are {1}'.format(value, ['ALL', 'COLLECTION', 'CONTAINER', 'DATASET', 'FILE']))
919
+ elif key in ('length.gt', 'length.lt', 'length.gte', 'length.lte', 'length'):
920
+ try:
921
+ value = int(value) # type: ignore
922
+ filters[key] = value
923
+ except ValueError:
924
+ raise ValueError('Length has to be an integer value.')
925
+ filters[key] = value
926
+ elif isinstance(value, str):
927
+ if value.lower() == 'true':
928
+ value = '1'
929
+ elif value.lower() == 'false':
930
+ value = '0'
931
+ filters[key] = value
932
+ else:
933
+ filters[key] = value
934
+
935
+ return filters, type_
936
+
937
+
938
+ def parse_did_filter_from_string_fe(
939
+ input_string: str,
940
+ name: str = '*',
941
+ type: str = 'collection',
942
+ omit_name: bool = False
943
+ ) -> tuple[list[dict[str, Any]], str]:
944
+ """
945
+ Parse DID filter string for the filter engine (fe).
946
+
947
+ Should adhere to the following conventions:
948
+ - ';' represents the logical OR operator
949
+ - ',' represents the logical AND operator
950
+ - all operators belong to set of (<=, >=, ==, !=, >, <, =)
951
+ - there should be no duplicate key+operator criteria.
952
+
953
+ One sided and compound inequalities are supported.
954
+
955
+ Sanity checking of input is left to the filter engine.
956
+
957
+ :param input_string: String containing the filter options.
958
+ :param name: DID name.
959
+ :param type: The type of the did: all(container, dataset, file), collection(dataset or container), dataset, container.
960
+ :param omit_name: omit addition of name to filters.
961
+ :return: list of dictionaries with each dictionary as a separate OR expression.
962
+ """
963
+ # lookup table unifying all comprehended operators to a nominal suffix.
964
+ # note that the order matters as the regex engine is eager, e.g. don't want to evaluate '<=' as '<' and '='.
965
+ operators_suffix_lut = OrderedDict({
966
+ '<=': 'lte',
967
+ '>=': 'gte',
968
+ '==': '',
969
+ '!=': 'ne',
970
+ '>': 'gt',
971
+ '<': 'lt',
972
+ '=': ''
973
+ })
974
+
975
+ # lookup table mapping operator opposites, used to reverse compound inequalities.
976
+ operator_opposites_lut = {
977
+ 'lt': 'gt',
978
+ 'lte': 'gte'
979
+ }
980
+ operator_opposites_lut.update({op2: op1 for op1, op2 in operator_opposites_lut.items()})
981
+
982
+ filters = []
983
+ if input_string:
984
+ or_groups = list(filter(None, input_string.split(';'))) # split <input_string> into OR clauses
985
+ for or_group in or_groups:
986
+ or_group = or_group.strip()
987
+ and_groups = list(filter(None, or_group.split(','))) # split <or_group> into AND clauses
988
+ and_group_filters = {}
989
+ for and_group in and_groups:
990
+ and_group = and_group.strip()
991
+ # tokenise this AND clause using operators as delimiters.
992
+ tokenisation_regex = "({})".format('|'.join(operators_suffix_lut.keys()))
993
+ and_group_split_by_operator = list(filter(None, re.split(tokenisation_regex, and_group)))
994
+ if len(and_group_split_by_operator) == 3: # this is a one-sided inequality or expression
995
+ key, operator, value = [token.strip() for token in and_group_split_by_operator]
996
+
997
+ # substitute input operator with the nominal operator defined by the LUT, <operators_suffix_LUT>.
998
+ operator_mapped = operators_suffix_lut.get(operator)
999
+
1000
+ filter_key_full = key
1001
+ if operator_mapped is not None:
1002
+ if operator_mapped:
1003
+ filter_key_full = "{}.{}".format(key, operator_mapped)
1004
+ else:
1005
+ raise DIDFilterSyntaxError("{} operator not understood.".format(operator_mapped))
1006
+
1007
+ if filter_key_full in and_group_filters:
1008
+ raise DuplicateCriteriaInDIDFilter(filter_key_full)
1009
+ else:
1010
+ and_group_filters[filter_key_full] = value
1011
+ elif len(and_group_split_by_operator) == 5: # this is a compound inequality
1012
+ value1, operator1, key, operator2, value2 = [token.strip() for token in and_group_split_by_operator]
1013
+
1014
+ # substitute input operator with the nominal operator defined by the LUT, <operators_suffix_LUT>.
1015
+ operator1_mapped = operator_opposites_lut.get(operators_suffix_lut.get(operator1))
1016
+ operator2_mapped = operators_suffix_lut.get(operator2)
1017
+
1018
+ filter_key1_full = filter_key2_full = key
1019
+ if operator1_mapped is not None and operator2_mapped is not None:
1020
+ if operator1_mapped: # ignore '' operator (maps from equals)
1021
+ filter_key1_full = "{}.{}".format(key, operator1_mapped)
1022
+ if operator2_mapped: # ignore '' operator (maps from equals)
1023
+ filter_key2_full = "{}.{}".format(key, operator2_mapped)
1024
+ else:
1025
+ raise DIDFilterSyntaxError("{} operator not understood.".format(operator_mapped))
1026
+
1027
+ if filter_key1_full in and_group_filters:
1028
+ raise DuplicateCriteriaInDIDFilter(filter_key1_full)
1029
+ else:
1030
+ and_group_filters[filter_key1_full] = value1
1031
+ if filter_key2_full in and_group_filters:
1032
+ raise DuplicateCriteriaInDIDFilter(filter_key2_full)
1033
+ else:
1034
+ and_group_filters[filter_key2_full] = value2
1035
+ else:
1036
+ raise DIDFilterSyntaxError(and_group)
1037
+
1038
+ # add name key to each AND clause if it hasn't already been populated from the filter and <omit_name> not set.
1039
+ if not omit_name and 'name' not in and_group_filters:
1040
+ and_group_filters['name'] = name
1041
+
1042
+ filters.append(and_group_filters)
1043
+ else:
1044
+ if not omit_name:
1045
+ filters.append({
1046
+ 'name': name
1047
+ })
1048
+ return filters, type
1049
+
1050
+
1051
+ def parse_replicas_from_file(path: "FileDescriptorOrPath") -> Any:
1052
+ """
1053
+ Parses the output of list_replicas from a json or metalink file
1054
+ into a dictionary. Metalink parsing is tried first and if it fails
1055
+ it tries to parse json.
1056
+
1057
+ :param path: the path to the input file
1058
+
1059
+ :returns: a list with a dictionary for each file
1060
+ """
1061
+ with open(path) as fp:
1062
+ try:
1063
+ root = ElementTree.parse(fp).getroot() # noqa: S314
1064
+ return parse_replicas_metalink(root)
1065
+ except ElementTree.ParseError as xml_err:
1066
+ try:
1067
+ return json.load(fp)
1068
+ except ValueError as json_err:
1069
+ raise MetalinkJsonParsingError(path, xml_err, json_err)
1070
+
1071
+
1072
+ def parse_replicas_from_string(string: str) -> Any:
1073
+ """
1074
+ Parses the output of list_replicas from a json or metalink string
1075
+ into a dictionary. Metalink parsing is tried first and if it fails
1076
+ it tries to parse json.
1077
+
1078
+ :param string: the string to parse
1079
+
1080
+ :returns: a list with a dictionary for each file
1081
+ """
1082
+ try:
1083
+ root = ElementTree.fromstring(string) # noqa: S314
1084
+ return parse_replicas_metalink(root)
1085
+ except ElementTree.ParseError as xml_err:
1086
+ try:
1087
+ return json.loads(string)
1088
+ except ValueError as json_err:
1089
+ raise MetalinkJsonParsingError(string, xml_err, json_err)
1090
+
1091
+
1092
+ def parse_replicas_metalink(root: ElementTree.Element) -> list[dict[str, Any]]:
1093
+ """
1094
+ Transforms the metalink tree into a list of dictionaries where
1095
+ each dictionary describes a file with its replicas.
1096
+ Will be called by parse_replicas_from_file and parse_replicas_from_string.
1097
+
1098
+ :param root: root node of the metalink tree
1099
+
1100
+ :returns: a list with a dictionary for each file
1101
+ """
1102
+ files = []
1103
+
1104
+ # metalink namespace
1105
+ ns = '{urn:ietf:params:xml:ns:metalink}'
1106
+ str_to_bool = {'true': True, 'True': True, 'false': False, 'False': False}
1107
+
1108
+ # loop over all <file> tags of the metalink string
1109
+ for file_tag_obj in root.findall(ns + 'file'):
1110
+ # search for identity-tag
1111
+ identity_tag_obj = file_tag_obj.find(ns + 'identity')
1112
+ if not ElementTree.iselement(identity_tag_obj):
1113
+ raise InputValidationError('Failed to locate identity-tag inside %s' % ElementTree.tostring(file_tag_obj))
1114
+
1115
+ cur_file = {'did': identity_tag_obj.text,
1116
+ 'adler32': None,
1117
+ 'md5': None,
1118
+ 'sources': []}
1119
+
1120
+ parent_dids = set()
1121
+ parent_dids_tag_obj = file_tag_obj.find(ns + 'parents')
1122
+ if ElementTree.iselement(parent_dids_tag_obj):
1123
+ for did_tag_obj in parent_dids_tag_obj.findall(ns + 'did'):
1124
+ parent_dids.add(did_tag_obj.text)
1125
+ cur_file['parent_dids'] = parent_dids
1126
+
1127
+ size_tag_obj = file_tag_obj.find(ns + 'size')
1128
+ cur_file['bytes'] = int(size_tag_obj.text) if ElementTree.iselement(size_tag_obj) else None
1129
+
1130
+ for hash_tag_obj in file_tag_obj.findall(ns + 'hash'):
1131
+ hash_type = hash_tag_obj.get('type')
1132
+ if hash_type:
1133
+ cur_file[hash_type] = hash_tag_obj.text
1134
+
1135
+ for url_tag_obj in file_tag_obj.findall(ns + 'url'):
1136
+ key_rename_map = {'location': 'rse'}
1137
+ src = {}
1138
+ for k, v in url_tag_obj.items():
1139
+ k = key_rename_map.get(k, k)
1140
+ src[k] = str_to_bool.get(v, v)
1141
+ src['pfn'] = url_tag_obj.text
1142
+ cur_file['sources'].append(src)
1143
+
1144
+ files.append(cur_file)
1145
+
1146
+ return files
1147
+
1148
+
1149
+ def get_thread_with_periodic_running_function(
1150
+ interval: Union[int, float],
1151
+ action: 'Callable[..., Any]',
1152
+ graceful_stop: threading.Event
1153
+ ) -> threading.Thread:
1154
+ """
1155
+ Get a thread where a function runs periodically.
1156
+
1157
+ :param interval: Interval in seconds when the action function should run.
1158
+ :param action: Function, that should run periodically.
1159
+ :param graceful_stop: Threading event used to check for graceful stop.
1160
+ """
1161
+ def start():
1162
+ while not graceful_stop.is_set():
1163
+ starttime = time.time()
1164
+ action()
1165
+ time.sleep(interval - (time.time() - starttime))
1166
+ t = threading.Thread(target=start)
1167
+ return t
1168
+
1169
+
1170
+ def run_cmd_process(cmd: str, timeout: int = 3600) -> tuple[int, str]:
1171
+ """
1172
+ shell command parser with timeout
1173
+
1174
+ :param cmd: shell command as a string
1175
+ :param timeout: in seconds
1176
+
1177
+ :return: stdout xor stderr, and errorcode
1178
+ """
1179
+
1180
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, preexec_fn=os.setsid, universal_newlines=True)
1181
+
1182
+ try:
1183
+ stdout, stderr = process.communicate(timeout=timeout)
1184
+ except subprocess.TimeoutExpired:
1185
+ try:
1186
+ # Kill the whole process group since we're using shell=True.
1187
+ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
1188
+ stdout, stderr = process.communicate(timeout=3)
1189
+ except subprocess.TimeoutExpired:
1190
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
1191
+ stdout, stderr = process.communicate()
1192
+
1193
+ if not stderr:
1194
+ stderr = ''
1195
+ if not stdout:
1196
+ stdout = ''
1197
+ if stderr and stderr != '':
1198
+ stdout += " Error: " + stderr
1199
+ if process:
1200
+ returncode = process.returncode
1201
+ else:
1202
+ returncode = 1
1203
+ if returncode != 1 and 'Command time-out' in stdout:
1204
+ returncode = 1
1205
+ if returncode is None:
1206
+ returncode = 0
1207
+
1208
+ return returncode, stdout
1209
+
1210
+
1211
+ def gateway_update_return_dict(
1212
+ dictionary: dict[str, Any],
1213
+ session: Optional["Session"] = None
1214
+ ) -> dict[str, Any]:
1215
+ """
1216
+ Ensure that rse is in a dictionary returned from core
1217
+
1218
+ :param dictionary: The dictionary to edit
1219
+ :param session: The DB session to use
1220
+ :returns dictionary: The edited dictionary
1221
+ """
1222
+ if not isinstance(dictionary, dict):
1223
+ return dictionary
1224
+
1225
+ copied = False # Avoid side effects from pass by object
1226
+
1227
+ for rse_str in ['rse', 'src_rse', 'source_rse', 'dest_rse', 'destination_rse']:
1228
+ rse_id_str = '%s_id' % rse_str
1229
+ if rse_id_str in dictionary.keys() and dictionary[rse_id_str] is not None:
1230
+ if rse_str not in dictionary.keys():
1231
+ if not copied:
1232
+ dictionary = dictionary.copy()
1233
+ copied = True
1234
+ import rucio.core.rse
1235
+ dictionary[rse_str] = rucio.core.rse.get_rse_name(rse_id=dictionary[rse_id_str], session=session)
1236
+
1237
+ if 'account' in dictionary.keys() and dictionary['account'] is not None:
1238
+ if not copied:
1239
+ dictionary = dictionary.copy()
1240
+ copied = True
1241
+ dictionary['account'] = dictionary['account'].external
1242
+
1243
+ if 'scope' in dictionary.keys() and dictionary['scope'] is not None:
1244
+ if not copied:
1245
+ dictionary = dictionary.copy()
1246
+ copied = True
1247
+ dictionary['scope'] = dictionary['scope'].external
1248
+
1249
+ return dictionary
1250
+
1251
+
1252
+ def setup_logger(
1253
+ module_name: Optional[str] = None,
1254
+ logger_name: Optional[str] = None,
1255
+ logger_level: Optional[int] = None,
1256
+ verbose: bool = False
1257
+ ) -> logging.Logger:
1258
+ '''
1259
+ Factory method to set logger with handlers.
1260
+ :param module_name: __name__ of the module that is calling this method
1261
+ :param logger_name: name of the logger, typically name of the module.
1262
+ :param logger_level: if not given, fetched from config.
1263
+ :param verbose: verbose option set in bin/rucio
1264
+ '''
1265
+ # helper method for cfg check
1266
+ def _force_cfg_log_level(cfg_option: str) -> bool:
1267
+ cfg_forced_modules = config_get('logging', cfg_option, raise_exception=False, default=None, clean_cached=True,
1268
+ check_config_table=False)
1269
+ if cfg_forced_modules and module_name is not None:
1270
+ if re.match(str(cfg_forced_modules), module_name):
1271
+ return True
1272
+ return False
1273
+
1274
+ # creating log
1275
+ if not logger_name:
1276
+ if not module_name:
1277
+ logger_name = 'usr'
1278
+ else:
1279
+ logger_name = module_name.split('.')[-1]
1280
+ logger = logging.getLogger(logger_name)
1281
+
1282
+ # extracting the log level
1283
+ if not logger_level:
1284
+ logger_level = logging.INFO
1285
+ if verbose:
1286
+ logger_level = logging.DEBUG
1287
+
1288
+ # overriding by the config
1289
+ cfg_levels = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR)
1290
+ for level in cfg_levels:
1291
+ cfg_opt = 'forceloglevel' + logging.getLevelName(level)
1292
+ if _force_cfg_log_level(cfg_opt):
1293
+ logger_level = level
1294
+
1295
+ # setting the log level
1296
+ logger.setLevel(logger_level)
1297
+
1298
+ # preferred logger handling
1299
+ def add_handler(logger: logging.Logger) -> None:
1300
+ hdlr = logging.StreamHandler()
1301
+
1302
+ def emit_decorator(fnc: 'Callable[..., Any]') -> 'Callable[..., Any]':
1303
+ def func(*args) -> 'Callable[..., Any]':
1304
+ if 'RUCIO_LOGGING_FORMAT' not in os.environ:
1305
+ levelno = args[0].levelno
1306
+ format_str = '%(asctime)s\t%(levelname)s\t%(message)s\033[0m'
1307
+ if levelno >= logging.CRITICAL:
1308
+ color = '\033[31;1m'
1309
+ elif levelno >= logging.ERROR:
1310
+ color = '\033[31;1m'
1311
+ elif levelno >= logging.WARNING:
1312
+ color = '\033[33;1m'
1313
+ elif levelno >= logging.INFO:
1314
+ color = '\033[32;1m'
1315
+ elif levelno >= logging.DEBUG:
1316
+ color = '\033[36;1m'
1317
+ format_str = '%(asctime)s\t%(levelname)s\t%(filename)s\t%(message)s\033[0m'
1318
+ else:
1319
+ color = '\033[0m'
1320
+ formatter = logging.Formatter('{0}{1}'.format(color, format_str))
1321
+ else:
1322
+ formatter = logging.Formatter(os.environ['RUCIO_LOGGING_FORMAT'])
1323
+ hdlr.setFormatter(formatter)
1324
+ return fnc(*args)
1325
+ return func
1326
+ hdlr.emit = emit_decorator(hdlr.emit)
1327
+ logger.addHandler(hdlr)
1328
+
1329
+ # setting handler and formatter
1330
+ if not logger.handlers:
1331
+ add_handler(logger)
1332
+
1333
+ return logger
1334
+
1335
+
1336
+ def daemon_sleep(
1337
+ start_time: float,
1338
+ sleep_time: float,
1339
+ graceful_stop: threading.Event,
1340
+ logger: "LoggerFunction" = logging.log
1341
+ ) -> None:
1342
+ """Sleeps a daemon the time provided by sleep_time"""
1343
+ end_time = time.time()
1344
+ time_diff = end_time - start_time
1345
+ if time_diff < sleep_time:
1346
+ logger(logging.INFO, 'Sleeping for a while : %s seconds', (sleep_time - time_diff))
1347
+ graceful_stop.wait(sleep_time - time_diff)
1348
+
1349
+
1350
+ class retry: # noqa: N801
1351
+ """Retry callable object with configuragle number of attempts"""
1352
+
1353
+ def __init__(self, func: 'Callable[..., Any]', *args, **kwargs):
1354
+ '''
1355
+ :param func: a method that should be executed with retries
1356
+ :param args: parameters of the func
1357
+ :param kwargs: key word arguments of the func
1358
+ '''
1359
+ self.func, self.args, self.kwargs = func, args, kwargs
1360
+
1361
+ def __call__(self, mtries: int = 3, logger: "LoggerFunction" = logging.log) -> 'Callable[..., Any]':
1362
+ '''
1363
+ :param mtries: maximum number of attempts to execute the function
1364
+ :param logger: preferred logger
1365
+ '''
1366
+ attempt = mtries
1367
+ while attempt > 1:
1368
+ try:
1369
+ if logger:
1370
+ logger(logging.DEBUG, '{}: Attempt {}'.format(self.func.__name__, mtries - attempt + 1))
1371
+ return self.func(*self.args, **self.kwargs)
1372
+ except Exception as e:
1373
+ if logger:
1374
+ logger(logging.DEBUG, '{}: Attempt failed {}'.format(self.func.__name__, mtries - attempt + 1))
1375
+ logger(logging.DEBUG, str(e))
1376
+ attempt -= 1
1377
+ return self.func(*self.args, **self.kwargs)
1378
+
1379
+
1380
+ class StoreAndDeprecateWarningAction(argparse.Action):
1381
+ '''
1382
+ StoreAndDeprecateWarningAction is a descendant of :class:`argparse.Action`
1383
+ and represents a store action with a deprecated argument name.
1384
+ '''
1385
+
1386
+ def __init__(self,
1387
+ option_strings: 'Sequence[str]',
1388
+ new_option_string: str,
1389
+ dest: str,
1390
+ **kwargs):
1391
+ """
1392
+ :param option_strings: all possible argument name strings
1393
+ :param new_option_string: the new option string which replaces the old
1394
+ :param dest: name of variable to store the value in
1395
+ :param kwargs: everything else
1396
+ """
1397
+ super(StoreAndDeprecateWarningAction, self).__init__(
1398
+ option_strings=option_strings,
1399
+ dest=dest,
1400
+ **kwargs)
1401
+ if new_option_string not in option_strings:
1402
+ raise ValueError("%s not supported as a string option." % new_option_string)
1403
+ self.new_option_string = new_option_string
1404
+
1405
+ def __call__(self, parser, namespace, values, option_string: Optional[str] = None):
1406
+ if option_string and option_string != self.new_option_string:
1407
+ # The logger gets typically initialized after the argument parser
1408
+ # to set the verbosity of the logger. Thus using simple print to console.
1409
+ print("Warning: The commandline argument {} is deprecated! Please use {} in the future.".format(option_string, self.new_option_string))
1410
+
1411
+ setattr(namespace, self.dest, values)
1412
+
1413
+
1414
+ class StoreTrueAndDeprecateWarningAction(argparse._StoreConstAction):
1415
+ '''
1416
+ StoreAndDeprecateWarningAction is a descendant of :class:`argparse.Action`
1417
+ and represents a store action with a deprecated argument name.
1418
+ '''
1419
+
1420
+ def __init__(self,
1421
+ option_strings: 'Sequence[str]',
1422
+ new_option_string: str,
1423
+ dest: str,
1424
+ default: bool = False,
1425
+ required: bool = False,
1426
+ help: Optional[str] = None):
1427
+ """
1428
+ :param option_strings: all possible argument name strings
1429
+ :param new_option_string: the new option string which replaces the old
1430
+ :param dest: name of variable to store the value in
1431
+ :param kwargs: everything else
1432
+ """
1433
+ super(StoreTrueAndDeprecateWarningAction, self).__init__(
1434
+ option_strings=option_strings,
1435
+ dest=dest,
1436
+ const=True,
1437
+ default=default,
1438
+ required=required,
1439
+ help=help)
1440
+ if new_option_string not in option_strings:
1441
+ raise ValueError("%s not supported as a string option." % new_option_string)
1442
+ self.new_option_string = new_option_string
1443
+
1444
+ def __call__(self, parser, namespace, values, option_string: Optional[str] = None):
1445
+ super(StoreTrueAndDeprecateWarningAction, self).__call__(parser, namespace, values, option_string=option_string)
1446
+ if option_string and option_string != self.new_option_string:
1447
+ # The logger gets typically initialized after the argument parser
1448
+ # to set the verbosity of the logger. Thus using simple print to console.
1449
+ print("Warning: The commandline argument {} is deprecated! Please use {} in the future.".format(option_string, self.new_option_string))
1450
+
1451
+
1452
+ class PriorityQueue:
1453
+ """
1454
+ Heap-based [1] priority queue which supports priority update operations
1455
+
1456
+ It is used as a dictionary: pq['element'] = priority
1457
+ The element with the highest priority can be accessed with pq.top() or pq.pop(),
1458
+ depending on the desire to keep it in the heap or not.
1459
+
1460
+ [1] https://en.wikipedia.org/wiki/Heap_(data_structure)
1461
+ """
1462
+ class ContainerSlot:
1463
+ def __init__(self, position: int, priority: int):
1464
+ self.pos = position
1465
+ self.prio = priority
1466
+
1467
+ def __init__(self):
1468
+ self.heap = []
1469
+ self.container = {}
1470
+
1471
+ def __len__(self):
1472
+ return len(self.heap)
1473
+
1474
+ def __getitem__(self, item):
1475
+ return self.container[item].prio
1476
+
1477
+ def __setitem__(self, key, value):
1478
+ if key in self.container:
1479
+ existing_prio = self.container[key].prio
1480
+ self.container[key].prio = value
1481
+ if value < existing_prio:
1482
+ self._priority_decreased(key)
1483
+ elif existing_prio < value:
1484
+ self._priority_increased(key)
1485
+ else:
1486
+ self.heap.append(key)
1487
+ self.container[key] = self.ContainerSlot(position=len(self.heap) - 1, priority=value)
1488
+ self._priority_decreased(key)
1489
+
1490
+ def __contains__(self, item):
1491
+ return item in self.container
1492
+
1493
+ def top(self):
1494
+ return self.heap[0]
1495
+
1496
+ def pop(self):
1497
+ item = self.heap[0]
1498
+ self.container.pop(item)
1499
+
1500
+ tmp_item = self.heap.pop()
1501
+ if self.heap:
1502
+ self.heap[0] = tmp_item
1503
+ self.container[tmp_item].pos = 0
1504
+ self._priority_increased(tmp_item)
1505
+ return item
1506
+
1507
+ def _priority_decreased(self, item):
1508
+ heap_changed = False
1509
+
1510
+ pos = self.container[item].pos
1511
+ pos_parent = (pos - 1) // 2
1512
+ while pos > 0 and self.container[self.heap[pos]].prio < self.container[self.heap[pos_parent]].prio:
1513
+ tmp_item, parent = self.heap[pos], self.heap[pos_parent] = self.heap[pos_parent], self.heap[pos]
1514
+ self.container[tmp_item].pos, self.container[parent].pos = self.container[parent].pos, self.container[tmp_item].pos
1515
+
1516
+ pos = pos_parent
1517
+ pos_parent = (pos - 1) // 2
1518
+
1519
+ heap_changed = True
1520
+ return heap_changed
1521
+
1522
+ def _priority_increased(self, item):
1523
+ heap_changed = False
1524
+ heap_len = len(self.heap)
1525
+ pos = self.container[item].pos
1526
+ pos_child1 = 2 * pos + 1
1527
+ pos_child2 = 2 * pos + 2
1528
+
1529
+ heap_restored = False
1530
+ while not heap_restored:
1531
+ # find minimum between item, child1, and child2
1532
+ if pos_child1 < heap_len and self.container[self.heap[pos_child1]].prio < self.container[self.heap[pos]].prio:
1533
+ pos_min = pos_child1
1534
+ else:
1535
+ pos_min = pos
1536
+ if pos_child2 < heap_len and self.container[self.heap[pos_child2]].prio < self.container[self.heap[pos_min]].prio:
1537
+ pos_min = pos_child2
1538
+
1539
+ if pos_min != pos:
1540
+ _, tmp_item = self.heap[pos_min], self.heap[pos] = self.heap[pos], self.heap[pos_min]
1541
+ self.container[tmp_item].pos = pos
1542
+
1543
+ pos = pos_min
1544
+ pos_child1 = 2 * pos + 1
1545
+ pos_child2 = 2 * pos + 2
1546
+
1547
+ heap_changed = True
1548
+ else:
1549
+ heap_restored = True
1550
+
1551
+ self.container[self.heap[pos]].pos = pos
1552
+ return heap_changed
1553
+
1554
+
1555
+ class Availability:
1556
+ """
1557
+ This util class acts as a translator between the availability stored as
1558
+ integer and as boolean values.
1559
+
1560
+ `None` represents a missing value. This lets a user update a specific value
1561
+ without altering the other ones. If it needs to be evaluated, it will
1562
+ correspond to `True`.
1563
+ """
1564
+
1565
+ read = None
1566
+ write = None
1567
+ delete = None
1568
+
1569
+ def __init__(
1570
+ self,
1571
+ read: Optional[bool] = None,
1572
+ write: Optional[bool] = None,
1573
+ delete: Optional[bool] = None
1574
+ ):
1575
+ self.read = read
1576
+ self.write = write
1577
+ self.delete = delete
1578
+
1579
+ def __iter__(self):
1580
+ """
1581
+ The iterator provides the feature to unpack the values of this class.
1582
+
1583
+ e.g. `read, write, delete = Availability(True, False, True)`
1584
+
1585
+ :returns: An iterator over the values `read`, `write`, `delete`.
1586
+ """
1587
+ return iter((self.read, self.write, self.delete))
1588
+
1589
+ def __repr__(self):
1590
+ return "Availability({}, {}, {})".format(self.read, self.write, self.delete)
1591
+
1592
+ def __eq__(self, other):
1593
+ return self.read == other.read and self.write == other.write and self.delete == other.delete
1594
+
1595
+ def __hash__(self):
1596
+ return hash(self.integer)
1597
+
1598
+ @classmethod
1599
+ def from_integer(cls, n):
1600
+ """
1601
+ Returns a new Availability instance where the values are set to the
1602
+ corresponding bit values in the integer.
1603
+
1604
+ :param n: The integer value to get the availabilities from.
1605
+ :returns: The corresponding Availability instance.
1606
+ """
1607
+ if n is None:
1608
+ return cls(None, None, None)
1609
+
1610
+ return cls(
1611
+ (n >> 2) % 2 == 1,
1612
+ (n >> 1) % 2 == 1,
1613
+ (n >> 0) % 2 == 1
1614
+ )
1615
+
1616
+ @property
1617
+ def integer(self):
1618
+ """
1619
+ Returns the corresponding integer for the instance values. The three
1620
+ least-significant bits correspond to the availability values.
1621
+
1622
+ :returns: An integer corresponding to the availability values. `None`
1623
+ gets treated as `True`.
1624
+ """
1625
+ read_value = (self.read or self.read is None) * 4
1626
+ write_value = (self.write or self.write is None) * 2
1627
+ delete_value = (self.delete or self.delete is None) * 1
1628
+
1629
+ return read_value + write_value + delete_value
1630
+
1631
+
1632
+ def retrying(
1633
+ retry_on_exception: "Callable[[Exception], bool]",
1634
+ wait_fixed: int,
1635
+ stop_max_attempt_number: int
1636
+ ) -> "Callable[[Callable[..., T]], Callable[..., T]]":
1637
+ """
1638
+ Decorator which retries a function multiple times on certain types of exceptions.
1639
+ :param retry_on_exception: Function which takes an exception as argument and returns True if we must retry on this exception
1640
+ :param wait_fixed: the amount of time to wait in-between two tries
1641
+ :param stop_max_attempt_number: maximum number of allowed attempts
1642
+ """
1643
+ def _decorator(fn):
1644
+ @wraps(fn)
1645
+ def _wrapper(*args, **kwargs):
1646
+ attempt = 0
1647
+ while True:
1648
+ attempt += 1
1649
+ try:
1650
+ return fn(*args, **kwargs)
1651
+ except Exception as e:
1652
+ if attempt >= stop_max_attempt_number:
1653
+ raise
1654
+ if not retry_on_exception(e):
1655
+ raise
1656
+ time.sleep(wait_fixed / 1000.0)
1657
+ return _wrapper
1658
+ return _decorator
1659
+
1660
+
1661
+ def deep_merge_dict(source: dict, destination: dict) -> dict:
1662
+ """Merge two dictionaries together recursively"""
1663
+ for key, value in source.items():
1664
+ if isinstance(value, dict):
1665
+ # get node or create one
1666
+ node = destination.setdefault(key, {})
1667
+ deep_merge_dict(value, node)
1668
+ else:
1669
+ destination[key] = value
1670
+
1671
+ return destination
1672
+
1673
+
1674
+ def is_method_overridden(obj, base_cls, method_name):
1675
+ """
1676
+ Return True if `obj` (an instance of a subclass of `base_cls`) has overridden the given method_name from base_cls.
1677
+ That is, `type(obj).<method_name>` is not the same function object as `base_cls.<method_name>`.
1678
+
1679
+ :param obj: An instance of (a subclass of) base_cls.
1680
+ :param base_cls: The base class which may define the method.
1681
+ :param method_name: Name of the method (str) to check.
1682
+ :returns: Boolean, True if the subclass provides a real override.
1683
+ """
1684
+ if not hasattr(obj, method_name):
1685
+ return False
1686
+ if getattr(type(obj), method_name, None) is getattr(base_cls, method_name, None): # Caring for bound/unbound cases
1687
+ return False
1688
+ return True