peeringdb 2.4.1__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.
peeringdb/__init__.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ PeeringDB API
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from importlib import import_module
8
+ from importlib import metadata as importlib_metadata
9
+ from typing import TYPE_CHECKING, Optional, Union
10
+
11
+ if TYPE_CHECKING:
12
+ from peeringdb.backend import Interface
13
+
14
+ from peeringdb.util import get_log_level, str_to_bool
15
+
16
+ __version__: str = importlib_metadata.version("peeringdb")
17
+ _log_level: int = logging.INFO
18
+
19
+
20
+ def _config_logs(
21
+ level: Optional[Union[str, int]] = None,
22
+ name: Optional[str] = None,
23
+ allow_other_loggers: Union[bool, str] = False,
24
+ ) -> None:
25
+ """
26
+ Set up or change logging configuration.
27
+
28
+ _config_logs() => idempotent setup;
29
+ _config_logs(L) => change log level
30
+ """
31
+ # print('_config_log', 'from %s' %name if name else '')
32
+ logging_format = "%(message)s"
33
+ # maybe better for log files
34
+ # FORMAT='[%(levelname)s]:%(message)s',
35
+
36
+ if isinstance(level, str):
37
+ level = get_log_level(level)
38
+
39
+ global _log_level
40
+ if level:
41
+ _log_level = level
42
+
43
+ if not isinstance(allow_other_loggers, bool):
44
+ try:
45
+ allow_other_loggers = str_to_bool(str(allow_other_loggers))
46
+ except Exception:
47
+ allow_other_loggers = False
48
+
49
+ if not allow_other_loggers:
50
+ # Reset handlers
51
+ for h in list(logging.root.handlers):
52
+ logging.root.removeHandler(h)
53
+
54
+ logging.basicConfig(level=_log_level, format=logging_format, stream=sys.stdout)
55
+ _log = logging.getLogger(__name__)
56
+ _log.setLevel(_log_level)
57
+
58
+ # external
59
+ for log in ["urllib3", "asyncio"]:
60
+ logging.getLogger(log).setLevel(_log_level)
61
+
62
+
63
+ class BackendError(Exception):
64
+ pass
65
+
66
+
67
+ # Map external module names to adaptor modules
68
+ SUPPORTED_BACKENDS: dict[str, str] = {
69
+ "django_peeringdb": "django_peeringdb.client_adaptor",
70
+ }
71
+
72
+ __backend: Optional[tuple["Interface", tuple[str, str]]] = None
73
+
74
+
75
+ def backend_initialized() -> bool:
76
+ return __backend is not None
77
+
78
+
79
+ def _get() -> tuple["Interface", tuple[str, str]]:
80
+ global __backend
81
+ if __backend:
82
+ return __backend # type: ignore[unreachable]
83
+ # mypy has trouble with global analysis here
84
+ raise BackendError("Backend not initialized")
85
+
86
+
87
+ def get_backend() -> "Interface":
88
+ return _get()[0]
89
+
90
+
91
+ def get_backend_info() -> tuple[str, str]:
92
+ return _get()[1]
93
+
94
+
95
+ def initialize_backend(name: str, **kwargs: Union[str, int, bool, dict]) -> None:
96
+ global __backend
97
+ if __backend:
98
+ raise BackendError("Backend already initialized")
99
+
100
+ try:
101
+ modname = SUPPORTED_BACKENDS[name]
102
+ except KeyError:
103
+ raise ValueError(f"Not a supported backend module: '{name}'")
104
+ # Load internal module associated with the ORM module
105
+ supportmod = import_module(modname)
106
+ # Backend is any object returned from load_backend
107
+ backend = supportmod.load_backend(**kwargs)
108
+
109
+ backend.Backend.setup()
110
+ __backend = (backend.Backend(), (name, backend.__version__))
111
+
112
+
113
+ # TODO
114
+ # def is_valid_backend(backend): ...
115
+
116
+ # namespace imports - import client at the end to avoid circular imports
117
+ from peeringdb import client # noqa: E402
@@ -0,0 +1,22 @@
1
+ import logging
2
+
3
+ # import pdb
4
+
5
+
6
+ def log_validation_errors(backend, e, obj, k):
7
+ log = logging.getLogger("peeringdb.sync")
8
+ log.debug(f"{e} : errors: {e.message_dict}")
9
+ for k, v in e.message_dict.items():
10
+ field = backend.get_field(obj, k)
11
+ try:
12
+ log.debug(f"{k}: {getattr(obj, k)}, dict: {field.__dict__}")
13
+ except backend.object_missing_error():
14
+ log.debug(f"{k}: Missing Object, dict: {field.__dict__}")
15
+
16
+
17
+ def try_or_debug(f):
18
+ try:
19
+ return f()
20
+ except Exception:
21
+ # pdb.set_trace() ???
22
+ raise
@@ -0,0 +1,8 @@
1
+ import http.client
2
+ import logging
3
+
4
+ http.client.HTTPConnection.debuglevel = 1
5
+
6
+ requests_log = logging.getLogger("requests.packages.urllib3")
7
+ requests_log.setLevel(logging.DEBUG)
8
+ requests_log.propagate = True
peeringdb/_sync.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ Sync implementation module
3
+ """
4
+
5
+ from collections import defaultdict
6
+ from typing import TYPE_CHECKING, Union
7
+
8
+ if TYPE_CHECKING:
9
+ from peeringdb.backend import Interface
10
+
11
+ from peeringdb.util import group_fields
12
+
13
+
14
+ def _field_resource(backend: "Interface", concrete: type, field: object) -> type:
15
+ field_name = getattr(field, "name", None)
16
+ if field_name is None:
17
+ raise ValueError("Field has no name")
18
+ field_concrete = backend.get_field_concrete(concrete, field_name)
19
+ if not isinstance(field_concrete, type):
20
+ raise ValueError(f"Expected type, got {type(field_concrete)}")
21
+ return backend.get_resource(field_concrete)
22
+
23
+
24
+ def _get_subrow(
25
+ row: dict[str, Union[str, int, bool, list, dict]], fname: str, field: object
26
+ ) -> tuple[str, Union[str, int, bool, list, dict, None]]:
27
+ key = getattr(field, "column", None)
28
+ if key is not None and isinstance(key, str):
29
+ subrow = row.get(key)
30
+ else:
31
+ key = fname
32
+ subrow = None
33
+ if subrow is None: # e.g. use "org" if "org_id" is missing
34
+ key = fname
35
+ try:
36
+ subrow = row[key]
37
+ except KeyError:
38
+ subrow = None
39
+ return key, subrow
40
+
41
+
42
+ def extract_relations(
43
+ backend: "Interface", res: type, row: dict[str, Union[str, int, bool, list, dict]]
44
+ ) -> tuple[
45
+ dict[type, dict[Union[str, int], dict[str, Union[str, int, bool, list, dict]]]],
46
+ dict[type, set[Union[str, int]]],
47
+ ]:
48
+ field_groups = group_fields(backend, backend.get_concrete(res))
49
+ # Already-fetched, and id-only refs
50
+ fetched: dict = defaultdict(dict)
51
+ dangling = defaultdict(set)
52
+
53
+ # Handle subrows that might be shallow (id) or deep (dict)
54
+ def _handle_subrow(resource, subrow):
55
+ if isinstance(subrow, dict):
56
+ pk = subrow["id"]
57
+ fetched[resource][pk] = subrow
58
+ elif subrow is None:
59
+ return
60
+ else:
61
+ pk = subrow
62
+ dangling[resource].add(pk)
63
+ return pk
64
+
65
+ for fname, field in field_groups["single_refs"].items():
66
+ fieldres = _field_resource(backend, backend.get_concrete(res), field)
67
+ _, subrow = _get_subrow(row, fname, field)
68
+ _handle_subrow(fieldres, subrow)
69
+
70
+ for fname, field in field_groups["many_refs"].items():
71
+ fieldres = _field_resource(backend, backend.get_concrete(res), field)
72
+ many_data = row.get(fname, [])
73
+ if isinstance(many_data, list):
74
+ for subrow in many_data:
75
+ _handle_subrow(fieldres, subrow)
76
+
77
+ return fetched, dangling
78
+
79
+
80
+ def set_single_relations(
81
+ backend: "Interface",
82
+ res: type,
83
+ obj: object,
84
+ row: dict[str, Union[str, int, bool, list, dict]],
85
+ ) -> None:
86
+ field_groups = group_fields(backend, backend.get_concrete(res))
87
+ for fname, field in field_groups["single_refs"].items():
88
+ key, subrow = _get_subrow(row, fname, field)
89
+ if isinstance(subrow, dict):
90
+ pk = subrow["id"]
91
+ else:
92
+ pk = subrow
93
+ setattr(obj, key, pk)
94
+
95
+
96
+ def set_many_relations(
97
+ backend: "Interface",
98
+ res: type,
99
+ obj: object,
100
+ row: dict[str, Union[str, int, bool, list, dict]],
101
+ ) -> None:
102
+ field_groups = group_fields(backend, backend.get_concrete(res))
103
+ for fname, field in field_groups["many_refs"].items():
104
+ fieldres = _field_resource(backend, backend.get_concrete(res), field)
105
+ pks_data = row.get(fname, [])
106
+ if isinstance(pks_data, list):
107
+ pks = pks_data
108
+ else:
109
+ pks = []
110
+ objs = [backend.get_object(backend.get_concrete(fieldres), pk) for pk in pks]
111
+ backend.set_relation_many_to_many(obj, fname, objs)
peeringdb/_update.py ADDED
@@ -0,0 +1,309 @@
1
+ """
2
+ Module defining main interface classes for sync
3
+ """
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Optional, Union
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+ from peeringdb import config, get_backend
13
+ from peeringdb._sync import extract_relations, set_many_relations, set_single_relations
14
+ from peeringdb.fetch import Fetcher
15
+ from peeringdb.private import private_data_has_been_fetched
16
+ from peeringdb.util import group_fields, log_error
17
+
18
+
19
+ class Updater:
20
+ """
21
+ Handles initial and incremental update from a PeeringDB remote API
22
+ to the local backend.
23
+
24
+ The updater is responsible for creating and updating objects in the local
25
+ backend. It does this by fetching objects from the remote API and creating
26
+ or updating the corresponding objects in the local backend.
27
+ """
28
+
29
+ def __init__(self, fetcher: Fetcher):
30
+ self._log = logging.getLogger(__name__)
31
+ self.resources: dict = {}
32
+ self.backend = get_backend()
33
+ self.fetcher = fetcher
34
+ self.config = config.load_config()
35
+
36
+ def copy_object(self, new):
37
+ """
38
+ Copies a new object to an existing one
39
+ :return:
40
+ """
41
+ old = self.backend.get_object(new.__class__, new.id)
42
+ for field in self.backend.get_fields(new.__class__):
43
+ try:
44
+ setattr(old, field.name, getattr(new, field.name))
45
+ except TypeError as e:
46
+ if "Direct assignment" in str(e):
47
+ pass # Ignore refs
48
+ else:
49
+ raise
50
+
51
+ self.clean_obj(old)
52
+ self.backend.save(old)
53
+
54
+ def clean_obj(self, obj):
55
+ """
56
+ Run object through backend validation
57
+
58
+ Will raise an exception if the object is not valid
59
+ """
60
+
61
+ try:
62
+ self.backend.clean(obj)
63
+ except self.backend.validation_error() as e:
64
+ # e.message_dict contains field names as keys and lists of errors as values
65
+ for errors in e.message_dict.values():
66
+ for error in errors:
67
+ # Checking if the error message is the one we want to ignore
68
+ # field is allowed to be None in the db, but not blank according
69
+ # to backend validation. We ignore this error since the data is
70
+ # already validated and writing None to the db is fine.
71
+ if error != "This field cannot be blank.":
72
+ raise e
73
+
74
+ def create_obj(
75
+ self, row: dict[str, Union[str, int, bool, list, dict]], res: type
76
+ ) -> tuple[object, bool]: # noqa: C901
77
+ """
78
+ Create a model instance from a row
79
+ :param row: Object from API
80
+ :param res: Resource to create
81
+ """
82
+ _, dangling = extract_relations(self.backend, res, row)
83
+ for resource, pks in dangling.items():
84
+ for pk in pks:
85
+ # Check if we have it
86
+ rel_obj = None
87
+ try:
88
+ self.backend.get_object(self.backend.get_concrete(resource), pk)
89
+ except self.backend.object_missing_error(
90
+ self.backend.get_concrete(resource)
91
+ ):
92
+ # We dont have the dangling relationship, so we try to fetch it
93
+ # from the api and create it.
94
+
95
+ self._log.info("Fetching dangling relationship %s %s", resource, pk)
96
+ related_row = self.fetcher.get(resource.tag, int(pk))
97
+
98
+ # instantiate the relationship object
99
+
100
+ rel_obj, _ = self.create_obj(related_row, resource)
101
+ try:
102
+ self.clean_obj(rel_obj)
103
+ except self.backend.validation_error() as e:
104
+ self._log.error(
105
+ "Failed to clean dangling object %s %s: %s", resource, pk, e
106
+ )
107
+ return None, False
108
+
109
+ # save the relationship object
110
+
111
+ if rel_obj:
112
+ self.backend.save(rel_obj)
113
+
114
+ # Initialize object
115
+ field_groups = group_fields(self.backend, self.backend.get_concrete(res))
116
+ try:
117
+ obj = self.backend.get_object(self.backend.get_concrete(res), row["id"])
118
+ except self.backend.object_missing_error(self.backend.get_concrete(res)):
119
+ tbl = self.backend.get_concrete(res)
120
+ obj = tbl()
121
+
122
+ # set_scalars
123
+ for fname, field in field_groups["scalars"].items():
124
+ value = row.get(fname, getattr(obj, fname, None))
125
+ value = self.backend.convert_field(obj.__class__, fname, value)
126
+
127
+ # TODO: datetimes are strings for some reason
128
+ if (
129
+ fname in ["created", "updated", "rir_status_updated", "ixf_last_import"]
130
+ and isinstance(value, str)
131
+ ) or (getattr(obj, "tzinfo", None) is not None):
132
+ value = datetime.fromisoformat(value.rstrip("Z"))
133
+ # elif (isinstance(value, str) and "T" in value and
134
+ # "Z" in value and "-" in value):
135
+ # print("NOT DATETIME", fname, value, type(value))
136
+
137
+ # Remove timezone info
138
+ if isinstance(value, datetime):
139
+ value = value.replace(tzinfo=None)
140
+
141
+ setattr(obj, fname, value)
142
+ self._log.debug(" %s: %s (%s)", fname, value, type(value))
143
+
144
+ set_single_relations(self.backend, res, obj, row)
145
+ set_many_relations(self.backend, res, obj, row)
146
+
147
+ try:
148
+ self.clean_obj(obj)
149
+ except self.backend.validation_error() as e:
150
+ self._log.debug("[%s] Failed to clean %s %s: %s", res.tag, res, obj, e)
151
+ if "already exists" in str(e):
152
+ self.update_collision(res, row, e)
153
+ self.clean_obj(obj)
154
+ return obj, True
155
+
156
+ return obj, False
157
+
158
+ def _handle_initial_sync(self, entries: list, res):
159
+ """
160
+ Called during the first sync of a resource
161
+
162
+ This will do a batch create of all objects in the resource
163
+ :param entries: List of objects from API
164
+ :param res: Resource to sync
165
+ """
166
+
167
+ objs = []
168
+ for row in entries:
169
+ try:
170
+ obj, _ = self.create_obj(row, res)
171
+ objs.append(obj)
172
+ except self.backend.object_missing_error(self.backend.get_concrete(res)):
173
+ try:
174
+ obj, _ = self.create_obj(row, res)
175
+ self.backend.save(obj)
176
+ except Exception as e:
177
+ obj_id = row.get("id", "Unknown")
178
+ self._log.info(f"Error creating {res.tag} with id {obj_id}: {e}")
179
+ log_error(self.config, res.tag, row.get("id", "Unknown"), str(e))
180
+ except Exception as e:
181
+ obj_id = row.get("id", "Unknown")
182
+ self._log.info(f"Error updating {res.tag} with id {obj_id}: {e}")
183
+ log_error(self.config, res.tag, row.get("id", "Unknown"), str(e))
184
+
185
+ self.backend.get_concrete(res).objects.bulk_create(objs)
186
+
187
+ def _handle_incremental_sync(self, entries: list, res):
188
+ """
189
+ Called during an incremental sync of a resource (i.e. not the first sync)
190
+
191
+ Entries will only contain objects that have changed since the last sync
192
+ :param entries: List of objects from API
193
+ :param res: Resource to sync
194
+ """
195
+
196
+ for row in entries:
197
+ try:
198
+ self.backend.get_object(self.backend.get_concrete(res), row["id"])
199
+ obj, _ = self.create_obj(row, res)
200
+ self.copy_object(obj)
201
+ except self.backend.object_missing_error(self.backend.get_concrete(res)):
202
+ try:
203
+ obj, _ = self.create_obj(row, res)
204
+ self.backend.save(obj)
205
+ except Exception as e:
206
+ obj_id = row.get("id", "Unknown")
207
+ self._log.info(f"Error creating {res.tag} with id {obj_id}: {e}")
208
+ log_error(self.config, res.tag, row.get("id", "Unknown"), str(e))
209
+ except Exception as e:
210
+ obj_id = row.get("id", "Unknown")
211
+ self._log.info(f"Error updating {res.tag} with id {obj_id}: {e}")
212
+ log_error(self.config, res.tag, row.get("id", "Unknown"), str(e))
213
+
214
+ def update_all(
215
+ self,
216
+ rs: list[type],
217
+ since: Optional[int] = None,
218
+ skip: Union[list[str], None] = None,
219
+ fetch_private: bool = False,
220
+ ):
221
+ """
222
+ Update all objects of a given type
223
+ :param rs: List of resources to update
224
+ :param since: Unix timestamp of last update
225
+ :param skip: List of resource tags to skip
226
+ """
227
+
228
+ for res in rs:
229
+ if skip is not None:
230
+ for i in skip:
231
+ self.fetcher.load(i, since)
232
+
233
+ if skip and res.tag in skip:
234
+ self._log.info("[%s] Skipping", res.tag)
235
+ continue
236
+
237
+ if since is None:
238
+ _since = self.backend.last_change(self.backend.get_concrete(res))
239
+ else:
240
+ _since = since
241
+
242
+ initial_private = False
243
+ if fetch_private:
244
+ initial_private = not private_data_has_been_fetched(self.backend, res)
245
+
246
+ self.fetcher.load(
247
+ res.tag,
248
+ _since + 1 if _since and isinstance(_since, int) else None,
249
+ fetch_private=fetch_private,
250
+ initial_private=initial_private,
251
+ )
252
+ entries = self.fetcher.entries(res.tag)
253
+ self._log.info("[%s] Processing %d objects", res.tag, len(entries))
254
+
255
+ if not _since:
256
+ self._handle_initial_sync(entries, res)
257
+ else:
258
+ self._handle_incremental_sync(entries, res)
259
+
260
+ def update_one(self, res, pk: int, depth=0):
261
+ """
262
+ Update a single object
263
+ :param res: Resource to update
264
+ :param pk: Primary key of object to update
265
+ :param depth: Depth of recursion
266
+ :return:
267
+ """
268
+ if depth != 0:
269
+ # no longer relevant, deprecation warning
270
+ self._log.warning(
271
+ "update_one: depth parameter is not used and will be removed "
272
+ "in a future version"
273
+ )
274
+
275
+ row = self.fetcher.get(res.tag, pk, depth=0, force_fetch=True)
276
+ # create object instance (unsaved)
277
+ obj, _ = self.create_obj(row, res)
278
+ try:
279
+ # attempt update existing instance of object (if exists, will save)
280
+ self.copy_object(obj)
281
+ except self.backend.object_missing_error(self.backend.get_concrete(res)):
282
+ # object does not exist, create object instance and save as
283
+ # new object
284
+ obj, _ = self.create_obj(row, res)
285
+ self.backend.save(obj)
286
+
287
+ def update_collision(self, res, row: dict, exc: Exception):
288
+ """
289
+ Sometimes we encounter edge-case collisions triggered by a
290
+ unique constraint validation error. This function attempts to
291
+ resolve the collision by updating the colliding object from the api
292
+ :param res: Resource to update
293
+ :param row: Row from API
294
+ :param exc: Exception raised by validation error
295
+ """
296
+
297
+ filters = {}
298
+ for field, error_message in exc.error_dict.items():
299
+ if "already exists" in str(error_message):
300
+ filters[field] = row[field]
301
+
302
+ for field, value in filters.items():
303
+ self._log.debug(
304
+ "[%s] Updating collision %s %s=%s", res.tag, res, field, value
305
+ )
306
+ collision = self.backend.get_object_by(
307
+ self.backend.get_concrete(res), field, value
308
+ )
309
+ self.update_one(res, collision.id)