Skip to content

Commit

Permalink
fix: Fix JSON encoder and decoder
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Feb 15, 2022
1 parent 7222f9f commit 3e768d6
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 30 deletions.
4 changes: 2 additions & 2 deletions docs/gen_griffe_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
import mkdocs_gen_files

from griffe.docstrings.parsers import Parser
from griffe.encoders import Encoder
from griffe.encoders import JSONEncoder
from griffe.loader import GriffeLoader

griffe = GriffeLoader().load_module("griffe")
serialized = json.dumps(
griffe,
cls=Encoder,
cls=JSONEncoder,
indent=0,
full=True,
docstring_parser=Parser.google,
Expand Down
6 changes: 3 additions & 3 deletions src/griffe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from griffe.agents.extensions import Extensions
from griffe.agents.extensions.base import load_extensions
from griffe.docstrings.parsers import Parser
from griffe.encoders import Encoder
from griffe.encoders import JSONEncoder
from griffe.exceptions import ExtensionError
from griffe.loader import GriffeLoader
from griffe.logger import get_logger
Expand Down Expand Up @@ -302,10 +302,10 @@ def main(args: list[str] | None = None) -> int: # noqa: WPS231
started = datetime.now()
if per_package_output:
for package_name, data in packages.items():
serialized = json.dumps(data, cls=Encoder, indent=2, full=opts.full)
serialized = json.dumps(data, cls=JSONEncoder, indent=2, full=opts.full)
_print_data(serialized, output.format(package=package_name))
else:
serialized = json.dumps(packages, cls=Encoder, indent=2, full=opts.full)
serialized = json.dumps(packages, cls=JSONEncoder, indent=2, full=opts.full)
_print_data(serialized, output)
elapsed = datetime.now() - started

Expand Down
162 changes: 137 additions & 25 deletions src/griffe/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
The available formats are:
- `JSON`: see the [encoder][griffe.encoders.Encoder] and [decoder][griffe.encoders.decoder].
- `JSON`: see the [`JSONEncoder`][griffe.encoders.JSONEncoder] and [`json_decoder`][griffe.encoders.json_decoder].
"""

from __future__ import annotations
Expand All @@ -11,16 +11,30 @@
from pathlib import Path, PosixPath
from typing import Any, Callable, Type

from griffe.dataclasses import Attribute, Class, Function, Kind, Module, Object, ParameterKind
from griffe.dataclasses import (
Alias,
Attribute,
Class,
Decorator,
Docstring,
Function,
Kind,
Module,
Object,
Parameter,
ParameterKind,
Parameters,
)
from griffe.docstrings.dataclasses import DocstringSectionKind
from griffe.docstrings.parsers import Parser
from griffe.expressions import Expression, Name


def _enum_value(obj):
return obj.value


_type_map: dict[Type, Callable[[Any], Any]] = {
_json_encoder_map: dict[Type, Callable[[Any], Any]] = {
Path: str,
PosixPath: str,
ParameterKind: _enum_value,
Expand All @@ -30,16 +44,19 @@ def _enum_value(obj):
}


class Encoder(json.JSONEncoder):
class JSONEncoder(json.JSONEncoder):
"""JSON encoder.
JSON encoders are not used directly, but through
JSON encoders can be used directly, or through
the [`json.dump`][] or [`json.dumps`][] methods.
Examples:
>>> from griffe.encoders import JSONEncoder
>>> JSONEncoder(full=True).encode(..., **kwargs)
>>> import json
>>> from griffe.encoders import Encoder
>>> json.dumps(..., cls=Encoder, full=True, **kwargs)
>>> from griffe.encoders import JSONEncoder
>>> json.dumps(..., cls=JSONEncoder, full=True, **kwargs)
"""

def __init__(
Expand All @@ -56,7 +73,7 @@ def __init__(
*args: See [`json.JSONEncoder`][].
full: Whether to dump full data or base data.
If you plan to reload the data in Python memory
using the [decoder][griffe.encoders.decoder],
using the [`json_decoder`][griffe.encoders.json_decoder],
you don't need the full data as it can be infered again
using the base data. If you want to feed a non-Python
tool instead, dump the full data.
Expand All @@ -81,34 +98,129 @@ def default(self, obj: Any) -> Any: # noqa: WPS212
try:
return obj.as_dict(full=self.full, docstring_parser=self.docstring_parser, **self.docstring_options)
except AttributeError:
return _type_map.get(type(obj), super().default)(obj)
return _json_encoder_map.get(type(obj), super().default)(obj)


def _load_docstring(obj_dict):
if "docstring" in obj_dict:
return Docstring(**obj_dict["docstring"])
return None


def _load_decorators(obj_dict):
return [Decorator(**dec) for dec in obj_dict.get("decorators", [])]


_annotation_loader_map = {
str: lambda _: _,
dict: lambda dct: Name(dct["source"], dct["full"]),
list: lambda lst: Expression(*[_load_annotation(_) for _ in lst]),
}


def decoder(obj_dict: dict[str, Any]) -> dict[str, Any] | Object: # noqa: WPS231
def _load_annotation(annotation: str | dict | list) -> str | Name | Expression:
if annotation is None:
return None
return _annotation_loader_map[type(annotation)](annotation)


def _load_parameter(obj_dict: dict[str, Any]) -> Parameter:
return Parameter(
obj_dict["name"],
annotation=_load_annotation(obj_dict["annotation"]),
kind=ParameterKind(obj_dict["kind"]),
default=obj_dict["default"],
)


def _load_module(obj_dict: dict[str, Any]) -> Module:
module = Module(name=obj_dict["name"], filepath=Path(obj_dict["filepath"]), docstring=_load_docstring(obj_dict))
for module_member in obj_dict.get("members", []):
module[module_member.name] = module_member
module.labels |= set(obj_dict.get("labels", ()))
return module


def _load_class(obj_dict: dict[str, Any]) -> Class:
class_ = Class(
name=obj_dict["name"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno", None),
docstring=_load_docstring(obj_dict),
decorators=_load_decorators(obj_dict),
bases=[_load_annotation(_) for _ in obj_dict["bases"]],
)
for class_member in obj_dict.get("members", []):
class_[class_member.name] = class_member
class_.labels |= set(obj_dict.get("labels", ()))
return class_


def _load_function(obj_dict: dict[str, Any]) -> Function:
function = Function(
name=obj_dict["name"],
parameters=Parameters(*obj_dict["parameters"]),
returns=_load_annotation(obj_dict["returns"]),
decorators=_load_decorators(obj_dict),
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno", None),
docstring=_load_docstring(obj_dict),
)
function.labels |= set(obj_dict.get("labels", ()))
return function


def _load_attribute(obj_dict: dict[str, Any]) -> Attribute:
attribute = Attribute(
name=obj_dict["name"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno", None),
docstring=_load_docstring(obj_dict),
value=obj_dict["value"],
annotation=_load_annotation(obj_dict.get("annotation", None)),
)
attribute.labels |= set(obj_dict.get("labels", ()))
return attribute


def _load_alias(obj_dict: dict[str, Any]) -> Alias:
return Alias(
name=obj_dict["name"],
target=obj_dict["target_path"],
lineno=obj_dict["lineno"],
endlineno=obj_dict.get("endlineno", None),
)


_loader_map: dict[Kind, Callable[[dict[str, Any]], Module | Class | Function | Attribute | Alias]] = { # noqa: WPS234
Kind.MODULE: _load_module,
Kind.CLASS: _load_class,
Kind.FUNCTION: _load_function,
Kind.ATTRIBUTE: _load_attribute,
Kind.ALIAS: _load_alias,
}


def json_decoder(obj_dict: dict[str, Any]) -> dict[str, Any] | Object | Alias | Parameter: # noqa: WPS231
"""Decode dictionaries as data classes.
The [`json.loads`][] method walks the tree from bottom to top.
Examples:
>>> import json
>>> from griffe.encoders import json_decoder
>>> json.loads(..., object_hook=json_decoder)
Parameters:
obj_dict: The dictionary to decode.
Returns:
An instance of a data class.
"""
if "kind" in obj_dict:
kind = Kind(obj_dict["kind"])
if kind == Kind.MODULE:
module = Module(name=obj_dict["name"], filepath=Path(obj_dict["filepath"]))
for module_member in obj_dict.get("members", []):
module[module_member.name] = module_member
return module
elif kind == Kind.CLASS:
class_ = Class(name=obj_dict["name"], lineno=obj_dict["lineno"], endlineno=obj_dict["endlineno"])
for class_member in obj_dict.get("members", []):
class_[class_member.name] = class_member
return class_
elif kind == Kind.FUNCTION:
return Function(name=obj_dict["name"], lineno=obj_dict["lineno"], endlineno=obj_dict["endlineno"])
elif kind == Kind.ATTRIBUTE:
return Attribute(name=obj_dict["name"], lineno=obj_dict["lineno"], endlineno=obj_dict["endlineno"])
try:
kind = Kind(obj_dict["kind"])
except ValueError:
return _load_parameter(obj_dict)
return _loader_map[kind](obj_dict)
return obj_dict
22 changes: 22 additions & 0 deletions tests/test_encoders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Tests for the `encoders` module."""

import json

from griffe.encoders import JSONEncoder, json_decoder
from griffe.loader import GriffeLoader


def test_minimal_data_is_enough():
"""Test serialization and de-serialization.
This is an end-to-end test that asserts
we can load back a serialized tree and
infer as much data as within the original tree.
"""
loader = GriffeLoader()
module = loader.load_module("griffe")
minimal = json.dumps(module, cls=JSONEncoder, full=False, indent=2)
full = json.dumps(module, cls=JSONEncoder, full=True, indent=2)
reloaded = json.loads(minimal, object_hook=json_decoder)
assert json.dumps(reloaded, cls=JSONEncoder, full=False, indent=2) == minimal
assert json.dumps(reloaded, cls=JSONEncoder, full=True, indent=2) == full

0 comments on commit 3e768d6

Please sign in to comment.