Repository: datajoint/datajoint-python (language: python)

Issue:
-> Plugin: Sync with release 0.12.8

`schema.save()` does not handle part tables correctly
![image](https://user-images.githubusercontent.com/2539832/97061982-c19eb180-154d-11eb-8ac2-39f8aa2a5fcc.png)
It should be `Segmentation.Roi`



Discussion / hints:

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..d386d4d4d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,37 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: 'bug, awaiting-triage'
+assignees: ''
+
+---
+
+## Bug Report
+
+### Description
+A clear and concise description of what is the overall operation that is intended to be performed that resulted in an error.
+
+### Reproducibility
+Include:
+- OS (WIN | MACOS | Linux)
+- Python Version OR MATLAB Version
+- MySQL Version
+- MySQL Deployment Strategy (local-native | local-docker | remote)
+- DataJoint Version
+- Minimum number of steps to reliably reproduce the issue
+- Complete error stack as a result of evaluating the above steps
+
+### Expected Behavior
+A clear and concise description of what you expected to happen.
+
+### Screenshots
+If applicable, add screenshots to help explain your problem.
+
+### Additional Research and Context
+Add any additional research or context that was conducted in creating this report.
+
+For example:
+- Related GitHub issues and PR's either within this repository or in other relevant repositories.
+- Specific links to specific lines or a focus within source code.
+- Relevant summary of Maintainers development meetings, milestones, projects, etc.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..4d4eeffd9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,46 @@
+---
+name: Feature request
+about: Suggest an idea for a new feature
+title: ''
+labels: 'enhancement, awaiting-triage'
+assignees: ''
+
+---
+
+## Feature Request
+
+### Problem
+A clear and concise description how this idea has manifested and the context. Elaborate on the need for this feature and/or what could be improved. Ex. I'm always frustrated when [...]
+
+### Requirements
+A clear and concise description of the requirements to satisfy the new feature. Detail what you expect from a successful implementation of the feature. Ex. When using this feature, it should [...]
+
+### Justification
+Provide the key benefits in making this a supported feature. Ex. Adding support for this feature would ensure [...]
+
+### Alternative Considerations
+Do you currently have a work-around for this? Provide any alternative solutions or features you've considered.
+
+### Related Errors
+Add any errors as a direct result of not exposing this feature.
+
+Please include steps to reproduce provided errors as follows:
+- OS (WIN | MACOS | Linux)
+- Python Version OR MATLAB Version
+- MySQL Version
+- MySQL Deployment Strategy (local-native | local-docker | remote)
+- DataJoint Version
+- Minimum number of steps to reliably reproduce the issue
+- Complete error stack as a result of evaluating the above steps
+
+### Screenshots
+If applicable, add screenshots to help explain your feature.
+
+### Additional Research and Context
+Add any additional research or context that was conducted in creating this feature request.
+
+For example:
+- Related GitHub issues and PR's either within this repository or in other relevant repositories.
+- Specific links to specific line or focus within source code.
+- Relevant summary of Maintainers development meetings, milestones, projects, etc.
+- Any additional supplemental web references or links that would further justify this feature request.
diff --git a/.github/workflows/development.yaml b/.github/workflows/development.yaml
new file mode 100644
index 000000000..55ced7eb9
--- /dev/null
+++ b/.github/workflows/development.yaml
@@ -0,0 +1,53 @@
+name: Development
+on:
+  push:
+    branches:
+      - '**' # every branch
+      - '!stage*' # exclude branches beginning with stage
+  pull_request:
+    branches:
+      - '**' # every branch
+      - '!stage*' # exclude branches beginning with stage
+jobs:
+  test:
+    if: github.event_name == 'push' || github.event_name == 'pull_request'
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        py_ver: ["3.8"]
+        mysql_ver: ["8.0", "5.7", "5.6"]
+        include:
+          - py_ver: "3.7"
+            mysql_ver: "5.7"
+          - py_ver: "3.6"
+            mysql_ver: "5.7"
+          - py_ver: "3.5"
+            mysql_ver: "5.7"
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{matrix.py_ver}}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{matrix.py_ver}}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install flake8
+      - name: Run syntax tests
+        run: flake8 datajoint --count --select=E9,F63,F7,F82 --show-source --statistics
+      - name: Run primary tests
+        env:
+          UID: "1001"
+          GID: "116"
+          PY_VER: ${{matrix.py_ver}}
+          MYSQL_VER: ${{matrix.mysql_ver}}
+          ALPINE_VER: "3.10"
+          MINIO_VER: RELEASE.2019-09-26T19-42-35Z
+          COMPOSE_HTTP_TIMEOUT: "120"
+          COVERALLS_SERVICE_NAME: travis-ci
+          COVERALLS_REPO_TOKEN: fd0BoXG46TPReEem0uMy7BJO5j0w1MQiY
+        run: docker-compose -f LNX-docker-compose.yml up --build --exit-code-from app
+      - name: Run style tests
+        run: |
+          flake8 --ignore=E121,E123,E126,E226,E24,E704,W503,W504,E722,F401,W605 datajoint \
+                 --count --max-complexity=62 --max-line-length=127 --statistics
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 72c11490b..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-sudo: required
-env:
-  global:
-    - MINIO_VER="RELEASE.2019-09-26T19-42-35Z"
-    - ALPINE_VER="3.10"
-    - COMPOSE_HTTP_TIMEOUT="300"
-    - UID="2000"
-    - GID="2000"
-    - COVERALLS_SERVICE_NAME="travis-ci"
-    - COVERALLS_REPO_TOKEN="fd0BoXG46TPReEem0uMy7BJO5j0w1MQiY"
-services: 
-- docker
-main: &main
-      stage: "Tests & Coverage: Alpine"
-      os: linux
-      dist: xenial # precise, trusty, xenial, bionic
-      language: shell
-      script:
-        - docker-compose -f LNX-docker-compose.yml up --build --exit-code-from app
-jobs:
-  include:
-    - stage: "Lint: Syntax"
-      language: python
-      install:
-        - pip install flake8
-      script:
-        - flake8 datajoint --count --select=E9,F63,F7,F82 --show-source --statistics
-    - <<: *main
-      env:
-      - PY_VER: "3.8"
-      - MYSQL_VER: "5.7"
-    - <<: *main
-      env:
-      - PY_VER: "3.7"
-      - MYSQL_VER: "5.7"
-    - <<: *main
-      env:
-      - PY_VER: "3.6"
-      - MYSQL_VER: "5.7"
-    - <<: *main
-      env:
-      - PY_VER: "3.5"
-      - MYSQL_VER: "5.7"
-    - <<: *main
-      env:
-      - PY_VER: "3.8"
-      - MYSQL_VER: "8.0"
-    - <<: *main
-      env:
-      - PY_VER: "3.8"
-      - MYSQL_VER: "5.6"
-    - stage: "Lint: Style"
-      language: python
-      install:
-        - pip install flake8
-      script:
-        - |
-          flake8 --ignore=E121,E123,E126,E226,E24,E704,W503,W504,E722,F401,W605 datajoint \
-          --count --max-complexity=62 --max-line-length=127 --statistics
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90d1f4e63..2714a0c4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
 ## Release notes
 
