Skip to content

Commit be67a12

Browse files
Merge pull request #17 from networktocode-llc/gfm-logging
Enable structured logging with structlog
2 parents 123397b + 4dfb41d commit be67a12

File tree

11 files changed

+231
-79
lines changed

11 files changed

+231
-79
lines changed

dsync/__init__.py

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
from collections.abc import Iterable as ABCIterable, Mapping as ABCMapping
1616
import enum
1717
from inspect import isclass
18-
import logging
1918
from typing import ClassVar, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
2019

2120
from pydantic import BaseModel
21+
import structlog # type: ignore
2222

2323
from .diff import Diff, DiffElement
2424
from .utils import intersection
@@ -32,7 +32,7 @@
3232
ObjectNotFound,
3333
)
3434

35-
logger = logging.getLogger(__name__)
35+
_logger = structlog.get_logger()
3636

3737

3838
class DSyncFlags(enum.Flag):
@@ -339,6 +339,7 @@ def __init__(self, name=None):
339339
Subclasses should be careful to call super().__init__() if they override this method.
340340
"""
341341
self._data = defaultdict(dict)
342+
self._log = _logger.new(dsync=self)
342343

343344
# If the type is not defined, use the name of the class as the default value
344345
if self.type is None:
@@ -359,6 +360,15 @@ def __init_subclass__(cls):
359360
f'Incorrect field name - {value.__name__} has type name "{value.get_type()}", not "{name}"'
360361
)
361362

363+
def __str__(self):
364+
"""String representation of a DSync."""
365+
if self.type != self.name:
366+
return f'{self.type} "{self.name}"'
367+
return self.type
368+
369+
def __repr__(self):
370+
return f"<{str(self)}>"
371+
362372
def load(self):
363373
"""Load all desired data from whatever backend data source into this instance."""
364374
# No-op in this generic class
@@ -375,12 +385,13 @@ def sync_from(self, source: "DSync", diff_class: Type[Diff] = Diff, flags: DSync
375385
diff_class (class): Diff or subclass thereof to use to calculate the diffs to use for synchronization
376386
flags (DSyncFlags): Flags influencing the behavior of this sync.
377387
"""
388+
log = self._log.bind(src=source, dst=self, flags=flags).unbind("dsync")
378389
diff = self.diff_from(source, diff_class=diff_class, flags=flags)
379390

380-
logger.info("Beginning sync")
391+
log.info("Beginning sync")
381392
for child in diff.get_children():
382-
self._sync_from_diff_element(child, flags=flags)
383-
logger.info("Sync complete")
393+
self._sync_from_diff_element(child, flags=flags, logger=log)
394+
log.info("Sync complete")
384395

