Skip to content

Commit

Permalink
Move get_model_fields() to formatters module, and add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
JWCook committed Aug 3, 2021
1 parent ae483d8 commit db5d74b
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 37 deletions.
48 changes: 37 additions & 11 deletions pyinaturalist/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from datetime import date, datetime
from functools import partial
from logging import basicConfig, getLogger
from typing import List, Type
from typing import Any, Iterable, List, Type

from attr import Attribute
from requests import PreparedRequest

from pyinaturalist.constants import DATETIME_SHORT_FORMAT, ResponseOrResults, ResponseResult
Expand All @@ -38,18 +39,9 @@
Taxon,
TaxonCount,
User,
get_model_fields,
get_lazy_attrs,
)

# If rich is installed, update its pretty-printer to include model properties
try:
from rich import pretty, print

pretty._get_attr_fields = get_model_fields
pretty.install()
except ImportError:
pass


def enable_logging(level: str = 'INFO'):
"""Configure logging to standard output with prettier tracebacks and terminal colors (if supported).
Expand Down Expand Up @@ -254,3 +246,37 @@ def _format_model_objects(obj: ResponseOrResults, cls: Type[BaseModel], **kwargs
format_species_counts = partial(_format_model_objects, cls=TaxonCount)
format_taxa = partial(_format_model_objects, cls=Taxon)
format_users = partial(_format_model_objects, cls=User)


def get_model_fields(obj: Any) -> Iterable[Attribute]:
"""Modification for rich's pretty-printer (specifically, ``rich.pretty._get_attr_fields``).
Adds placeholder attributes for lazy-loaded model properties so they get included in the output.
This is particularly useful for previewing in Jupyter or another REPL. These nested objects are
shown in condensed format so the preview is more readable. Otherwise, some objects]
(especially observations) can turn into a huge wall of text several pages long.
Does not change behavior for anything except :py:class:`.BaseModel` subclasses.
"""

def condense_nested_models(obj):
tab = ' '
if obj and isinstance(obj, list):
condensed_objs = f',\n{tab}{tab}'.join([str(o) for o in obj])
return f'[\n{tab}{tab}{condensed_objs}\n{tab}]'
return str(obj)

attrs = list(obj.__attrs_attrs__)
if isinstance(obj, BaseModel):
attrs += get_lazy_attrs(obj, repr=condense_nested_models)
return attrs


# If rich is installed, update its pretty-printer to include model properties
try:
from rich import pretty, print

pretty._get_attr_fields = get_model_fields
pretty.install()
except ImportError:
pass
2 changes: 1 addition & 1 deletion pyinaturalist/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from pyinaturalist.models.lazy_property import (
LazyProperty,
add_lazy_attrs,
get_lazy_attrs,
get_lazy_properties,
get_model_fields,
)


Expand Down
29 changes: 4 additions & 25 deletions pyinaturalist/models/lazy_property.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from functools import update_wrapper
from inspect import signature
from typing import Any, Callable, Dict, Iterable, List, Type
from typing import Callable, Dict, List, Type

from attr import Attribute, Factory

Expand Down Expand Up @@ -95,30 +95,9 @@ def add_lazy_attrs(cls, fields):
return list(fields) + [p.get_lazy_attr() for p in lazy_properties]


def get_model_fields(obj: Any) -> Iterable[Attribute]:
"""Modification for rich's pretty-printer (specifically, ``rich.pretty._get_attr_fields``).
Adds placeholder attributes for lazy-loaded model properties so they get included in the output.
This is particularly useful for previewing in Jupyter or another REPL. These nested objects are
shown in condensed format so the preview is more readable. Otherwise, some objects]
(especially observations) can turn into a huge wall of text several pages long.
Does not change behavior for anything except :py:class:`.BaseModel` subclasses.
"""

def condense_nested_models(obj):
if obj and isinstance(obj, list):
condensed_objs = ',\n '.join([str(o) for o in obj])
return f'[\n{condensed_objs}\n ]'
return str(obj)

attrs = list(obj.__attrs_attrs__)
if isinstance(obj, BaseModel):
attrs += [
make_attribute(property, repr=condense_nested_models)
for property in get_lazy_properties(type(obj))
]
return attrs
def get_lazy_attrs(obj, **kwargs) -> List[Attribute]:
"""Get placeholder attributes for lazy-loaded model properties"""
return [make_attribute(p, **kwargs) for p in get_lazy_properties(type(obj))]


def get_lazy_properties(cls: Type[BaseModel]) -> Dict[str, LazyProperty]:
Expand Down
92 changes: 92 additions & 0 deletions test/test_formatters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# flake8: noqa: F405
import re

import pytest
from rich.console import Console
from rich.table import Table
Expand Down Expand Up @@ -138,3 +140,93 @@ def test_simplify_observation():
simplified_obs = simplify_observations(j_observation_1)
# Not much worth testing here, just make sure it returns something that can be formatted
assert format_observations(simplified_obs)


PRINTED_OBSERVATION = """
Observation(
id=16227955,
created_at=datetime.datetime(2018, 9, 5, 0, 0, tzinfo=tzoffset('Europe/Paris', 3600)),
captive=False,
community_taxon_id=493595,
description='',
faves=[],
geoprivacy=None,
identifications_count=2,
identifications_most_agree=True,
identifications_most_disagree=False,
identifications_some_agree=True,
license_code='CC0',
location=(50.646894, 4.360086),
mappable=True,
num_identification_agreements=2,
num_identification_disagreements=0,
oauth_application_id=None,
obscured=False,
observed_on=datetime.datetime(2018, 9, 5, 14, 6, tzinfo=tzoffset('Europe/Paris', 3600)),
outlinks=[{'source': 'GBIF', 'url': 'http://www.gbif.org/occurrence/1914197587'}],
out_of_range=None,
owners_identification_from_vision=True,
place_guess='54 rue des Badauds',
place_ids=[7008, 8657, 14999, 59614, 67952, 80627, 81490, 96372, 96794, 97391, 97582, 108692],
positional_accuracy=23,
preferences={'prefers_community_taxon': None},
project_ids=[],
project_ids_with_curator_id=[],
project_ids_without_curator_id=[],
public_positional_accuracy=23,
quality_grade='research',
quality_metrics=[],
reviewed_by=[180811, 886482, 1226913],
site_id=1,
sounds=[],
species_guess='Lixus bardanae',
tags=[],
updated_at=datetime.datetime(2018, 9, 22, 19, 19, 27, tzinfo=tzoffset(None, 7200)),
uri='https://www.inaturalist.org/observations/16227955',
uuid='6448d03a-7f9a-4099-86aa-ca09a7740b00',
votes=[],
annotations=[],
comments=[
borisb on Sep 05, 2018: I now see: Bonus species on observation! You ma...,
borisb on Sep 05, 2018: suspect L. bardanae - but sits on Solanum (non-...
],
identifications=[
[34896306] 🪲 Genus: Lixus (improving) added on Sep 05, 2018 by niconoe,
[34926789] 🪲 Species: Lixus bardanae (improving) added on Sep 05, 2018 by borisb,
[36039221] 🪲 Species: Lixus bardanae (supporting) added on Sep 22, 2018 by jpreudhomme
],
ofvs=[],
photos=[
[24355315] https://static.inaturalist.org/photos/24355315/original.jpeg?1536150664 (CC-BY, 1445x1057),
[24355313] https://static.inaturalist.org/photos/24355313/original.jpeg?1536150659 (CC-BY, 2048x1364)
],
project_observations=[],
taxon=[493595] 🪲 Species: Lixus bardanae,
user=[886482] niconoe (Nicolas Noé)
)
"""


def test_get_model_fields():
"""Ensure that nested model objects are included in get_model_fields() output"""
observation = Observation.from_json(j_observation_1)
model_fields = get_model_fields(observation)

n_nested_model_objects = 8
n_regular_attrs = len(Observation.__attrs_attrs__)
assert len(model_fields) == n_regular_attrs + n_nested_model_objects


def test_pretty_print():
"""Test rich.pretty with modifications, via get_model_fields()"""
console = Console(force_terminal=False, width=120)
observation = Observation.from_json(j_observation_1)

with console.capture() as output:
console.print(observation)
rendered = output.get()

# Don't check for differences in indendtation
rendered = re.sub(' +', ' ', rendered.strip())
expected = re.sub(' +', ' ', PRINTED_OBSERVATION.strip())
assert rendered == expected

0 comments on commit db5d74b

Please sign in to comment.