diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb6274e58de7..5edccd70ccb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,8 +15,24 @@ # limitations under the License. # repos: - - repo: https://github.com/ambv/black +- repo: https://github.com/ambv/black rev: stable hooks: - - id: black + - id: black language_version: python3.6 + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.3 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-docstring-first + - id: check-json + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.1 + hooks: + - id: flake8 diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index e90d5d3fcd56..f7b7248d733c 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -22,11 +22,11 @@ from sqlalchemy.orm import foreign, relationship from superset.models.core import Slice -from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.helpers import AuditMixinNullable, ExportImportMixin from superset.utils import core as utils -class BaseDatasource(AuditMixinNullable, ImportMixin): +class BaseDatasource(AuditMixinNullable, ExportImportMixin): """A common interface to objects that are queryable (tables and datasources)""" @@ -330,7 +330,7 @@ def update_from_object(self, obj): ) -class BaseColumn(AuditMixinNullable, ImportMixin): +class BaseColumn(AuditMixinNullable, ExportImportMixin): """Interface for column""" __tablename__ = None # {connector_name}_column @@ -347,6 +347,7 @@ class BaseColumn(AuditMixinNullable, ImportMixin): # [optional] Set this to support import/export functionality export_fields = [] + export_ordering = "column_name" def __repr__(self): return self.column_name @@ -399,7 +400,7 @@ def data(self): return {s: getattr(self, s) for s in attrs if hasattr(self, s)} -class BaseMetric(AuditMixinNullable, ImportMixin): +class BaseMetric(AuditMixinNullable, ExportImportMixin): """Interface for Metrics""" @@ -414,6 +415,8 @@ class BaseMetric(AuditMixinNullable, ImportMixin): d3format = Column(String(128)) warning_text = Column(Text) + export_ordering = "metric_name" + """ The interface should also declare a datasource relationship pointing to a derivative of BaseDatasource, along with a FK diff --git a/superset/connectors/connector_registry.py b/superset/connectors/connector_registry.py index be31a37ee070..a02d5441dba2 100644 --- a/superset/connectors/connector_registry.py +++ b/superset/connectors/connector_registry.py @@ -40,6 +40,11 @@ def get_datasource(cls, datasource_type, datasource_id, session): .first() ) + @classmethod + def get_datasource_by_uuid(cls, session, source_type, uuid): + source_class = ConnectorRegistry.sources[source_type] + return session.query(source_class).filter_by(uuid=uuid).one() + @classmethod def get_all_datasources(cls, session): datasources = [] diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 3f81ca7e57c0..42411707a425 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -63,7 +63,7 @@ from superset import conf, db, security_manager from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.exceptions import MetricPermException, SupersetException -from superset.models.helpers import AuditMixinNullable, ImportMixin, QueryResult +from superset.models.helpers import AuditMixinNullable, ExportImportMixin, QueryResult from superset.utils import core as utils, import_datasource from superset.utils.core import DimSelector, DTTM_ALIAS, flasher @@ -97,7 +97,7 @@ def __init__(self, name, post_aggregator): self.post_aggregator = post_aggregator -class DruidCluster(Model, AuditMixinNullable, ImportMixin): +class DruidCluster(Model, AuditMixinNullable, ExportImportMixin): """ORM object referencing the Druid clusters""" diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 6805777995ca..0a55dc317e31 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -331,6 +331,7 @@ class SqlaTable(Model, BaseDatasource): ] export_parent = "database" export_children = ["metrics", "columns"] + export_ordering = "table_name" sqla_aggregations = { "COUNT_DISTINCT": lambda column_name: sa.func.COUNT(sa.distinct(column_name)), diff --git a/superset/migrations/versions/38f7d2edcb84_uuids.py b/superset/migrations/versions/38f7d2edcb84_uuids.py new file mode 100644 index 000000000000..b2c28767f0a9 --- /dev/null +++ b/superset/migrations/versions/38f7d2edcb84_uuids.py @@ -0,0 +1,142 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Adds uuid columns to all classes with ImportExportMixin: dashboards, datasources, dbs, slices, tables +Revision ID: e5200a951e62 +Revises: e9df189e5c7e +Create Date: 2019-05-08 13:42:48.479145 +""" +import uuid + +from alembic import op +from sqlalchemy import CHAR, Column, Integer +from sqlalchemy.ext.declarative import declarative_base + +from superset import db +from superset.utils.sqla import get_uuid, uuid_sqla_column + +# revision identifiers, used by Alembic. +revision = "38f7d2edcb84" +down_revision = "b4a38aa87893" + +Base = declarative_base() + + +class ImportExportMixin: + id = Column(Integer, primary_key=True) + uuid = uuid_sqla_column + + +class Dashboard(Base, ImportExportMixin): + __tablename__ = "dashboards" + + +class Datasource(Base, ImportExportMixin): + __tablename__ = "datasources" + + +class Database(Base, ImportExportMixin): + __tablename__ = "dbs" + + +class DruidCluster(Base, ImportExportMixin): + __tablename__ = "clusters" + + +class DruidMetric(Base, ImportExportMixin): + __tablename__ = "metrics" + + +class DruidColumn(Base, ImportExportMixin): + __tablename__ = "columns" + + +class Slice(Base, ImportExportMixin): + __tablename__ = "slices" + + +class SqlaTable(Base, ImportExportMixin): + __tablename__ = "tables" + + +class SqlMetric(Base, ImportExportMixin): + __tablename__ = "sql_metrics" + + +class TableColumn(Base, ImportExportMixin): + __tablename__ = "table_columns" + + +def batch_commit(iterable, mutator, session, batch_size=100): + count = len(iterable) + for i, obj in enumerate(iterable): + mutator(obj) + session.merge(obj) + if i % 100 == 0: + session.commit() + print(f"uuid assigned to {i} out of {count}") + session.commit() + print(f"Done! Assigned {count} uuids") + + +def default_mutator(obj): + obj.uuid = get_uuid() + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + db_type = session.bind.dialect.name + + def add_uuid_column(tbl_name, _type): + """Add a uuid column to a given table""" + print(f"Add uuid column to table '{tbl_name}'") + with op.batch_alter_table(tbl_name) as batch_op: + batch_op.add_column(Column("uuid", CHAR(36), default=get_uuid)) + batch_commit(session.query(_type).all(), default_mutator, session) + + add_uuid_column("dashboards", Dashboard) + add_uuid_column("datasources", Datasource) + add_uuid_column("dbs", Database) + add_uuid_column("clusters", DruidCluster) + add_uuid_column("metrics", DruidMetric) + add_uuid_column("columns", DruidColumn) + add_uuid_column("slices", Slice) + add_uuid_column("sql_metrics", SqlMetric) + add_uuid_column("tables", SqlaTable) + add_uuid_column("table_columns", TableColumn) + + session.close() + + +def downgrade(): + for tbl in [ + "dashboards", + "datasources", + "dbs", + "clusters", + "metrics", + "columns", + "slices", + "sql_metrics", + "tables", + "table_columns", + ]: + try: + with op.batch_alter_table(tbl) as batch_op: + batch_op.drop_column("uuid") + except Exception: + pass diff --git a/superset/models/core.py b/superset/models/core.py index 258cf9b12b4b..a8463351e603 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -56,7 +56,7 @@ from superset import app, db, db_engine_specs, security_manager from superset.connectors.connector_registry import ConnectorRegistry from superset.legacy import update_time_range -from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.helpers import AuditMixinNullable, ExportImportMixin from superset.models.tags import ChartUpdater, DashboardUpdater, FavStarUpdater from superset.models.user_attributes import UserAttribute from superset.utils import cache as cache_util, core as utils @@ -151,7 +151,7 @@ class CssTemplate(Model, AuditMixinNullable): ) -class Slice(Model, AuditMixinNullable, ImportMixin): +class Slice(Model, AuditMixinNullable, ExportImportMixin): """A slice is essentially a report or a view on data""" @@ -176,6 +176,8 @@ class Slice(Model, AuditMixinNullable, ImportMixin): "params", "cache_timeout", ) + export_fields_json = ["params"] + export_ordering = "slice_name" def __repr__(self): return self.slice_name or str(self.id) @@ -407,7 +409,7 @@ def url(self): ) -class Dashboard(Model, AuditMixinNullable, ImportMixin): +class Dashboard(Model, AuditMixinNullable, ExportImportMixin): """The dashboard object!""" @@ -430,6 +432,8 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin): "css", "slug", ) + export_fields_json = ("position_json", "json_metadata") + export_ordering = "dashboard_title" def __repr__(self): return self.dashboard_title or str(self.id) @@ -695,7 +699,7 @@ def export_dashboards(cls, dashboard_ids): ) -class Database(Model, AuditMixinNullable, ImportMixin): +class Database(Model, AuditMixinNullable, ExportImportMixin): """An ORM object that stores Database related information""" @@ -744,6 +748,7 @@ class Database(Model, AuditMixinNullable, ImportMixin): "extra", ) export_children = ["tables"] + export_ordering = "database_name" def __repr__(self): return self.verbose_name if self.verbose_name else self.database_name diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 327cc21efb65..64bbe09cda7d 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -26,12 +26,12 @@ from flask_appbuilder.models.mixins import AuditMixin import humanize import sqlalchemy as sa -from sqlalchemy import and_, or_, UniqueConstraint +from sqlalchemy import UniqueConstraint from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm.exc import MultipleResultsFound import yaml from superset.utils.core import QueryStatus +from superset.utils.sqla import uuid_sqla_column def json_to_dict(json_str): @@ -43,18 +43,26 @@ def json_to_dict(json_str): return {} -class ImportMixin(object): +class ExportImportMixin(object): + uuid = uuid_sqla_column export_parent = None + # The name of the attribute # with the SQL Alchemy back reference - export_children = [] - # List of (str) names of attributes - # with the SQL Alchemy forward references - export_fields = [] # The names of the attributes - # that are available for import and export + # that are made available for import and export + export_fields = [] + + # Fields that are stored as json string in db, this abstraction + # will serialize/deserialize them so that it's a consistent data structure + export_fields_json = [] + + # If a collection is exported, this str represents by which argument + # to sort the collection achieve a deterministic order. + # Deterministic ordering is useful for diffing and unit tests + export_ordering = None @classmethod def _parent_foreign_key_mappings(cls): @@ -64,6 +72,10 @@ def _parent_foreign_key_mappings(cls): return {l.name: r.name for (l, r) in parent_rel.local_remote_pairs} return {} + @property + def export_fields_with_uuid(self): + return list(self.export_fields) + ["uuid"] + @classmethod def _unique_constrains(cls): """Get all (single column and multi column) unique constraints""" @@ -75,6 +87,22 @@ def _unique_constrains(cls): unique.extend({c.name} for c in cls.__table__.columns if c.unique) return unique + def as_json(self): + return json.dumps(self.export_to_dict(), indent=2, sort_keys=True) + + def as_yaml(self): + return yaml.safe_dump(self.export_to_dict()) + + @property + def as_json_wrapped(self): + s = escape(self.as_json()) + return Markup(f"
{s}
") + + @property + def as_yaml_wrapped(self): + s = escape(self.as_yaml()) + return Markup(f"
{s}
") + @classmethod def export_schema(cls, recursive=True, include_parent_ref=False): """Export schema as a dictionary""" @@ -107,73 +135,41 @@ def formatter(c): return schema @classmethod - def import_from_dict(cls, session, dict_rep, parent=None, recursive=True, sync=[]): + def import_from_dict( + cls, session, dict_rep, parent=None, recursive=True, sync=None + ): """Import obj from a dictionary""" + sync = sync or [] parent_refs = cls._parent_foreign_key_mappings() export_fields = set(cls.export_fields) | set(parent_refs.keys()) - new_children = { - c: dict_rep.get(c) for c in cls.export_children if c in dict_rep - } - unique_constrains = cls._unique_constrains() - filters = [] # Using these filters to check if obj already exists - - # Remove fields that should not get imported for k in list(dict_rep): - if k not in export_fields: + # Remove fields that should not get imported + if k not in export_fields and k != "uuid": del dict_rep[k] + # Serialize json fields that are stored as text in the db + if k in cls.export_fields_json: + dict_rep[k] = json.dumps(dict_rep[k]) - if not parent: - if cls.export_parent: - for p in parent_refs.keys(): - if p not in dict_rep: - raise RuntimeError( - "{0}: Missing field {1}".format(cls.__name__, p) - ) - else: + if parent: # Set foreign keys to parent obj for k, v in parent_refs.items(): dict_rep[k] = getattr(parent, v) - - # Add filter for parent obj - filters.extend([getattr(cls, k) == dict_rep.get(k) for k in parent_refs.keys()]) - - # Add filter for unique constraints - ucs = [ - and_( - *[ - getattr(cls, k) == dict_rep.get(k) - for k in cs - if dict_rep.get(k) is not None - ] - ) - for cs in unique_constrains - ] - filters.append(or_(*ucs)) + elif cls.export_parent: + for p in parent_refs.keys(): + if p not in dict_rep: + raise RuntimeError(f"{cls.__name__}: Missing field {p}") # Check if object already exists in DB, break if more than one is found - try: - obj_query = session.query(cls).filter(and_(*filters)) - obj = obj_query.one_or_none() - except MultipleResultsFound as e: - logging.error( - "Error importing %s \n %s \n %s", - cls.__name__, - str(obj_query), - yaml.safe_dump(dict_rep), - ) - raise e + obj = session.query(cls).filter_by(uuid=dict_rep.get("uuid")).one_or_none() if not obj: - is_new_obj = True - # Create new DB object + logging.info("Creating new %s %s", cls.__tablename__, str(obj)) obj = cls(**dict_rep) - logging.info("Importing new %s %s", obj.__tablename__, str(obj)) if cls.export_parent and parent: setattr(obj, cls.export_parent, parent) session.add(obj) else: - is_new_obj = False logging.info("Updating %s %s", obj.__tablename__, str(obj)) # Update columns for k, v in dict_rep.items(): @@ -181,30 +177,15 @@ def import_from_dict(cls, session, dict_rep, parent=None, recursive=True, sync=[ # Recursively create children if recursive: - for c in cls.export_children: - child_class = cls.__mapper__.relationships[c].argument.class_ - added = [] - for c_obj in new_children.get(c, []): - added.append( - child_class.import_from_dict( - session=session, dict_rep=c_obj, parent=obj, sync=sync - ) - ) - # If children should get synced, delete the ones that did not - # get updated. - if c in sync and not is_new_obj: - back_refs = child_class._parent_foreign_key_mappings() - delete_filters = [ - getattr(child_class, k) == getattr(obj, back_refs.get(k)) - for k in back_refs.keys() - ] - to_delete = set( - session.query(child_class).filter(and_(*delete_filters)) - ).difference(set(added)) - for o in to_delete: - logging.info("Deleting %s %s", c, str(obj)) - session.delete(o) - + import_args = dict(session=session, parent=obj, sync=sync) + for rel in cls.export_children: + child_class = cls.__mapper__.relationships[rel].argument.class_ + children = dict_rep.get(rel, []) + children_orm = [ + child_class.import_from_dict(child, **import_args) + for child in children + ] + setattr(obj, rel, children_orm) return obj def export_to_dict( @@ -217,35 +198,42 @@ def export_to_dict( parent_ref = cls.__mapper__.relationships.get(cls.export_parent) if parent_ref: parent_excludes = {c.name for c in parent_ref.local_columns} - dict_rep = { - c.name: getattr(self, c.name) - for c in cls.__table__.columns + + dict_rep = dict() + + for c in cls.__table__.columns: + key = c.name + value = getattr(self, key) if ( - c.name in self.export_fields - and c.name not in parent_excludes + key in self.export_fields_with_uuid + and key not in parent_excludes and ( include_defaults - or ( - getattr(self, c.name) is not None - and (not c.default or getattr(self, c.name) != c.default.arg) - ) + or (value is not None and (not c.default or value != c.default.arg)) ) - ) - } + ): + if key in self.export_fields_json: + value = json.loads(value) + dict_rep[key] = value if recursive: - for c in self.export_children: + for relationship_name in self.export_children: # sorting to make lists of children stable - dict_rep[c] = sorted( - [ + orm_children = getattr(self, relationship_name) + sort_by = None + children = [] + if orm_children: + children = [ child.export_to_dict( recursive=recursive, include_parent_ref=include_parent_ref, include_defaults=include_defaults, ) - for child in getattr(self, c) - ], - key=lambda k: sorted(k.items()), - ) + for child in orm_children + ] + sort_by = orm_children[0].export_ordering + if sort_by: + children = sorted(children, key=lambda x: x.get(sort_by)) + dict_rep[relationship_name] = children return dict_rep diff --git a/superset/models/schedules.py b/superset/models/schedules.py index dbcd56d83a27..93029555637c 100644 --- a/superset/models/schedules.py +++ b/superset/models/schedules.py @@ -25,7 +25,7 @@ from sqlalchemy.orm import relationship from superset import security_manager -from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.helpers import AuditMixinNullable metadata = Model.metadata # pylint: disable=no-member @@ -73,7 +73,7 @@ def user(self): delivery_type = Column(Enum(EmailDeliveryType)) -class DashboardEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedule): +class DashboardEmailSchedule(Model, AuditMixinNullable, EmailSchedule): __tablename__ = "dashboard_email_schedules" dashboard_id = Column(Integer, ForeignKey("dashboards.id")) dashboard = relationship( @@ -81,7 +81,7 @@ class DashboardEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedu ) -class SliceEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedule): +class SliceEmailSchedule(Model, AuditMixinNullable, EmailSchedule): __tablename__ = "slice_email_schedules" slice_id = Column(Integer, ForeignKey("slices.id")) slice = relationship("Slice", backref="email_schedules", foreign_keys=[slice_id]) diff --git a/superset/utils/sqla.py b/superset/utils/sqla.py new file mode 100644 index 000000000000..53d73b817889 --- /dev/null +++ b/superset/utils/sqla.py @@ -0,0 +1,26 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import uuid + +import sqlalchemy as sa + + +def get_uuid(): + return str(uuid.uuid4()) + + +uuid_sqla_column = sa.Column(sa.CHAR(36), unique=True, default=get_uuid) diff --git a/superset/views/core.py b/superset/views/core.py index 5cedfe1c185f..969199575b9c 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -303,6 +303,7 @@ class DatabaseView(SupersetModelView, DeleteMixin, YamlExportMixin): # noqa "created_on", "changed_by", "changed_on", + "as_yaml_wrapped", ] add_template = "superset/models/database/add.html" edit_template = "superset/models/database/edit.html" @@ -634,6 +635,15 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa "params", "cache_timeout", ] + show_columns = [ + "slice_name", + "description", + "viz_type", + "owners", + "dashboards", + "cache_timeout", + "as_yaml_wrapped", + ] base_order = ("changed_on", "desc") description_columns = { "description": Markup( @@ -655,6 +665,7 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa } base_filters = [["id", SliceFilter, lambda: []]] label_columns = { + "as_yaml_wrapped": _("YAML"), "cache_timeout": _("Cache Timeout"), "creator": _("Creator"), "dashboards": _("Dashboards"), @@ -666,6 +677,7 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa "slice_link": _("Chart"), "slice_name": _("Name"), "table": _("Table"), + "uuid": "UUID", "viz_type": _("Visualization Type"), } @@ -770,7 +782,16 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa "css", "json_metadata", ] - show_columns = edit_columns + ["table_names", "charts"] + show_columns = [ + "dashboard_title", + "slug", + "uuid", + "owners", + "css", + "table_names", + "charts", + "as_yaml_wrapped", + ] search_columns = ("dashboard_title", "slug", "owners") add_columns = edit_columns base_order = ("changed_on", "desc") @@ -797,6 +818,7 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa } base_filters = [["slice", DashboardFilter, lambda: []]] label_columns = { + "as_yaml_wrapped": _("YAML"), "dashboard_link": _("Dashboard"), "dashboard_title": _("Title"), "slug": _("Slug"), @@ -804,10 +826,10 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa "owners": _("Owners"), "creator": _("Creator"), "modified": _("Modified"), - "position_json": _("Position JSON"), "css": _("CSS"), "json_metadata": _("JSON Metadata"), "table_names": _("Underlying Tables"), + "uuid": "UUID", } def pre_add(self, obj): diff --git a/tests/dict_import_export_tests.py b/tests/dict_import_export_tests.py index ba6766bbb9cf..fdaf4fd1a8dc 100644 --- a/tests/dict_import_export_tests.py +++ b/tests/dict_import_export_tests.py @@ -112,7 +112,20 @@ def get_datasource(self, datasource_id): def get_table_by_name(self, name): return db.session.query(SqlaTable).filter_by(table_name=name).first() + @classmethod + def drop_uuid(cls, obj): + if type(obj) == dict: + if "uuid" in obj: + obj.pop("uuid") + for k in list(obj): + cls.drop_uuid(obj[k]) + if type(obj) == list: + for o in obj: + cls.drop_uuid(o) + def yaml_compare(self, obj_1, obj_2): + self.drop_uuid(obj_1) + self.drop_uuid(obj_2) obj_1_str = yaml.safe_dump(obj_1, default_flow_style=False) obj_2_str = yaml.safe_dump(obj_2, default_flow_style=False) self.assertEquals(obj_1_str, obj_2_str)