385396
def sync_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFlags = DSyncFlags.NONE):
386397
"""Synchronize data from the current DSync object into the given target DSync object.
@@ -393,7 +404,11 @@ def sync_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFl
393404
target.sync_from(self, diff_class=diff_class, flags=flags)
394405

395406
def _sync_from_diff_element(
396-
self, element: DiffElement, flags: DSyncFlags = DSyncFlags.NONE, parent_model: DSyncModel = None,
407+
self,
408+
element: DiffElement,
409+
flags: DSyncFlags = DSyncFlags.NONE,
410+
logger: structlog.BoundLogger = None,
411+
parent_model: DSyncModel = None,
397412
):
398413
"""Synchronize a given DiffElement (and its children, if any) into this DSync.
399414
@@ -402,49 +417,53 @@ def _sync_from_diff_element(
402417
Args:
403418
element: DiffElement to synchronize diffs from
404419
flags (DSyncFlags): Flags influencing the behavior of this sync.
420+
logger: Parent logging context
405421
parent_model: Parent object to update (`add_child`/`remove_child`) if the sync creates/deletes an object.
406422
"""
407423
# pylint: disable=too-many-branches
408424
# GFM: I made a few attempts at refactoring this to reduce the branching, but found that it was less readable.
409425
# So let's live with the slightly too high number of branches (14/12) for now.
426+
log = logger or self._log
410427
object_class = getattr(self, element.type)
411428
obj = self.get(object_class, element.keys)
412-
attrs = (
413-
{attr_key: element.source_attrs[attr_key] for attr_key in element.get_attrs_keys()}
414-
if element.source_attrs is not None
415-
else {}
429+
# Get the attributes that actually differ between source and dest
430+
diffs = element.get_attrs_diffs()
431+
log = log.bind(
432+
action=element.action,
433+
model=object_class.get_type(),
434+
unique_id=object_class.create_unique_id(**element.keys),
435+
diffs=diffs,
416436
)
417437

418438
try:
419439
if element.action == "create":
440+
log.debug("Attempting object creation")
420441
if obj:
421442
raise ObjectNotCreated(f"Failed to create {object_class.get_type()} {element.keys} - it exists!")
422-
logger.info("Creating %s %s (%s)", object_class.get_type(), element.keys, attrs)
423-
obj = object_class.create(dsync=self, ids=element.keys, attrs=attrs)
443+
obj = object_class.create(dsync=self, ids=element.keys, attrs={key: diffs[key]["src"] for key in diffs})
444+
log.info("Created successfully", status="success")
424445
elif element.action == "update":
446+
log.debug("Attempting object update")
425447
if not obj:
426448
raise ObjectNotUpdated(f"Failed to update {object_class.get_type()} {element.keys} - not found!")
427-
logger.info("Updating %s %s with %s", object_class.get_type(), element.keys, attrs)
428-
obj = obj.update(attrs=attrs)
449+
obj = obj.update(attrs={key: diffs[key]["src"] for key in diffs})
450+
log.info("Updated successfully", status="success")
429451
elif element.action == "delete":
452+
log.debug("Attempting object deletion")
430453
if not obj:
431454
raise ObjectNotDeleted(f"Failed to delete {object_class.get_type()} {element.keys} - not found!")
432-
logger.info("Deleting %s %s", object_class.get_type(), element.keys)
433455
obj = obj.delete()
456+
log.info("Deleted successfully", status="success")
434457
except ObjectCrudException as exception:
435-
logger.error(
436-
"Error during %s of %s %s (%s): %s",
437-
element.action,
438-
object_class.get_type(),
439-
element.keys,
440-
attrs,
441-
exception,
442-
)
458+
log.error(str(exception), status="error")
443459
if not flags & DSyncFlags.CONTINUE_ON_FAILURE:
444460
raise
461+
else:
462+
if obj is None:
463+
log.warning("Non-fatal failure encountered", status="failure")
445464

446465
if obj is None:
447-
logger.warning("Not syncing children of %s %s", element.type, element.keys)
466+
log.warning("Not syncing children")
448467
return
449468

450469
if element.action == "create":
@@ -457,7 +476,7 @@ def _sync_from_diff_element(
457476
parent_model.remove_child(obj)
458477

459478
for child in element.get_children():
460-
self._sync_from_diff_element(child, flags=flags, parent_model=obj)
479+
self._sync_from_diff_element(child, flags=flags, parent_model=obj, logger=logger)
461480

462481
# ------------------------------------------------------------------------------
463482
# Diff calculation and construction
@@ -471,20 +490,25 @@ def diff_from(self, source: "DSync", diff_class: Type[Diff] = Diff, flags: DSync
471490
diff_class (class): Diff or subclass thereof to use for diff calculation and storage.
472491
flags (DSyncFlags): Flags influencing the behavior of this diff operation.
473492
"""
474-
logger.info("Beginning diff")
493+
log = self._log.bind(src=source, dst=self, flags=flags).unbind("dsync")
494+
log.info("Beginning diff")
475495
diff = diff_class()
476496

477497
for obj_type in intersection(self.top_level, source.top_level):
478498

479499
diff_elements = self._diff_objects(
480-
source=source.get_all(obj_type), dest=self.get_all(obj_type), source_root=source, flags=flags,
500+
source=source.get_all(obj_type),
501+
dest=self.get_all(obj_type),
502+
source_root=source,
503+
flags=flags,
504+
logger=log,
481505
)
482506

483507
for diff_element in diff_elements:
484508
diff.add(diff_element)
485509

486510
# Notify the diff that it has been fully populated, in case it wishes to print, save to a file, etc.
487-
logger.info("Diff complete")
511+
log.info("Diff complete")
488512
diff.complete()
489513
return diff
490514

@@ -498,12 +522,13 @@ def diff_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFl
498522
"""
499523
return target.diff_from(self, diff_class=diff_class, flags=flags)
500524

501-
def _diff_objects(
525+
def _diff_objects( # pylint: disable=too-many-arguments
502526
self,
503527
source: Iterable[DSyncModel],
504528
dest: Iterable[DSyncModel],
505529
source_root: "DSync",
506530
flags: DSyncFlags = DSyncFlags.NONE,
531+
logger: structlog.BoundLogger = None,
507532
) -> List[DiffElement]:
508533
"""Generate a list of DiffElement between the given lists of objects.
509534
@@ -514,6 +539,7 @@ def _diff_objects(
514539
dest: DSyncModel instances retrieved from this DSync instance
515540
source_root (DSync): The other DSync object being diffed against (owner of the `source` models, if any)
516541
flags (DSyncFlags): Flags influencing the behavior of this diff operation.
542+
logger: Parent logging context
517543
518544
Raises:
519545
TypeError: if the source and dest args are not the same type, or if that type is unsupported
@@ -537,16 +563,16 @@ def _diff_objects(
537563
self._validate_objects_for_diff(combined_dict)
538564

539565
for uid in combined_dict:
566+
log = logger or self._log
540567
src_obj, dst_obj = combined_dict[uid]
541-
542-
if flags & DSyncFlags.SKIP_UNMATCHED_SRC and not dst_obj:
543-
logger.debug("Skipping unmatched source object {src_obj}")
544-
continue
545-
if flags & DSyncFlags.SKIP_UNMATCHED_DST and not src_obj:
546-
logger.debug("Skipping unmatched dest object {dst_obj}")
547-
continue
548-
568+
if not src_obj and not dst_obj:
569+
# Should never happen
570+
raise RuntimeError(f"UID {uid} is in combined_dict but has neither src_obj nor dst_obj??")
549571
if src_obj:
572+
log = log.bind(model=src_obj.get_type(), unique_id=src_obj.get_unique_id())
573+
if flags & DSyncFlags.SKIP_UNMATCHED_SRC and not dst_obj:
574+
log.debug("Skipping unmatched source object")
575+
continue
550576
diff_element = DiffElement(
551577
obj_type=src_obj.get_type(),
552578
name=src_obj.get_shortname(),
@@ -555,24 +581,25 @@ def _diff_objects(
555581
dest_name=self.name,
556582
)
557583
elif dst_obj:
584+
log = log.bind(model=dst_obj.get_type(), unique_id=dst_obj.get_unique_id())
585+
if flags & DSyncFlags.SKIP_UNMATCHED_DST and not src_obj:
586+
log.debug("Skipping unmatched dest object")
587+
continue
558588
diff_element = DiffElement(
559589
obj_type=dst_obj.get_type(),
560590
name=dst_obj.get_shortname(),
561591
keys=dst_obj.get_identifiers(),
562592
source_name=source_root.name,
563593
dest_name=self.name,
564594
)
565-
else:
566-
# Should be unreachable
567-
raise RuntimeError(f"UID {uid} is in combined_dict but has neither src_obj nor dst_obj??")
568595

569596
if src_obj:
570597
diff_element.add_attrs(source=src_obj.get_attrs(), dest=None)
571598
if dst_obj:
572599
diff_element.add_attrs(source=None, dest=dst_obj.get_attrs())
573600

574601
# Recursively diff the children of src_obj and dst_obj and attach the resulting diffs to the diff_element
575-
self._diff_child_objects(diff_element, src_obj, dst_obj, source_root, flags)
602+
self._diff_child_objects(diff_element, src_obj, dst_obj, source_root, flags=flags, logger=logger)
576603

577604
diffs.append(diff_element)
578605

@@ -607,6 +634,7 @@ def _diff_child_objects( # pylint: disable=too-many-arguments
607634
dst_obj: Optional[DSyncModel],
608635
source_root: "DSync",
609636
flags: DSyncFlags,
637+
logger: structlog.BoundLogger,
610638
):
611639
"""For all children of the given DSyncModel pair, diff them recursively, adding diffs to the given diff_element.
612640
@@ -638,7 +666,7 @@ def _diff_child_objects( # pylint: disable=too-many-arguments
638666
dst_objs = self.get_by_uids(getattr(dst_obj, child_fieldname), child_type) if dst_obj else []
639667

640668
for child_diff_element in self._diff_objects(
641-
source=src_objs, dest=dst_objs, source_root=source_root, flags=flags
669+
source=src_objs, dest=dst_objs, source_root=source_root, flags=flags, logger=logger,
642670
):
643671
diff_element.add_child(child_diff_element)
644672

dsync/diff.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""
1515

1616
from functools import total_ordering
17-
from typing import Iterator, Iterable, Optional
17+
from typing import Any, Iterator, Iterable, Mapping, Optional
1818

1919
from .exceptions import ObjectAlreadyExists
2020
from .utils import intersection, OrderedDefaultDict
@@ -165,7 +165,7 @@ def __eq__(self, other):
165165

166166
def __str__(self):
167167
"""Basic string representation of a DiffElement."""
168-
return f"{self.type} : {self.name} : {self.keys} : {self.source_name}:{self.source_attrs} : {self.dest_name}:{self.dest_attrs}"
168+
return f'{self.type} "{self.name}" : {self.keys} : {self.source_name} {self.dest_name} : {self.get_attrs_diffs()}'
169169

170170
@property
171171
def action(self) -> Optional[str]:
@@ -212,6 +212,26 @@ def get_attrs_keys(self) -> Iterable[str]:
212212
return self.source_attrs.keys()
213213
return []
214214

215+
# The below would be more accurate but typing.Literal is only in Python 3.8 and later
216+
# def get_attrs_diffs(self) -> Mapping[str, Mapping[Literal["src", "dst"], Any]]:
217+
def get_attrs_diffs(self) -> Mapping[str, Mapping[str, Any]]:
218+
"""Get the dict of actual attribute diffs between source_attrs and dest_attrs.
219+
220+
Returns:
221+
dict: of the form `{key: {src: <value>, dst: <value>}, key2: ...}`
222+
"""
223+
if self.source_attrs is not None and self.dest_attrs is not None:
224+
return {
225+
key: dict(src=self.source_attrs[key], dst=self.dest_attrs[key])
226+
for key in self.get_attrs_keys()
227+
if self.source_attrs[key] != self.dest_attrs[key]
228+
}
229+
if self.source_attrs is None and self.dest_attrs is not None:
230+
return {key: dict(src=None, dst=self.dest_attrs[key]) for key in self.get_attrs_keys()}
231+
if self.source_attrs is not None and self.dest_attrs is None:
232+
return {key: dict(src=self.source_attrs[key], dst=None) for key in self.get_attrs_keys()}
233+
return {}
234+
215235
def add_child(self, element: "DiffElement"):
216236
"""Attach a child object of type DiffElement.
217237
@@ -262,10 +282,7 @@ def print_detailed(self, indent: int = 0):
262282
else:
263283
print(f"{margin}{self.type}: {self.name}")
264284
# Only print attrs that have meaning in both source and dest
265-
for attr in self.get_attrs_keys():
266-
if self.source_attrs[attr] != self.dest_attrs[attr]:
267-
print(
268-
f"{margin} {attr} {self.source_name}({self.source_attrs[attr]}) {self.dest_name}({self.dest_attrs[attr]})"
269-
)
285+
for attr, item in self.get_attrs_diffs().items():
286+
print(f"{margin} {attr} {self.source_name}({item.get('src')}) {self.dest_name}({item.get('dst')})")
270287

271288
self.child_diff.print_detailed(indent + 2)

dsync/logging.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Helpful APIs for setting up DSync logging."""
2+
3+
import logging
4+
5+
import structlog # type: ignore
6+
7+
8+
def enable_console_logging(verbosity=0):
9+
"""Enable formatted logging to console with the specified verbosity.
10+
11+
See https://www.structlog.org/en/stable/development.html as a reference
12+
13+
Args:
14+
verbosity (int): 0 for WARNING logs, 1 for INFO logs, 2 for DEBUG logs
15+
"""
16+
if verbosity == 0:
17+
logging.basicConfig(format="%(message)s", level=logging.WARNING)
18+
elif verbosity == 1:
19+
logging.basicConfig(format="%(message)s", level=logging.INFO)
20+
else:
21+
logging.basicConfig(format="%(message)s", level=logging.DEBUG)
22+
23+
structlog.configure(
24+
processors=[
25+
structlog.stdlib.filter_by_level, # <-- added
26+
structlog.stdlib.add_logger_name,
27+
structlog.stdlib.add_log_level,
28+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"),
29+
structlog.processors.StackInfoRenderer(),
30+
structlog.processors.format_exc_info,
31+
structlog.dev.ConsoleRenderer(),
32+
],
33+
context_class=dict,
34+
logger_factory=structlog.stdlib.LoggerFactory(),
35+
wrapper_class=structlog.stdlib.BoundLogger,
36+
cache_logger_on_first_use=True,
37+
)

examples/example1/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ c = BackendC()
2222
c.load()
2323
```
2424

25+
Configure verbosity of DSync's structured logging to console; the default is full verbosity (all logs including debugging)
26+
```python
27+
from dsync.logging import enable_console_logging
28+
enable_console_logging(verbosity=0) # Show WARNING and ERROR logs only
29+
# enable_console_logging(verbosity=1) # Also include INFO logs
30+
# enable_console_logging(verbosity=2) # Also include DEBUG logs
31+
```
32+
2533
Show the differences between A and B
2634
```python
2735
diff_a_b = a.diff_to(b)
@@ -46,4 +54,4 @@ diff_a_b = a.diff_to(b)
4654
diff_a_b.print_detailed()
4755
```
4856

49-
> In the Device model, the role is not defined as an attribute so it's not shown when we are comparing the different objects, even if the value is different.
57+
> In the Device model, the `site_name` and `role` are not included in the `_attributes`, so they are not shown when we are comparing the different objects, even if the value is different.

0 commit comments

Comments
 (0)