+### 0.13.0 -- TBD
+* Support DataJoint datatype and connection plugins (#715, #729) PR 730, #735
+* Allow updating specified secondary attributes using `update1` PR #763
+
+### 0.12.8 -- Jan 12, 2021
+* table.children, .parents, .descendents, and ancestors can return queryable objects. PR #833
+* Load dependencies before querying dependencies. (#179) PR #833
+* Fix display of part tables in `schema.save`. (#821) PR #833
+* Add `schema.list_tables`. (#838) PR #844
+* Fix minio new version regression.  PR #847
+* Add more S3 logging for debugging. (#831) PR #832 
+* Convert testing framework from TravisCI to GitHub Actions (#841) PR #840
+ 
 ### 0.12.7 -- Oct 27, 2020
 * Fix case sensitivity issues to adapt to MySQL 8+.  PR #819
 * Fix pymysql regression bug (#814) PR #816
diff --git a/LNX-docker-compose.yml b/LNX-docker-compose.yml
index ba774c06a..64d8b7ba3 100644
--- a/LNX-docker-compose.yml
+++ b/LNX-docker-compose.yml
@@ -32,7 +32,7 @@ services:
       interval: 1s
   fakeservices.datajoint.io:
     <<: *net
-    image: raphaelguzman/nginx:v0.0.10
+    image: raphaelguzman/nginx:v0.0.13
     environment:
     - ADD_db_TYPE=DATABASE
     - ADD_db_ENDPOINT=db:3306
diff --git a/datajoint.pub b/datajoint.pub
new file mode 100644
index 000000000..4aaa823d2
--- /dev/null
+++ b/datajoint.pub
@@ -0,0 +1,6 @@
+-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDUMOo2U7YQ1uOrKU/IreM3AQP2
+AXJC3au+S9W+dilxHcJ3e98bRVqrFeOofcGeRPoNc38fiLmLDUiBskJeVrpm29Wo
+AkH6yhZWk1o8NvGMhK4DLsJYlsH6tZuOx9NITKzJuOOH6X1I5Ucs7NOSKnmu7g5g
+WTT5kCgF5QAe5JN8WQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/datajoint/attribute_adapter.py b/datajoint/attribute_adapter.py
index b1bf74fb3..dc1c45706 100644
--- a/datajoint/attribute_adapter.py
+++ b/datajoint/attribute_adapter.py
@@ -1,5 +1,6 @@
 import re
 from .errors import DataJointError, _support_adapted_types
+from .plugin import type_plugins
 
 
 class AttributeAdapter:
@@ -38,10 +39,11 @@ def get_adapter(context, adapter_name):
         raise DataJointError('Support for Adapted Attribute types is disabled.')
     adapter_name = adapter_name.lstrip('<').rstrip('>')
     try:
-        adapter = context[adapter_name]
+        adapter = (context[adapter_name] if adapter_name in context
+                   else type_plugins[adapter_name]['object'].load())
     except KeyError:
         raise DataJointError(
-            "Attribute adapter '{adapter_name}' is not defined.".format(adapter_name=adapter_name)) from None
+            "Attribute adapter '{adapter_name}' is not defined.".format(adapter_name=adapter_name))
     if not isinstance(adapter, AttributeAdapter):
         raise DataJointError(
             "Attribute adapter '{adapter_name}' must be an instance of datajoint.AttributeAdapter".format(
diff --git a/datajoint/autopopulate.py b/datajoint/autopopulate.py
index 22f945bd7..87101818d 100644
--- a/datajoint/autopopulate.py
+++ b/datajoint/autopopulate.py
@@ -32,25 +32,19 @@ def key_source(self):
                 The default value is the join of the parent relations.
                 Users may override to change the granularity or the scope of populate() calls.
         """
-        def parent_gen(self):
-            if self.target.full_table_name not in self.connection.dependencies:
-                self.connection.dependencies.load()
-            for parent_name, fk_props in self.target.parents(primary=True).items():
-                if not parent_name.isdigit():  # simple foreign key
-                    yield FreeTable(self.connection, parent_name).proj()
-                else:
-                    grandparent = list(self.connection.dependencies.in_edges(parent_name))[0][0]
-                    yield FreeTable(self.connection, grandparent).proj(**{
-                        attr: ref for attr, ref in fk_props['attr_map'].items() if ref != attr})
+        def _rename_attributes(table, props):
+            return (table.proj(
+                **{attr: ref for attr, ref in props['attr_map'].items() if attr != ref})
+                if props['aliased'] else table)
 
         if self._key_source is None:
-            parents = parent_gen(self)
-            try:
-                self._key_source = next(parents)
-            except StopIteration:
-                raise DataJointError('A relation must have primary dependencies for auto-populate to work') from None
-            for q in parents:
-                self._key_source *= q
+            parents = self.target.parents(primary=True, as_objects=True, foreign_key_info=True)
+            if not parents:
+                raise DataJointError(
+                    'A relation must have primary dependencies for auto-populate to work')
+            self._key_source = _rename_attributes(*parents[0])
+            for q in parents[1:]:
+                self._key_source *= _rename_attributes(*q)
         return self._key_source
 
     def make(self, key):
diff --git a/datajoint/connection.py b/datajoint/connection.py
index 4fc7c930a..28375d586 100644
--- a/datajoint/connection.py
+++ b/datajoint/connection.py
@@ -11,11 +11,36 @@
 from .settings import config
 from . import errors
 from .dependencies import Dependencies
+from .plugin import connection_plugins
 
 logger = logging.getLogger(__name__)
 query_log_max_length = 300
 
 
+def get_host_hook(host_input):
+    if '://' in host_input:
+        plugin_name = host_input.split('://')[0]
+        try:
+            return connection_plugins[plugin_name]['object'].load().get_host(host_input)
+        except KeyError:
+            raise errors.DataJointError(
+                "Connection plugin '{}' not found.".format(plugin_name))
+    else:
+        return host_input
+
+
+def connect_host_hook(connection_obj):
+    if '://' in connection_obj.conn_info['host_input']:
+        plugin_name = connection_obj.conn_info['host_input'].split('://')[0]
+        try:
+            connection_plugins[plugin_name]['object'].load().connect_host(connection_obj)
+        except KeyError:
+            raise errors.DataJointError(
+                "Connection plugin '{}' not found.".format(plugin_name))
+    else:
+        connection_obj.connect()
+
+
 def translate_query_error(client_error, query):
     """
     Take client error and original query and return the corresponding DataJoint exception.
@@ -76,7 +101,8 @@ def conn(host=None, user=None, password=None, *, init_fun=None, reset=False, use
                         #encrypted-connection-options).
     """
     if not hasattr(conn, 'connection') or reset:
-        host = host if host is not None else config['database.host']
+        host_input = host if host is not None else config['database.host']
+        host = get_host_hook(host_input)
         user = user if user is not None else config['database.user']
         password = password if password is not None else config['database.password']
         if user is None:  # pragma: no cover
@@ -85,7 +111,8 @@ def conn(host=None, user=None, password=None, *, init_fun=None, reset=False, use
             password = getpass(prompt="Please enter DataJoint password: ")
         init_fun = init_fun if init_fun is not None else config['connection.init_function']
         use_tls = use_tls if use_tls is not None else config['database.use_tls']
-        conn.connection = Connection(host, user, password, None, init_fun, use_tls)
+        conn.connection = Connection(host, user, password, None, init_fun, use_tls,
+                                     host_input=host_input)
     return conn.connection
 
 
@@ -104,7 +131,8 @@ class Connection:
     :param use_tls: TLS encryption option
     """
 
-    def __init__(self, host, user, password, port=None, init_fun=None, use_tls=None):
+    def __init__(self, host, user, password, port=None, init_fun=None, use_tls=None,
+                 host_input=None):
         if ':' in host:
             # the port in the hostname overrides the port argument
             host, port = host.split(':')
@@ -115,10 +143,11 @@ def __init__(self, host, user, password, port=None, init_fun=None, use_tls=None)
         if use_tls is not False:
             self.conn_info['ssl'] = use_tls if isinstance(use_tls, dict) else {'ssl': {}}
         self.conn_info['ssl_input'] = use_tls
+        self.conn_info['host_input'] = host_input
         self.init_fun = init_fun
         print("Connecting {user}@{host}:{port}".format(**self.conn_info))
         self._conn = None
-        self.connect()
+        connect_host_hook(self)
         if self.is_connected:
             logger.info("Connected {user}@{host}:{port}".format(**self.conn_info))
             self.connection_id = self.query('SELECT connection_id()').fetchone()[0]
@@ -149,7 +178,7 @@ def connect(self):
                              "STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION",
                     charset=config['connection.charset'],
                     **{k: v for k, v in self.conn_info.items()
-                       if k != 'ssl_input'})
+                       if k not in ['ssl_input', 'host_input']})
             except client.err.InternalError:
                 self._conn = client.connect(
                     init_command=self.init_fun,
@@ -157,7 +186,7 @@ def connect(self):
                              "STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION",
                     charset=config['connection.charset'],
                     **{k: v for k, v in self.conn_info.items()
-                       if not(k == 'ssl_input' or
+                       if not(k in ['ssl_input', 'host_input'] or
                               k == 'ssl' and self.conn_info['ssl_input'] is None)})
         self._conn.autocommit(True)
 
@@ -166,6 +195,7 @@ def close(self):
 
     def register(self, schema):
         self.schemas[schema.database] = schema
+        self.dependencies.clear()
 
     def ping(self):
         """
@@ -193,7 +223,7 @@ def _execute_query(cursor, query, args, cursor_class, suppress_warnings):
                     warnings.simplefilter("ignore")
                 cursor.execute(query, args)
         except client.err.Error as err:
-            raise translate_query_error(err, query) from None
+            raise translate_query_error(err, query)
 
     def query(self, query, args=(), *, as_dict=False, suppress_warnings=True, reconnect=None):
         """
@@ -216,10 +246,10 @@ def query(self, query, args=(), *, as_dict=False, suppress_warnings=True, reconn
             if not reconnect:
                 raise
             warnings.warn("MySQL server has gone away. Reconnecting to the server.")
-            self.connect()
+            connect_host_hook(self)
             if self._in_transaction:
                 self.cancel_transaction()
-                raise errors.LostConnectionError("Connection was lost during a transaction.") from None
+                raise errors.LostConnectionError("Connection was lost during a transaction.")
             logger.debug("Re-executing")
             cursor = self._conn.cursor(cursor=cursor_class)
             self._execute_query(cursor, query, args, cursor_class, suppress_warnings)
diff --git a/datajoint/declare.py b/datajoint/declare.py
index df643dc8a..1423dba15 100644
--- a/datajoint/declare.py
+++ b/datajoint/declare.py
@@ -45,7 +45,7 @@ def match_type(attribute_type):
     try:
         return next(category for category, pattern in TYPE_PATTERN.items() if pattern.match(attribute_type))
     except StopIteration:
-        raise DataJointError("Unsupported attribute type {type}".format(type=attribute_type)) from None
+        raise DataJointError("Unsupported attribute type {type}".format(type=attribute_type))
 
 
 logger = logging.getLogger(__name__)
@@ -135,7 +135,7 @@ def compile_foreign_key(line, context, attributes, primary_key, attr_sql, foreig
         try:
             result = foreign_key_parser_old.parseString(line)
         except pp.ParseBaseException as err:
-            raise DataJointError('Parsing error in line "%s". %s.' % (line, err)) from None
+            raise DataJointError('Parsing error in line "%s". %s.' % (line, err))
         else:
             obsolete = True
     try:
@@ -430,7 +430,7 @@ def compile_attribute(line, in_key, foreign_key_sql, context):
         match = attribute_parser.parseString(line + '#', parseAll=True)
     except pp.ParseException as err:
         raise DataJointError('Declaration error in position {pos} in line:\n  {line}\n{msg}'.format(
-            line=err.args[0], pos=err.args[1], msg=err.args[2])) from None
+            line=err.args[0], pos=err.args[1], msg=err.args[2]))
     match['comment'] = match['comment'].rstrip('#')
     if 'default' not in match:
         match['default'] = ''
diff --git a/datajoint/dependencies.py b/datajoint/dependencies.py
index ce083eb8e..a4e1afb0c 100644
--- a/datajoint/dependencies.py
+++ b/datajoint/dependencies.py
@@ -1,9 +1,36 @@
 import networkx as nx
 import itertools
+import re
 from collections import defaultdict, OrderedDict
 from .errors import DataJointError
 
 
+def unite_master_parts(lst):
+    """
+    re-order a list of table names so that part tables immediately follow their master tables without breaking
+    the topological order.
+    Without this correction, a simple topological sort may insert other descendants between master and parts.
+    The input list must be topologically sorted.
+    :example:
+    unite_master_parts(
+        ['`s`.`a`', '`s`.`a__q`', '`s`.`b`', '`s`.`c`', '`s`.`c__q`', '`s`.`b__q`', '`s`.`d`', '`s`.`a__r`']) ->
+        ['`s`.`a`', '`s`.`a__q`', '`s`.`a__r`', '`s`.`b`', '`s`.`b__q`', '`s`.`c`', '`s`.`c__q`', '`s`.`d`']
+    """
+    for i in range(2, len(lst)):
+        name = lst[i]
+        match = re.match(r'(?P<master>`\w+`.`\w+)__\w+`', name)
+        if match:  # name is a part table
+            master = match.group('master')
+            for j in range(i-1, -1, -1):
+                if lst[j] == master + '`' or lst[j].startswith(master + '__'):
+                    # move from the ith position to the (j+1)th position
+                    lst[j+1:i+1] = [name] + lst[j+1:i]
+                    break
+            else:
+                raise DataJointError("Found a part table {name} without its master table.".format(name=name))
+    return lst
+
+
 class Dependencies(nx.DiGraph):
     """
     The graph of dependencies (foreign keys) between loaded tables.
@@ -16,15 +43,22 @@ class Dependencies(nx.DiGraph):
     def __init__(self, connection=None):
         self._conn = connection
         self._node_alias_count = itertools.count()
+        self._loaded = False
         super().__init__(self)
 
-    def load(self):
+    def clear(self):
+        self._loaded = False
+        super().clear()
+
+    def load(self, force=True):
         """
         Load dependencies for all loaded schemas.
         This method gets called before any operation that requires dependencies: delete, drop, populate, progress.
         """
-
         # reload from scratch to prevent duplication of renamed edges
+        if self._loaded and not force:
+            return
+
         self.clear()
 
         # load primary key info
@@ -77,6 +111,7 @@ def load(self):
 
         if not nx.is_directed_acyclic_graph(self):  # pragma: no cover
             raise DataJointError('DataJoint can only work with acyclic dependencies')
+        self._loaded = True
 
     def parents(self, table_name, primary=None):
         """
@@ -86,6 +121,7 @@ def parents(self, table_name, primary=None):
             attribute are considered.
         :return: dict of tables referenced by the foreign keys of table
         """
+        self.load(force=False)
         return {p[0]: p[2] for p in self.in_edges(table_name, data=True)
                 if primary is None or p[2]['primary'] == primary}
 
@@ -97,6 +133,7 @@ def children(self, table_name, primary=None):
             attribute are considered.
         :return: dict of tables referencing the table through foreign keys
         """
+        self.load(force=False)
         return {p[1]: p[2] for p in self.out_edges(table_name, data=True)
                 if primary is None or p[2]['primary'] == primary}
 
@@ -105,17 +142,19 @@ def descendants(self, full_table_name):
         :param full_table_name:  In form `schema`.`table_name`
         :return: all dependent tables sorted in topological order.  Self is included.
         """
+        self.load(force=False)
         nodes = self.subgraph(
             nx.algorithms.dag.descendants(self, full_table_name))
-        return [full_table_name] + list(
-            nx.algorithms.dag.topological_sort(nodes))
+        return unite_master_parts([full_table_name] + list(
+            nx.algorithms.dag.topological_sort(nodes)))
 
     def ancestors(self, full_table_name):
         """
         :param full_table_name:  In form `schema`.`table_name`
         :return: all dependent tables sorted in topological order.  Self is included.
         """
+        self.load(force=False)
         nodes = self.subgraph(
             nx.algorithms.dag.ancestors(self, full_table_name))
-        return [full_table_name] + list(reversed(list(
-            nx.algorithms.dag.topological_sort(nodes))))
+        return list(reversed(unite_master_parts(list(
+            nx.algorithms.dag.topological_sort(nodes)) + [full_table_name])))
diff --git a/datajoint/diagram.py b/datajoint/diagram.py
index 45fe95ea1..41ead0712 100644
--- a/datajoint/diagram.py
+++ b/datajoint/diagram.py
@@ -5,6 +5,7 @@
 import warnings
 import inspect
 from .table import Table
+from .dependencies import unite_master_parts
 
 try:
     from matplotlib import pyplot as plt
@@ -155,29 +156,8 @@ def is_part(part, master):
             return self
 
         def topological_sort(self):
-            """
-            :return:  list of nodes in topological order
-            """
-
-            def _unite(lst):
-                """
-                reorder list so that parts immediately follow their masters without breaking the topological order.
-                Without this correction, simple topological sort may insert other descendants between master and parts
-                :example:
-                _unite(['a', 'a__q', 'b', 'c', 'c__q', 'b__q', 'd', 'a__r'])
-                -> ['a', 'a__q', 'a__r', 'b', 'b__q', 'c', 'c__q', 'd']
-                """
-                if len(lst) <= 2:
-                    return lst
-                el = lst.pop()
-                lst = _unite(lst)
-                if '__' in el:
-                    master = el.split('__')[0]
-                    if not lst[-1].startswith(master):
-                        return _unite(lst[:-1] + [el, lst[-1]])
-                return lst + [el]
-
-            return _unite(list(nx.algorithms.dag.topological_sort(
+            """ :return:  list of nodes in topological order """
+            return unite_master_parts(list(nx.algorithms.dag.topological_sort(
                 nx.DiGraph(self).subgraph(self.nodes_to_show))))
 
         def __add__(self, arg):
diff --git a/datajoint/errors.py b/datajoint/errors.py
index 93a4e01a9..fe0ffc539 100644
--- a/datajoint/errors.py
+++ b/datajoint/errors.py
@@ -5,11 +5,24 @@
 import os
 
 
+# --- Unverified Plugin Check ---
+class PluginWarning(Exception):
+    pass
+
+
 # --- Top Level ---
 class DataJointError(Exception):
     """
     Base class for errors specific to DataJoint internal operation.
     """
+    def __init__(self, *args):
+        from .plugin import connection_plugins, type_plugins
+        self.__cause__ = PluginWarning(
+            'Unverified DataJoint plugin detected.') if any([any(
+                [not plugins[k]['verified'] for k in plugins])
+                for plugins in [connection_plugins, type_plugins]
+                if plugins]) else None
+
     def suggest(self, *args):
         """
         regenerate the exception with additional arguments
diff --git a/datajoint/expression.py b/datajoint/expression.py
index 28ff4a6f4..4e4924fa9 100644
--- a/datajoint/expression.py
+++ b/datajoint/expression.py
@@ -129,7 +129,7 @@ def prep_value(k, v):
                     try:
                         v = uuid.UUID(v)
                     except (AttributeError, ValueError):
-                        raise DataJointError('Badly formed UUID {v} in restriction by `{k}`'.format(k=k, v=v)) from None
+                        raise DataJointError('Badly formed UUID {v} in restriction by `{k}`'.format(k=k, v=v))
                 return "X'%s'" % binascii.hexlify(v.bytes).decode()
             if isinstance(v, (datetime.date, datetime.datetime, datetime.time, decimal.Decimal)):
                 return '"%s"' % v
diff --git a/datajoint/external.py b/datajoint/external.py
index 5e4c7ff0c..f89b753b7 100644
--- a/datajoint/external.py
+++ b/datajoint/external.py
@@ -230,7 +230,7 @@ def upload_filepath(self, local_filepath):
             relative_filepath = str(local_filepath.relative_to(self.spec['stage']).as_posix())
         except ValueError:
             raise DataJointError('The path {path} is not in stage {stage}'.format(
-                path=local_filepath.parent, **self.spec)) from None
+                path=local_filepath.parent, **self.spec))
         uuid = uuid_from_buffer(init_string=relative_filepath)  # hash relative path, not contents
         contents_hash = uuid_from_file(local_filepath)
 
diff --git a/datajoint/hash.py b/datajoint/hash.py
index d65f1f829..67ec103ae 100644
--- a/datajoint/hash.py
+++ b/datajoint/hash.py
@@ -4,7 +4,7 @@
 from pathlib import Path
 
 
-def hash_key_values(mapping):
+def key_hash(mapping):
     """
     32-byte hash of the mapping's key values sorted by the key name.
     This is often used to convert a long primary key value into a shorter hash.
diff --git a/datajoint/heading.py b/datajoint/heading.py
index ca637912f..aaed7f8bf 100644
--- a/datajoint/heading.py
+++ b/datajoint/heading.py
@@ -250,8 +250,8 @@ def init_from_database(self, conn, database, table_name, context):
                         url = "https://docs.datajoint.io/python/admin/5-blob-config.html" \
                               "#migration-between-datajoint-v0-11-and-v0-12"
                         raise DataJointError('Legacy datatype `{type}`. Migrate your external stores to '
-                                             'datajoint 0.12: {url}'.format(url=url, **attr)) from None
-                    raise DataJointError('Unknown attribute type `{type}`'.format(**attr)) from None
+                                             'datajoint 0.12: {url}'.format(url=url, **attr))
+                    raise DataJointError('Unknown attribute type `{type}`'.format(**attr))
                 if category == 'FILEPATH' and not _support_filepath_types():
                     raise DataJointError("""
                         The filepath data type is disabled until complete validation.
diff --git a/datajoint/jobs.py b/datajoint/jobs.py
index d80911782..2efed5afb 100644
--- a/datajoint/jobs.py
+++ b/datajoint/jobs.py
@@ -1,5 +1,5 @@
 import os
-from .hash import hash_key_values
+from .hash import key_hash
 import platform
 from .table import Table
 from .settings import config
@@ -69,7 +69,7 @@ def reserve(self, table_name, key):
         """
         job = dict(
             table_name=table_name,
-            key_hash=hash_key_values(key),
+            key_hash=key_hash(key),
             status='reserved',
             host=platform.node(),
             pid=os.getpid(),
@@ -89,7 +89,7 @@ def complete(self, table_name, key):
         :param table_name: `database`.`table_name`
         :param key: the dict of the job's primary key
         """
-        job_key = dict(table_name=table_name, key_hash=hash_key_values(key))
+        job_key = dict(table_name=table_name, key_hash=key_hash(key))
         (self & job_key).delete_quick()
 
     def error(self, table_name, key, error_message, error_stack=None):
@@ -107,7 +107,7 @@ def error(self, table_name, key, error_message, error_stack=None):
             self.insert1(
                 dict(
                     table_name=table_name,
-                    key_hash=hash_key_values(key),
+                    key_hash=key_hash(key),
                     status="error",
                     host=platform.node(),
                     pid=os.getpid(),
diff --git a/datajoint/plugin.py b/datajoint/plugin.py
new file mode 100644
index 000000000..c80388e09
--- /dev/null
+++ b/datajoint/plugin.py
@@ -0,0 +1,38 @@
+from .settings import config
+import pkg_resources
+from pathlib import Path
+from cryptography.exceptions import InvalidSignature
+from setuptools_certificate import hash_pkg, verify
+
+
+def _update_error_stack(plugin_name):
+    try:
+        base_name = 'datajoint'
+        base_meta = pkg_resources.get_distribution(base_name)
+        plugin_meta = pkg_resources.get_distribution(plugin_name)
+
+        data = hash_pkg(str(Path(plugin_meta.module_path, plugin_name)))
+        signature = plugin_meta.get_metadata('{}.sig'.format(plugin_name))
+        pubkey_path = str(Path(base_meta.egg_info, '{}.pub'.format(base_name)))
+        verify(pubkey_path, data, signature)
+        print('DataJoint verified plugin `{}` detected.'.format(plugin_name))
+        return True
+    except (FileNotFoundError, InvalidSignature):
+        print('Unverified plugin `{}` detected.'.format(plugin_name))
+        return False
+
+
+def _import_plugins(category):
+    return {
+            entry_point.name: dict(object=entry_point,
+                                   verified=_update_error_stack(
+                                        entry_point.module_name.split('.')[0]))
+            for entry_point
+            in pkg_resources.iter_entry_points('datajoint_plugins.{}'.format(category))
+            if 'plugin' not in config or category not in config['plugin'] or
+            entry_point.module_name.split('.')[0] in config['plugin'][category]
+        }
+
+
+connection_plugins = _import_plugins('connection')
+type_plugins = _import_plugins('datatype')
diff --git a/datajoint/s3.py b/datajoint/s3.py
index f8e59bccf..f50e479db 100644
--- a/datajoint/s3.py
+++ b/datajoint/s3.py
@@ -5,9 +5,12 @@
 import minio   # https://docs.minio.io/docs/python-client-api-reference
 import warnings
 import uuid
+import logging
 from pathlib import Path
 from . import errors
 
+logger = logging.getLogger(__name__)
+
 
 class Folder:
     """
@@ -18,24 +21,31 @@ def __init__(self, endpoint, bucket, access_key, secret_key, *, secure=False, **
                                   secure=secure)
         self.bucket = bucket
         if not self.client.bucket_exists(bucket):
-            raise errors.BucketInaccessible('Inaccessible s3 bucket %s' % bucket) from None
+            raise errors.BucketInaccessible('Inaccessible s3 bucket %s' % bucket)
 
     def put(self, name, buffer):
+        logger.debug('put: {}:{}'.format(self.bucket, name))
         return self.client.put_object(
             self.bucket, str(name), BytesIO(buffer), length=len(buffer))
 
     def fput(self, local_file, name, metadata=None):
+        logger.debug('fput: {} -> {}:{}'.format(self.bucket, local_file, name))
         return self.client.fput_object(
             self.bucket, str(name), str(local_file), metadata=metadata)
 
     def get(self, name):
+        logger.debug('get: {}:{}'.format(self.bucket, name))
         try:
             return self.client.get_object(self.bucket, str(name)).data
-        except minio.error.NoSuchKey:
-            raise errors.MissingExternalFile('Missing s3 key %s' % name) from None
+        except minio.error.S3Error as e:
+            if e.code == 'NoSuchKey':
+                raise errors.MissingExternalFile('Missing s3 key %s' % name)
+            else:
+                raise e
 
     def fget(self, name, local_filepath):
         """get file from object name to local filepath"""
+        logger.debug('fget: {}:{}'.format(self.bucket, name))
         name = str(name)
         stat = self.client.stat_object(self.bucket, name)
         meta = {k.lower().lstrip('x-amz-meta'): v for k, v in stat.metadata.items()}
@@ -49,19 +59,28 @@ def fget(self, name, local_filepath):
             return uuid.UUID(meta['contents_hash'])
 
     def exists(self, name):
+        logger.debug('exists: {}:{}'.format(self.bucket, name))
         try:
             self.client.stat_object(self.bucket, str(name))
-        except minio.error.NoSuchKey:
-            return False
+        except minio.error.S3Error as e:
+            if e.code == 'NoSuchKey':
+                return False
+            else:
+                raise e
         return True
 
     def get_size(self, name):
+        logger.debug('get_size: {}:{}'.format(self.bucket, name))
         try:
             return self.client.stat_object(self.bucket, str(name)).size
-        except minio.error.NoSuchKey:
-            raise errors.MissingExternalFile from None
+        except minio.error.S3Error as e:
+            if e.code == 'NoSuchKey':
+                raise errors.MissingExternalFile
+            else:
+                raise e
 
     def remove_object(self, name):
+        logger.debug('remove_object: {}:{}'.format(self.bucket, name))
         try:
             self.client.remove_object(self.bucket, str(name))
         except minio.ResponseError:
diff --git a/datajoint/schemas.py b/datajoint/schemas.py
index a708db3ce..046f20767 100644
--- a/datajoint/schemas.py
+++ b/datajoint/schemas.py
@@ -247,7 +247,6 @@ def save(self, python_filename=None):
         This method is in preparation for a future release and is not officially supported.
         :return: a string containing the body of a complete Python module defining this schema.
         """
-
         module_count = itertools.count()
         # add virtual modules for referenced modules with names vmod0, vmod1, ...
         module_lookup = collections.defaultdict(lambda: 'vmod' + str(next(module_count)))
@@ -258,15 +257,16 @@ def make_class_definition(table):
             class_name = table.split('.')[1].strip('`')
             indent = ''
             if tier == 'Part':
-                class_name = class_name.split('__')[1]
+                class_name = class_name.split('__')[-1]
                 indent += '    '
             class_name = to_camel_case(class_name)
 
             def repl(s):
-                d, tab = s.group(1), s.group(2)
-                return ('' if d == db else (module_lookup[d]+'.')) + to_camel_case(tab)
+                d, tabs = s.group(1), s.group(2)
+                return ('' if d == db else (module_lookup[d]+'.')) + '.'.join(
+                    to_camel_case(tab) for tab in tabs.lstrip('__').split('__'))
 
-            return ('' if tier == 'Part' else '@schema\n') + \
+            return ('' if tier == 'Part' else '\n@schema\n') + \
                 '{indent}class {class_name}(dj.{tier}):\n{indent}    definition = """\n{indent}    {defi}"""'.format(
                     class_name=class_name,
                     indent=indent,
@@ -276,8 +276,8 @@ def repl(s):
                         FreeTable(self.connection, table).describe(printout=False).replace('\n', '\n    ' + indent)))
 
         diagram = Diagram(self)
-        body = '\n\n\n'.join(make_class_definition(table) for table in diagram.topological_sort())
-        python_code = '\n\n\n'.join((
+        body = '\n\n'.join(make_class_definition(table) for table in diagram.topological_sort())
+        python_code = '\n\n'.join((
             '"""This module was auto-generated by datajoint from an existing schema"""',
             "import datajoint as dj\n\nschema = dj.Schema('{db}')".format(db=db),
             '\n'.join("{module} = dj.VirtualModule('{module}', '{schema_name}')".format(module=v, schema_name=k)
@@ -288,6 +288,17 @@ def repl(s):
             with open(python_filename, 'wt') as f:
                 f.write(python_code)
 
+    def list_tables(self):
+        """
+        Return a list of all tables in the schema except tables with ~ in first character such
+        as ~logs and ~job
+        :return: A list of table names in their raw datajoint naming convection form
+        """
+
+        return [table_name for (table_name,) in self.connection.query("""
+            SELECT table_name FROM information_schema.tables
+            WHERE table_schema = %s and table_name NOT LIKE '~%%'""", args=(self.database))]
+
 
 class VirtualModule(types.ModuleType):
     """
diff --git a/datajoint/settings.py b/datajoint/settings.py
index a58ab0f15..95cf1db20 100644
--- a/datajoint/settings.py
+++ b/datajoint/settings.py
@@ -133,7 +133,7 @@ def get_store_spec(self, store):
         try:
             spec = self['stores'][store]
         except KeyError:
-            raise DataJointError('Storage {store} is requested but not configured'.format(store=store)) from None
+            raise DataJointError('Storage {store} is requested but not configured'.format(store=store))
 
         spec['subfolding'] = spec.get('subfolding', DEFAULT_SUBFOLDING)
         spec_keys = {  # REQUIRED in uppercase and allowed in lowercase
@@ -144,7 +144,7 @@ def get_store_spec(self, store):
             spec_keys = spec_keys[spec.get('protocol', '').lower()]
         except KeyError:
             raise DataJointError(
-                'Missing or invalid protocol in dj.config["stores"]["{store}"]'.format(store=store)) from None
+                'Missing or invalid protocol in dj.config["stores"]["{store}"]'.format(store=store))
 
         # check that all required keys are present in spec
         try:
diff --git a/datajoint/table.py b/datajoint/table.py
index 7dbe4876e..aaee1e2ee 100644
--- a/datajoint/table.py
+++ b/datajoint/table.py
@@ -11,7 +11,7 @@
 from .declare import declare, alter
 from .expression import QueryExpression
 from . import blob
-from .utils import user_choice
+from .utils import user_choice, OrderedDict
 from .heading import Heading
 from .errors import DuplicateError, AccessError, DataJointError, UnknownAttributeError
 from .version import __version__ as version
@@ -40,8 +40,7 @@ class Table(QueryExpression):
     @property
     def heading(self):
         """
-        Returns the table heading. If the table is not declared, attempts to declare it and return heading.
-        :return: table heading
+        :return: table heading. If the table is not declared, attempts to declare it first.
         """
         if self._heading is None:
             self._heading = Heading()  # instance-level heading
@@ -53,7 +52,8 @@ def heading(self):
     def declare(self, context=None):
         """
         Declare the table in the schema based on self.definition.
-        :param context: the context for foreign key resolution. If None, foreign keys are not allowed.
+        :param context: the context for foreign key resolution. If None, foreign keys are
+            not allowed.
         """
         if self.connection.in_transaction:
             raise DataJointError('Cannot declare new tables inside a transaction, '
@@ -116,29 +116,68 @@ def get_select_fields(self, select_fields=None):
         """
         return '*' if select_fields is None else self.heading.project(select_fields).as_sql
 
-    def parents(self, primary=None):
+    def parents(self, primary=None, as_objects=False, foreign_key_info=False):
         """
         :param primary: if None, then all parents are returned. If True, then only foreign keys composed of
-            primary key attributes are considered.  If False, the only foreign keys including at least one non-primary
-            attribute are considered.
-        :return: dict of tables referenced with self's foreign keys
+            primary key attributes are considered.  If False, return foreign keys including at least one
+            secondary attribute.
+        :param as_objects: if False, return table names. If True, return table objects.
+        :param foreign_key_info: if True, each element in result also includes foreign key info.
+        :return: list of parents as table names or table objects
+            with (optional) foreign key information.
+        """
+        get_edge = self.connection.dependencies.parents
+        nodes = [next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+                 for name, props in get_edge(self.full_table_name, primary).items()]
+        if as_objects:
+            nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+        if not foreign_key_info:
+            nodes = [name for name, props in nodes]
+        return nodes
+
+    def children(self, primary=None, as_objects=False, foreign_key_info=False):
         """
-        return self.connection.dependencies.parents(self.full_table_name, primary)
+        :param primary: if None, then all children are returned. If True, then only foreign keys composed of
+            primary key attributes are considered.  If False, return foreign keys including at least one
+            secondary attribute.
+        :param as_objects: if False, return table names. If True, return table objects.
+        :param foreign_key_info: if True, each element in result also includes foreign key info.
+        :return: list of children as table names or table objects
+            with (optional) foreign key information.
+        """
+        get_edge = self.connection.dependencies.children
+        nodes = [next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+                 for name, props in get_edge(self.full_table_name, primary).items()]
+        if as_objects:
+            nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+        if not foreign_key_info:
+            nodes = [name for name, props in nodes]
+        return nodes
 
-    def children(self, primary=None):
+    def descendants(self, as_objects=False):
         """
-        :param primary: if None, then all children are returned. If True, then only foreign keys composed of
-            primary key attributes are considered.  If False, the only foreign keys including at least one non-primary
-            attribute are considered.
-        :return: dict of tables with foreign keys referencing self
+        :param as_objects: False - a list of table names; True - a list of table objects.
+        :return: list of tables descendants in topological order.
         """
-        return self.connection.dependencies.children(self.full_table_name, primary)
+        return [FreeTable(self.connection, node) if as_objects else node
+                for node in self.connection.dependencies.descendants(self.full_table_name) if not node.isdigit()]
 
-    def descendants(self):
-        return self.connection.dependencies.descendants(self.full_table_name)
+    def ancestors(self, as_objects=False):
+        """
+        :param as_objects: False - a list of table names; True - a list of table objects.
+        :return: list of tables ancestors in topological order.
+        """
+        return [FreeTable(self.connection, node) if as_objects else node
+                for node in self.connection.dependencies.ancestors(self.full_table_name) if not node.isdigit()]
 
-    def ancestors(self):
-        return self.connection.dependencies.ancestors(self.full_table_name)
+    def parts(self, as_objects=False):
+        """
+        return part tables either as entries in a dict with foreign key informaiton or a list of objects
+        :param as_objects: if False (default), the output is a dict describing the foreign keys. If True, return table objects.
+        """
+        nodes = [node for node in self.connection.dependencies.nodes
+                 if not node.isdigit() and node.startswith(self.full_table_name[:-1] + '__')]
+        return [FreeTable(self.connection, c) for c in nodes] if as_objects else nodes
 
     @property
     def is_declared(self):
@@ -276,9 +315,9 @@ def insert(self, rows, replace=False, skip_duplicates=False, ignore_extra_fields
                 self.connection.query(query, args=list(
                     itertools.chain.from_iterable((v for v in r['values'] if v is not None) for r in rows)))
             except UnknownAttributeError as err:
-                raise err.suggest('To ignore extra fields in insert, set ignore_extra_fields=True') from None
+                raise err.suggest('To ignore extra fields in insert, set ignore_extra_fields=True')
             except DuplicateError as err:
-                raise err.suggest('To ignore duplicate entries in insert, set skip_duplicates=True') from None
+                raise err.suggest('To ignore duplicate entries in insert, set skip_duplicates=True')
 
     def delete_quick(self, get_count=False):
         """
@@ -304,7 +343,7 @@ def delete(self, verbose=True):
                                  'Set dj.config["safemode"] = False or complete the ongoing transaction first.')
         graph = conn.dependencies
         graph.load()
-        delete_list = collections.OrderedDict(
+        delete_list = OrderedDict(
             (name, _RenameMap(next(iter(graph.parents(name).items()))) if name.isdigit() else FreeTable(conn, name))
             for name in graph.descendants(self.full_table_name))
 
@@ -432,7 +471,7 @@ def describe(self, context=None, printout=True):
             del frame
         if self.full_table_name not in self.connection.dependencies:
             self.connection.dependencies.load()
-        parents = self.parents()
+        parents = self.parents(foreign_key_info=True)
         in_key = True
         definition = ('# ' + self.heading.table_info['comment'] + '\n'
                       if self.heading.table_info['comment'] else '')
@@ -445,11 +484,10 @@ def describe(self, context=None, printout=True):
                 in_key = False
             attributes_thus_far.add(attr.name)
             do_include = True
-            for parent_name, fk_props in list(parents.items()):  # need list() to force a copy
+            for parent_name, fk_props in parents:
                 if attr.name in fk_props['attr_map']:
                     do_include = False
                     if attributes_thus_far.issuperset(fk_props['attr_map']):
-                        parents.pop(parent_name)
                         # foreign key properties
                         try:
                             index_props = indexes.pop(tuple(fk_props['attr_map']))
@@ -459,19 +497,19 @@ def describe(self, context=None, printout=True):
                             index_props = [k for k, v in index_props.items() if v]
                             index_props = ' [{}]'.format(', '.join(index_props)) if index_props else ''
 
-                        if not parent_name.isdigit():
+                        if not fk_props['aliased']:
                             # simple foreign key
                             definition += '->{props} {class_name}\n'.format(
                                 props=index_props,
                                 class_name=lookup_class_name(parent_name, context) or parent_name)
                         else:
                             # projected foreign key
-                            parent_name = list(self.connection.dependencies.in_edges(parent_name))[0][0]
-                            lst = [(attr, ref) for attr, ref in fk_props['attr_map'].items() if ref != attr]
                             definition += '->{props} {class_name}.proj({proj_list})\n'.format(
                                 props=index_props,
                                 class_name=lookup_class_name(parent_name, context) or parent_name,
-                                proj_list=','.join('{}="{}"'.format(a, b) for a, b in lst))
+                                proj_list=','.join(
+                                    '{}="{}"'.format(attr, ref)
+                                    for attr, ref in fk_props['attr_map'].items() if ref != attr))
                             attributes_declared.update(fk_props['attr_map'])
             if do_include:
                 attributes_declared.add(attr.name)
@@ -556,7 +594,8 @@ def __make_placeholder(self, name, value, ignore_extra_fields=False):
                         value = uuid.UUID(value)
                     except (AttributeError, ValueError):
                         raise DataJointError(
-                            'badly formed UUID value {v} for attribute `{n}`'.format(v=value, n=name)) from None
+                            'badly formed UUID value {v} for attribute `{n}`'.format(v=value,
+                                                                                     n=name))
                 value = value.bytes
             elif attr.is_blob:
                 value = blob.pack(value)
@@ -731,6 +770,7 @@ def __init__(self, arg, database=None, skip_logging=False):
 
         if not self.is_declared:
             self.declare()
+            self.connection.dependencies.clear()
         self._user = self.connection.get_user()
 
     @property
diff --git a/datajoint/user_tables.py b/datajoint/user_tables.py
index 5613ebb3c..3326bcb52 100644
--- a/datajoint/user_tables.py
+++ b/datajoint/user_tables.py
@@ -12,8 +12,9 @@
 
 # attributes that trigger instantiation of user classes
 supported_class_attrs = {
-    'key_source', 'describe', 'alter', 'heading', 'populate', 'progress', 'primary_key', 'proj', 'aggr',
-    'fetch', 'fetch1', 'head', 'tail',
+    'key_source', 'describe', 'alter', 'heading', 'populate', 'progress', 'primary_key',
+    'proj', 'aggr', 'fetch', 'fetch1', 'head', 'tail',
+    'descendants', 'parts', 'ancestors', 'parents', 'children',
     'insert', 'insert1', 'update1', 'drop', 'drop_quick', 'delete', 'delete_quick'}
 
 
diff --git a/datajoint/utils.py b/datajoint/utils.py
index 8ad6f4b48..a20f26059 100644
--- a/datajoint/utils.py
+++ b/datajoint/utils.py
@@ -45,13 +45,13 @@ def to_camel_case(s):
     :param s: string in under_score notation
     :returns: string in CamelCase notation
     Example:
-    >>> to_camel_case("table_name") # yields "TableName"
+    >>> to_camel_case("table_name")  # returns "TableName"
     """
 
     def to_upper(match):
         return match.group(0)[-1].upper()
 
-    return re.sub('(^|[_\W])+[a-zA-Z]', to_upper, s)
+    return re.sub(r'(^|[_\W])+[a-zA-Z]', to_upper, s)
 
 
 def from_camel_case(s):
diff --git a/datajoint/version.py b/datajoint/version.py
index e4953315f..a7571b6c4 100644
--- a/datajoint/version.py
+++ b/datajoint/version.py
@@ -1,3 +1,3 @@
-__version__ = "0.12.7"
+__version__ = "0.13.0"
 
 assert len(__version__) <= 10  # The log table limits version to the 10 characters
diff --git a/docs-parts/intro/Releases_lang1.rst b/docs-parts/intro/Releases_lang1.rst
index cb05af36f..9012bceeb 100644
--- a/docs-parts/intro/Releases_lang1.rst
+++ b/docs-parts/intro/Releases_lang1.rst
@@ -1,3 +1,18 @@
+0.13.0 -- TBD
+----------------------
+* Support DataJoint datatype and connection plugins (#715, #729) PR 730, #735
+* Allow updating specified secondary attributes using `update1` PR #763
+
+0.12.8 -- Jan 12, 2021
+----------------------
+* table.children, .parents, .descendents, and ancestors can return queryable objects. PR #833
+* Load dependencies before querying dependencies. (#179) PR #833
+* Fix display of part tables in `schema.save`. (#821) PR #833
+* Add `schema.list_tables`. (#838) PR #844
+* Fix minio new version regression.  PR #847
+* Add more S3 logging for debugging. (#831) PR #832 
+* Convert testing framework from TravisCI to GitHub Actions (#841) PR #840
+
 0.12.7 -- Oct 27, 2020
 ----------------------
 * Fix case sensitivity issues to adapt to MySQL 8+.  PR #819
diff --git a/local-docker-compose.yml b/local-docker-compose.yml
index 36b70b1cb..28522b619 100644
--- a/local-docker-compose.yml
+++ b/local-docker-compose.yml
@@ -34,7 +34,7 @@ services:
       interval: 1s
   fakeservices.datajoint.io:
     <<: *net
-    image: raphaelguzman/nginx:v0.0.10
+    image: raphaelguzman/nginx:v0.0.13
     environment:
     - ADD_db_TYPE=DATABASE
     - ADD_db_ENDPOINT=db:3306
diff --git a/requirements.txt b/requirements.txt
index 5c84e822c..fa86f6fb5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,5 +6,7 @@ pandas
 tqdm
 networkx
 pydot
-minio
+minio>=7.0.0
 matplotlib
+cryptography
+setuptools_certificate
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 339bd502e..c91f6f3d1 100644
--- a/setup.py
+++ b/setup.py
@@ -31,5 +31,7 @@
     keywords='database organization',
     packages=find_packages(exclude=['contrib', 'docs', 'tests*']),
     install_requires=requirements,
-    python_requires='~={}.{}'.format(*min_py_version)
+    python_requires='~={}.{}'.format(*min_py_version),
+    setup_requires=['setuptools_certificate'], # maybe remove due to conflicts?
+    pubkey_path='./datajoint.pub'
 )


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index bb0b814c4..1a48a3a91 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -49,44 +49,6 @@
 PREFIX = environ.get('DJ_TEST_DB_PREFIX', 'djtest')
 conn_root = dj.conn(**CONN_INFO_ROOT)
 
-if LooseVersion(conn_root.query(
-        "select @@version;").fetchone()[0]) >= LooseVersion('8.0.0'):
-    # create user if necessary on mysql8
-    conn_root.query("""
-            CREATE USER IF NOT EXISTS 'datajoint'@'%%'
-            IDENTIFIED BY 'datajoint';
-            """)
-    conn_root.query("""
-            CREATE USER IF NOT EXISTS 'djview'@'%%'
-            IDENTIFIED BY 'djview';
-            """)
-    conn_root.query("""
-            CREATE USER IF NOT EXISTS 'djssl'@'%%'
-            IDENTIFIED BY 'djssl'
-            REQUIRE SSL;
-            """)
-    conn_root.query(
-        "GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%';")
-    conn_root.query(
-        "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%';")
-    conn_root.query(
-        "GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%';")
-else:
-    # grant permissions. For mysql5.6/5.7 this also automatically creates user
-    # if not exists
-    conn_root.query("""
-        GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%'
-        IDENTIFIED BY 'datajoint';
-        """)
-    conn_root.query(
-        "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%' IDENTIFIED BY 'djview';"
-        )
-    conn_root.query("""
-        GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%'
-        IDENTIFIED BY 'djssl'
-        REQUIRE SSL;
-        """)
-
 # Initialize httpClient with relevant timeout.
 httpClient = urllib3.PoolManager(
             timeout=30,
@@ -115,6 +77,45 @@ def setup_package():
     """
     dj.config['safemode'] = False
 
+    # Create MySQL users
+    if LooseVersion(conn_root.query(
+            "select @@version;").fetchone()[0]) >= LooseVersion('8.0.0'):
+        # create user if necessary on mysql8
+        conn_root.query("""
+                CREATE USER IF NOT EXISTS 'datajoint'@'%%'
+                IDENTIFIED BY 'datajoint';
+                """)
+        conn_root.query("""
+                CREATE USER IF NOT EXISTS 'djview'@'%%'
+                IDENTIFIED BY 'djview';
+                """)
+        conn_root.query("""
+                CREATE USER IF NOT EXISTS 'djssl'@'%%'
+                IDENTIFIED BY 'djssl'
+                REQUIRE SSL;
+                """)
+        conn_root.query(
+            "GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%';")
+        conn_root.query(
+            "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%';")
+        conn_root.query(
+            "GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%';")
+    else:
+        # grant permissions. For mysql5.6/5.7 this also automatically creates user
+        # if not exists
+        conn_root.query("""
+            GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%'
+            IDENTIFIED BY 'datajoint';
+            """)
+        conn_root.query(
+            "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%' IDENTIFIED BY 'djview';"
+            )
+        conn_root.query("""
+            GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%'
+            IDENTIFIED BY 'djssl'
+            REQUIRE SSL;
+            """)
+
     # Add old MySQL
     source = Path(
         Path(__file__).resolve().parent,
@@ -136,8 +137,9 @@ def setup_package():
     region = "us-east-1"
     try:
         minioClient.make_bucket(S3_MIGRATE_BUCKET, location=region)
-    except minio.error.BucketAlreadyOwnedByYou:
-        pass
+    except minio.error.S3Error as e:
+        if e.code != 'BucketAlreadyOwnedByYou':
+            raise e
 
     pathlist = Path(source).glob('**/*')
     for path in pathlist:
@@ -149,8 +151,9 @@ def setup_package():
     # Add S3
     try:
         minioClient.make_bucket(S3_CONN_INFO['bucket'], location=region)
-    except minio.error.BucketAlreadyOwnedByYou:
-        pass
+    except minio.error.S3Error as e:
+        if e.code != 'BucketAlreadyOwnedByYou':
+            raise e
 
     # Add old File Content
     try:
@@ -169,24 +172,28 @@ def teardown_package():
     To deal with possible foreign key constraints, it will unset
     and then later reset FOREIGN_KEY_CHECKS flag
     """
-    conn = dj.conn(**CONN_INFO)
-    conn.query('SET FOREIGN_KEY_CHECKS=0')
-    cur = conn.query('SHOW DATABASES LIKE "{}\_%%"'.format(PREFIX))
+    conn_root.query('SET FOREIGN_KEY_CHECKS=0')
+    cur = conn_root.query('SHOW DATABASES LIKE "{}\_%%"'.format(PREFIX))
     for db in cur.fetchall():
-        conn.query('DROP DATABASE `{}`'.format(db[0]))
-    conn.query('SET FOREIGN_KEY_CHECKS=1')
+        conn_root.query('DROP DATABASE `{}`'.format(db[0]))
+    conn_root.query('SET FOREIGN_KEY_CHECKS=1')
     if os.path.exists("dj_local_conf.json"):
         remove("dj_local_conf.json")
 
+    # Remove created users
+    conn_root.query('DROP USER `datajoint`')
+    conn_root.query('DROP USER `djview`')
+    conn_root.query('DROP USER `djssl`')
+
     # Remove old S3
-    objs = list(minioClient.list_objects_v2(
+    objs = list(minioClient.list_objects(
             S3_MIGRATE_BUCKET, recursive=True))
     objs = [minioClient.remove_object(S3_MIGRATE_BUCKET,
             o.object_name.encode('utf-8')) for o in objs]
     minioClient.remove_bucket(S3_MIGRATE_BUCKET)
 
     # Remove S3
-    objs = list(minioClient.list_objects_v2(S3_CONN_INFO['bucket'], recursive=True))
+    objs = list(minioClient.list_objects(S3_CONN_INFO['bucket'], recursive=True))
     objs = [minioClient.remove_object(S3_CONN_INFO['bucket'],
             o.object_name.encode('utf-8')) for o in objs]
     minioClient.remove_bucket(S3_CONN_INFO['bucket'])
diff --git a/tests/test_adapted_attributes.py b/tests/test_adapted_attributes.py
index dfaa370de..2769e5bd0 100644
--- a/tests/test_adapted_attributes.py
+++ b/tests/test_adapted_attributes.py
@@ -6,9 +6,8 @@
 from .schema_adapted import graph, layout_to_filepath
 
 
-def test_adapted_type():
+def test_adapted_type(c=adapted.Connectivity()):
     dj.errors._switch_adapted_types(True)
-    c = adapted.Connectivity()
     graphs = [nx.lollipop_graph(4, 2), nx.star_graph(5), nx.barbell_graph(3, 1), nx.cycle_graph(5)]
     c.insert((i, g) for i, g in enumerate(graphs))
     returned_graphs = c.fetch('conn_graph', order_by='connid')
@@ -83,6 +82,3 @@ def test_adapted_virtual():
             assert_true(0 == len(nx.symmetric_difference(g1, g2).edges))
     c.delete()
     dj.errors._switch_adapted_types(False)
-
-
-
diff --git a/tests/test_declare.py b/tests/test_declare.py
index 6688e8cbb..cce2d6366 100644
--- a/tests/test_declare.py
+++ b/tests/test_declare.py
@@ -1,4 +1,4 @@
-from nose.tools import assert_true, assert_false, assert_equal, assert_list_equal, raises
+from nose.tools import assert_true, assert_false, assert_equal, assert_list_equal, raises, assert_set_equal
 from .schema import *
 import datajoint as dj
 import inspect
@@ -63,7 +63,6 @@ def test_describe_dependencies():
         s2 = declare(rel.full_table_name, rel.describe(), context)
         assert_equal(s1, s2)
 
-
     @staticmethod
     def test_part():
         # Lookup and part with the same name.  See issue #365
@@ -81,6 +80,7 @@ class TypeMaster(dj.Manual):
             definition = """
             master_id : int
             """
+
             class Type(dj.Part):
                 definition = """
                 -> TypeMaster
@@ -126,21 +126,39 @@ def test_attributes():
 
     @staticmethod
     def test_dependencies():
-        assert_true(experiment.full_table_name in set(user.children(primary=False)))
+        assert_true(experiment.full_table_name in user.children(primary=False))
         assert_equal(set(experiment.parents(primary=False)), {user.full_table_name})
+        assert_true(experiment.full_table_name in user.children(primary=False))
+        assert_set_equal(set(experiment.parents(primary=False)), {user.full_table_name})
+        assert_set_equal(set(s.full_table_name for s in experiment.parents(primary=False, as_objects=True)),
+                         {user.full_table_name})
 
         assert_true(experiment.full_table_name in subject.descendants())
+        assert_true(experiment.full_table_name in {s.full_table_name for s in subject.descendants(as_objects=True)})
         assert_true(subject.full_table_name in experiment.ancestors())
+        assert_true(subject.full_table_name in {s.full_table_name for s in experiment.ancestors(as_objects=True)})
 
         assert_true(trial.full_table_name in experiment.descendants())
+        assert_true(trial.full_table_name in {s.full_table_name for s in experiment.descendants(as_objects=True)})
         assert_true(experiment.full_table_name in trial.ancestors())
-
-        assert_equal(set(trial.children(primary=True)),
-                     {ephys.full_table_name, trial.Condition.full_table_name})
-        assert_equal(set(ephys.parents(primary=True)), {trial.full_table_name})
-
-        assert_equal(set(ephys.children(primary=True)), {channel.full_table_name})
-        assert_equal(set(channel.parents(primary=True)), {ephys.full_table_name})
+        assert_true(experiment.full_table_name in {s.full_table_name for s in trial.ancestors(as_objects=True)})
+
+        assert_set_equal(set(trial.children(primary=True)), {ephys.full_table_name, trial.Condition.full_table_name})
+        assert_set_equal(set(trial.parts()), {trial.Condition.full_table_name})
+        assert_set_equal(set(s.full_table_name for s in trial.parts(as_objects=True)),
+                         {trial.Condition.full_table_name})
+        assert_set_equal(set(ephys.parents(primary=True)),
+                         {trial.full_table_name})
+        assert_set_equal(set(s.full_table_name for s in ephys.parents(primary=True, as_objects=True)),
+                         {trial.full_table_name})
+        assert_set_equal(set(ephys.children(primary=True)),
+                         {channel.full_table_name})
+        assert_set_equal(set(s.full_table_name for s in ephys.children(primary=True, as_objects=True)),
+                         {channel.full_table_name})
+        assert_set_equal(set(channel.parents(primary=True)),
+                         {ephys.full_table_name})
+        assert_set_equal(set(s.full_table_name for s in channel.parents(primary=True, as_objects=True)),
+                         {ephys.full_table_name})
 
     @staticmethod
     @raises(dj.DataJointError)
diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py
index ed986b94c..2b7687bb5 100644
--- a/tests/test_dependencies.py
+++ b/tests/test_dependencies.py
@@ -1,10 +1,32 @@
-from nose.tools import assert_true, assert_false, assert_equal, assert_list_equal, raises
+from nose.tools import assert_true, raises, assert_list_equal
 from .schema import *
+from datajoint.dependencies import unite_master_parts
+
+
+def test_unite_master_parts():
+    assert_list_equal(unite_master_parts(
+        ['`s`.`a`', '`s`.`a__q`', '`s`.`b`', '`s`.`c`', '`s`.`c__q`', '`s`.`b__q`', '`s`.`d`', '`s`.`a__r`']),
+        ['`s`.`a`', '`s`.`a__q`', '`s`.`a__r`', '`s`.`b`', '`s`.`b__q`', '`s`.`c`', '`s`.`c__q`', '`s`.`d`'])
+    assert_list_equal(unite_master_parts(
+        [
+            '`cells`.`cell_analysis_method`',
+            '`cells`.`cell_analysis_method_task_type`',
+            '`cells`.`cell_analysis_method_users`',
+            '`cells`.`favorite_selection`',
+            '`cells`.`cell_analysis_method__cell_selection_params`',
+            '`cells`.`cell_analysis_method__field_detect_params`']),
+        [
+            '`cells`.`cell_analysis_method`',
+            '`cells`.`cell_analysis_method__cell_selection_params`',
+            '`cells`.`cell_analysis_method__field_detect_params`',
+            '`cells`.`cell_analysis_method_task_type`',
+            '`cells`.`cell_analysis_method_users`',
+            '`cells`.`favorite_selection`'
+        ])
 
 
 def test_nullable_dependency():
     """test nullable unique foreign key"""
-
     # Thing C has a nullable dependency on B whose primary key is composite
     a = ThingA()
     b = ThingB()
diff --git a/tests/test_fetch.py b/tests/test_fetch.py
index 8c8d50549..c72c3c896 100644
--- a/tests/test_fetch.py
+++ b/tests/test_fetch.py
@@ -201,7 +201,7 @@ def test_fetch1_step3(self):
         self.lang.fetch1('name')
 
     def test_decimal(self):
-        """Tests that decimal fields are correctly fetched and used in restrictions, see issue #334"""
+        """ Tests that decimal fields are correctly fetched and used in restrictions, see issue #334"""
         rel = schema.DecimalPrimaryKey()
         rel.insert1([decimal.Decimal('3.1415926')])
         keys = rel.fetch()
diff --git a/tests/test_foreign_keys.py b/tests/test_foreign_keys.py
index 719ae7129..fbf023bb0 100644
--- a/tests/test_foreign_keys.py
+++ b/tests/test_foreign_keys.py
@@ -1,4 +1,4 @@
-from nose.tools import assert_equal, assert_false, assert_true, raises
+from nose.tools import assert_equal, assert_false, assert_true
 from datajoint.declare import declare
 
 from . import schema_advanced
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
new file mode 100644
index 000000000..5fb7d46bc
--- /dev/null
+++ b/tests/test_plugin.py
@@ -0,0 +1,51 @@
+import datajoint.errors as djerr
+import datajoint.plugin as p
+import pkg_resources
+from os import path
+
+
+def test_check_pubkey():
+    base_name = 'datajoint'
+    base_meta = pkg_resources.get_distribution(base_name)
+    pubkey_meta = base_meta.get_metadata('{}.pub'.format(base_name))
+
+    with open(path.join(path.abspath(
+            path.dirname(__file__)), '..', 'datajoint.pub'), "r") as f:
+        assert(f.read() == pubkey_meta)
+
+
+def test_normal_djerror():
+    try:
+        raise djerr.DataJointError
+    except djerr.DataJointError as e:
+        assert(e.__cause__ is None)
+
+
+def test_verified_djerror(category='connection'):
+    try:
+        curr_plugins = getattr(p, '{}_plugins'.format(category))
+        setattr(p, '{}_plugins'.format(category), dict(test_plugin_id=dict(
+                                                        verified=True, object='example')))
+        raise djerr.DataJointError
+    except djerr.DataJointError as e:
+        setattr(p, '{}_plugins'.format(category), curr_plugins)
+        assert(e.__cause__ is None)
+
+
+def test_verified_djerror_type():
+    test_verified_djerror(category='type')
+
+
+def test_unverified_djerror(category='connection'):
+    try:
+        curr_plugins = getattr(p, '{}_plugins'.format(category))
+        setattr(p, '{}_plugins'.format(category), dict(test_plugin_id=dict(
+                                                        verified=False, object='example')))
+        raise djerr.DataJointError("hello")
+    except djerr.DataJointError as e:
+        setattr(p, '{}_plugins'.format(category), curr_plugins)
+        assert(isinstance(e.__cause__, djerr.PluginWarning))
+
+
+def test_unverified_djerror_type():
+    test_unverified_djerror(category='type')
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 98ec3e7f5..2d868b60d 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -4,6 +4,7 @@
 from . import schema
 from . import schema_empty
 from . import PREFIX, CONN_INFO
+from .schema_simple import schema as schema_simple
 
 
 def relation_selector(attr):
@@ -105,6 +106,11 @@ class Unit(dj.Part):
 
     test_schema.drop()
 
+def test_list_tables():
+    assert(['#a', '#argmax_test', '#data_a', '#data_b', '#i_j', '#j_i', '#l',\
+    '#t_test_update', '__b', '__b__c', '__d', '__e', '__e__f', 'f',\
+    'reserved_word'] == schema_simple.list_tables())
+
 
 def test_schema_save():
     assert_true("class Experiment(dj.Imported)" in schema.schema.code)