From 49173f55ddf69ae313ddae6fbe18ad6eaddbfee0 Mon Sep 17 00:00:00 2001
From: James Page <james.page@canonical.com>
Date: Thu, 3 Nov 2022 15:17:03 +0100
Subject: [PATCH] General tidy for module ready for release.

Refresh charm to drop release usage in ops-sunbeam.

Drop surplus template fragments.

Refresh unit tests.

Tidy requirements.txt.

Switch to black + other linters.

Tidy docstrings across operator.

Change-Id: I872da0c54dda857a4005b84905cb248d7a9782ae
---
 charms/nova-k8s/pyproject.toml                |  39 +++
 charms/nova-k8s/requirements.txt              |   8 -
 charms/nova-k8s/src/charm.py                  | 272 +++++++++++-------
 .../src/templates/parts/section-database      |   7 -
 .../src/templates/parts/section-federation    |  10 -
 .../src/templates/parts/section-middleware    |   6 -
 .../src/templates/parts/section-signing       |  15 -
 charms/nova-k8s/tests/unit/__init__.py        |  15 +
 charms/nova-k8s/tests/unit/test_nova_charm.py |  64 +++--
 charms/nova-k8s/tox.ini                       |  41 ++-
 10 files changed, 296 insertions(+), 181 deletions(-)
 create mode 100644 charms/nova-k8s/pyproject.toml
 delete mode 100644 charms/nova-k8s/src/templates/parts/section-database
 delete mode 100644 charms/nova-k8s/src/templates/parts/section-federation
 delete mode 100644 charms/nova-k8s/src/templates/parts/section-middleware
 delete mode 100644 charms/nova-k8s/src/templates/parts/section-signing

diff --git a/charms/nova-k8s/pyproject.toml b/charms/nova-k8s/pyproject.toml
new file mode 100644
index 00000000..2896bc05
--- /dev/null
+++ b/charms/nova-k8s/pyproject.toml
@@ -0,0 +1,39 @@
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+# Testing tools configuration
+[tool.coverage.run]
+branch = true
+
+[tool.coverage.report]
+show_missing = true
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+log_cli_level = "INFO"
+
+# Formatting tools configuration
+[tool.black]
+line-length = 79
+
+[tool.isort]
+profile = "black"
+multi_line_output = 3
+force_grid_wrap = true
+
+# Linting tools configuration
+[tool.flake8]
+max-line-length = 79
+max-doc-length = 99
+max-complexity = 10
+exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"]
+select = ["E", "W", "F", "C", "N", "R", "D", "H"]
+# Ignore W503, E501 because using black creates errors with this
+# Ignore D107 Missing docstring in __init__
+ignore = ["W503", "E501", "D107", "E402"]
+per-file-ignores = []
+docstring-convention = "google"
+# Check for properly formatted copyright header in each file
+copyright-check = "True"
+copyright-author = "Canonical Ltd."
+copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s"
diff --git a/charms/nova-k8s/requirements.txt b/charms/nova-k8s/requirements.txt
index b9b8fb9b..63e8a30a 100644
--- a/charms/nova-k8s/requirements.txt
+++ b/charms/nova-k8s/requirements.txt
@@ -16,12 +16,4 @@ lightkube
 lightkube-models
 ops
 git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam
-
-python-keystoneclient  # keystone-k8s
-
 git+https://opendev.org/openstack/charm-ops-interface-tls-certificates#egg=interface_tls_certificates
-
-# Note: Required for cinder-k8s, cinder-ceph-k8s, glance-k8s, nova-k8s
-git+https://opendev.org/openstack/charm-ops-interface-ceph-client#egg=interface_ceph_client
-# Charmhelpers is only present as interface_ceph_client uses it.
-git+https://github.com/juju/charm-helpers.git#egg=charmhelpers
diff --git a/charms/nova-k8s/src/charm.py b/charms/nova-k8s/src/charm.py
index 4bebdd12..3d59fffe 100755
--- a/charms/nova-k8s/src/charm.py
+++ b/charms/nova-k8s/src/charm.py
@@ -1,4 +1,18 @@
 #!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+#
+# Licensed 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.
+
 """Nova Operator Charm.
 
 This charm provide Nova services as part of an OpenStack deployment
@@ -6,19 +20,25 @@ This charm provide Nova services as part of an OpenStack deployment
 
 import logging
 import uuid
-from typing import Callable, List, Mapping
-
-import ops.framework
-from ops.main import main
-from ops.pebble import ExecError
-
-import ops_sunbeam.charm as sunbeam_charm
-import ops_sunbeam.core as sunbeam_core
-import ops_sunbeam.container_handlers as sunbeam_chandlers
-import ops_sunbeam.relation_handlers as sunbeam_rhandlers
-import ops_sunbeam.config_contexts as sunbeam_ctxts
+from typing import (
+    Callable,
+    List,
+    Mapping,
+)
 
 import charms.sunbeam_nova_compute_operator.v0.cloud_compute as cloud_compute
+import ops.framework
+import ops_sunbeam.charm as sunbeam_charm
+import ops_sunbeam.config_contexts as sunbeam_ctxts
+import ops_sunbeam.container_handlers as sunbeam_chandlers
+import ops_sunbeam.core as sunbeam_core
+import ops_sunbeam.relation_handlers as sunbeam_rhandlers
+from ops.main import (
+    main,
+)
+from ops.pebble import (
+    ExecError,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -31,23 +51,24 @@ class WSGINovaMetadataConfigContext(sunbeam_ctxts.ConfigContext):
 
     def context(self) -> dict:
         """WSGI configuration options."""
-        log_svc_name = self.charm.service_name.replace('-', '_')
+        log_svc_name = self.charm.service_name.replace("-", "_")
         return {
             "name": self.charm.service_name,
             "public_port": 8775,
             "user": self.charm.service_user,
             "group": self.charm.service_group,
-            "wsgi_admin_script": '/usr/bin/nova-metadata-wsgi',
-            "wsgi_public_script": '/usr/bin/nova-metadata-wsgi',
+            "wsgi_admin_script": "/usr/bin/nova-metadata-wsgi",
+            "wsgi_public_script": "/usr/bin/nova-metadata-wsgi",
             "error_log": f"/var/log/apache2/{log_svc_name}_error.log",
             "custom_log": f"/var/log/apache2/{log_svc_name}_access.log",
         }
 
 
 class NovaSchedulerPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
+    """Pebble handler for Nova scheduler."""
 
     def get_layer(self) -> dict:
-        """Nova Scheduler service
+        """Nova Scheduler service layer.
 
         :returns: pebble layer configuration for scheduler service
         :rtype: dict
@@ -60,9 +81,9 @@ class NovaSchedulerPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
                     "override": "replace",
                     "summary": "Nova Scheduler",
                     "command": "nova-scheduler",
-                    "startup": "enabled"
+                    "startup": "enabled",
                 }
-            }
+            },
         }
 
     def get_healthcheck_layer(self) -> dict:
@@ -76,25 +97,27 @@ class NovaSchedulerPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
                 "online": {
                     "override": "replace",
                     "level": "ready",
-                    "exec": {
-                        "command": "service nova-scheduler status"
-                    }
+                    "exec": {"command": "service nova-scheduler status"},
                 },
             }
         }
 
-    def default_container_configs(self):
+    def default_container_configs(
+        self,
+    ) -> List[sunbeam_core.ContainerConfigFile]:
+        """Container configurations for handler."""
         return [
             sunbeam_core.ContainerConfigFile(
-                '/etc/nova/nova.conf',
-                'nova',
-                'nova')]
+                "/etc/nova/nova.conf", "nova", "nova"
+            )
+        ]
 
 
 class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
+    """Pebble handler for Nova Conductor container."""
 
     def get_layer(self):
-        """Nova Conductor service
+        """Nova Conductor service.
 
         :returns: pebble service layer configuration for conductor service
         :rtype: dict
@@ -107,9 +130,9 @@ class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
                     "override": "replace",
                     "summary": "Nova Conductor",
                     "command": "nova-conductor",
-                    "startup": "enabled"
+                    "startup": "enabled",
                 }
-            }
+            },
         }
 
     def get_healthcheck_layer(self) -> dict:
@@ -122,19 +145,20 @@ class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
                 "online": {
                     "override": "replace",
                     "level": "ready",
-                    "exec": {
-                        "command": "service nova-conductor status"
-                    }
+                    "exec": {"command": "service nova-conductor status"},
                 },
             }
         }
 
-    def default_container_configs(self):
+    def default_container_configs(
+        self,
+    ) -> List[sunbeam_core.ContainerConfigFile]:
+        """Container configurations for handler."""
         return [
             sunbeam_core.ContainerConfigFile(
-                '/etc/nova/nova.conf',
-                'nova',
-                'nova')]
+                "/etc/nova/nova.conf", "nova", "nova"
+            )
+        ]
 
 
 class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
@@ -148,7 +172,9 @@ class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
         callback_f: Callable,
         mandatory: bool = False,
     ):
-        """Creates a new CloudComputeRequiresHandler that handles initial
+        """Constructor for CloudComputeRequiresHandler.
+
+        Creates a new CloudComputeRequiresHandler that handles initial
         events from the relation and invokes the provided callbacks based on
         the event raised.
 
@@ -175,11 +201,11 @@ class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
         )
         self.framework.observe(
             compute_service.on.compute_nodes_connected,
-            self._compute_nodes_connected
+            self._compute_nodes_connected,
         )
         self.framework.observe(
             compute_service.on.compute_nodes_ready,
-            self._compute_nodes_connected
+            self._compute_nodes_connected,
         )
         return compute_service
 
@@ -191,6 +217,7 @@ class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
 
     @property
     def ready(self) -> bool:
+        """Interface ready for use."""
         return True
 
 
@@ -199,20 +226,21 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
 
     _state = ops.framework.StoredState()
     service_name = "nova-api"
-    wsgi_admin_script = '/usr/bin/nova-api-wsgi'
-    wsgi_public_script = '/usr/bin/nova-api-wsgi'
-    shared_metadata_secret_key = 'shared-metadata-secret'
+    wsgi_admin_script = "/usr/bin/nova-api-wsgi"
+    wsgi_public_script = "/usr/bin/nova-api-wsgi"
+    shared_metadata_secret_key = "shared-metadata-secret"
     mandatory_relations = {
-        'database',
-        'api-database',
-        'cell-database',
-        'amqp',
-        'identity-service',
-        'ingress-public',
+        "database",
+        "api-database",
+        "cell-database",
+        "amqp",
+        "identity-service",
+        "ingress-public",
     }
 
     @property
     def db_sync_cmds(self) -> List[List[str]]:
+        """DB sync commands for Nova operator."""
         # we must provide the database connection for the cell database,
         # because the database credentials are different to the main database.
         # If we don't provide them:
@@ -222,14 +250,29 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
         # https://docs.openstack.org/nova/yoga/admin/cells.html#configuring-a-new-deployment
         cell_database = self.dbs["cell-database"].context()["connection"]
         return [
-            ['sudo', '-u', 'nova', 'nova-manage', 'api_db', 'sync'],
+            ["sudo", "-u", "nova", "nova-manage", "api_db", "sync"],
             [
-                'sudo', '-u', 'nova', 'nova-manage', 'cell_v2', 'map_cell0',
-                '--database_connection', cell_database
+                "sudo",
+                "-u",
+                "nova",
+                "nova-manage",
+                "cell_v2",
+                "map_cell0",
+                "--database_connection",
+                cell_database,
+            ],
+            ["sudo", "-u", "nova", "nova-manage", "db", "sync"],
+            [
+                "sudo",
+                "-u",
+                "nova",
+                "nova-manage",
+                "cell_v2",
+                "create_cell",
+                "--name",
+                "cell1",
+                "--verbose",
             ],
-            ['sudo', '-u', 'nova', 'nova-manage', 'db', 'sync'],
-            ['sudo', '-u', 'nova', 'nova-manage', 'cell_v2', 'create_cell',
-             '--name', 'cell1', '--verbose'],
         ]
 
     @property
@@ -240,26 +283,30 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
     @property
     def service_user(self) -> str:
         """Service user file and directory ownership."""
-        return 'nova'
+        return "nova"
 
     @property
     def service_group(self) -> str:
         """Service group file and directory ownership."""
-        return 'nova'
+        return "nova"
 
     @property
     def service_endpoints(self):
+        """Service endpoints for Nova."""
         return [
             {
-                'service_name': 'nova',
-                'type': 'compute',
-                'description': "OpenStack Compute",
-                'internal_url': f'{self.internal_url}/v2.1',
-                'public_url': f'{self.public_url}/v2.1',
-                'admin_url': f'{self.admin_url}/v2.1'}]
+                "service_name": "nova",
+                "type": "compute",
+                "description": "OpenStack Compute",
+                "internal_url": f"{self.internal_url}/v2.1",
+                "public_url": f"{self.public_url}/v2.1",
+                "admin_url": f"{self.admin_url}/v2.1",
+            }
+        ]
 
     @property
     def default_public_ingress_port(self):
+        """Default port for service ingress."""
         return 8774
 
     @property
@@ -275,41 +322,43 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
             "cell-database": "nova_cell0",
         }
 
-    def get_pebble_handlers(self):
+    def get_pebble_handlers(
+        self,
+    ) -> List[sunbeam_chandlers.ServicePebbleHandler]:
+        """Pebble handlers for operator."""
         pebble_handlers = super().get_pebble_handlers()
-        pebble_handlers.extend([
-            NovaSchedulerPebbleHandler(
-                self,
-                NOVA_SCHEDULER_CONTAINER,
-                'nova-scheduler',
-                [],
-                self.template_dir,
-                self.openstack_release,
-                self.configure_charm),
-            NovaConductorPebbleHandler(
-                self,
-                NOVA_CONDUCTOR_CONTAINER,
-                'nova-conductor',
-                [],
-                self.template_dir,
-                self.openstack_release,
-                self.configure_charm)])
+        pebble_handlers.extend(
+            [
+                NovaSchedulerPebbleHandler(
+                    self,
+                    NOVA_SCHEDULER_CONTAINER,
+                    "nova-scheduler",
+                    [],
+                    self.template_dir,
+                    self.configure_charm,
+                ),
+                NovaConductorPebbleHandler(
+                    self,
+                    NOVA_CONDUCTOR_CONTAINER,
+                    "nova-conductor",
+                    [],
+                    self.template_dir,
+                    self.configure_charm,
+                ),
+            ]
+        )
         return pebble_handlers
 
     def get_relation_handlers(
         self, handlers: List[sunbeam_rhandlers.RelationHandler] = None
     ) -> List[sunbeam_rhandlers.RelationHandler]:
-        """
-
-        :param handlers:
-        :return:
-        """
+        """Relation handlers for operator."""
         handlers = super().get_relation_handlers(handlers or [])
         if self.can_add_handler("cloud-compute", handlers):
             self.compute_nodes = CloudComputeRequiresHandler(
                 self,
-                'cloud-compute',
-                self.model.config['region'],
+                "cloud-compute",
+                self.model.config["region"],
                 self.register_compute_nodes,
             )
             handlers.append(self.compute_nodes)
@@ -322,7 +371,8 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
         _cadapters.extend(
             [
                 WSGINovaMetadataConfigContext(
-                    self, 'wsgi_nova_metadata',
+                    self,
+                    "wsgi_nova_metadata",
                 )
             ]
         )
@@ -334,8 +384,7 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
 
     def set_shared_metadatasecret(self):
         """Store the shared metadata secret."""
-        self.leader_set(
-            {self.shared_metadata_secret_key: str(uuid.uuid1())})
+        self.leader_set({self.shared_metadata_secret_key: str(uuid.uuid1())})
 
     def register_compute_nodes(self, event: ops.framework.EventBase) -> None:
         """Register compute nodes when the event is received.
@@ -363,27 +412,33 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
         #     return
 
         self.compute_nodes.interface.set_controller_info(
-            region=self.model.config['region'],
+            region=self.model.config["region"],
             cross_az_attach=False,
         )
 
         try:
-            logger.debug('Discovering hosts for cell1')
-            cell1_uuid = self.get_cell_uuid('cell1')
-            cmd = ['nova-manage', 'cell_v2', 'discover_hosts', '--cell_uuid',
-                   cell1_uuid, '--verbose']
+            logger.debug("Discovering hosts for cell1")
+            cell1_uuid = self.get_cell_uuid("cell1")
+            cmd = [
+                "nova-manage",
+                "cell_v2",
+                "discover_hosts",
+                "--cell_uuid",
+                cell1_uuid,
+                "--verbose",
+            ]
             handler.execute(cmd, exception_on_error=True)
         except ExecError:
-            logger.exception('Failed to discover hosts for cell1')
+            logger.exception("Failed to discover hosts for cell1")
             raise
 
     def get_cell_uuid(self, cell, fatal=True):
-        """Returns the cell UUID from the name
+        """Returns the cell UUID from the name.
 
         :param cell: string cell name i.e. 'cell1'
         :returns: string cell uuid
         """
-        logger.debug(f'listing cells for {cell}')
+        logger.debug(f"listing cells for {cell}")
         cells = self.get_cells()
         cell_info = cells.get(cell)
         if not cell_info:
@@ -391,7 +446,7 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
                 raise Exception(f"Cell {cell} not found")
             return None
 
-        return cell_info['uuid']
+        return cell_info["uuid"]
 
     def get_cells(self):
         """Returns the cells configured in the environment.
@@ -401,31 +456,33 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
         """
         logger.info("Getting details of cells")
         cells = {}
-        cmd = ['sudo', 'nova-manage', 'cell_v2', 'list_cells', '--verbose']
+        cmd = ["sudo", "nova-manage", "cell_v2", "list_cells", "--verbose"]
         handler = self.get_named_pebble_handler(NOVA_CONDUCTOR_CONTAINER)
         try:
             out = handler.execute(cmd, exception_on_error=True)
         except ExecError:
-            logger.exception('list_cells failed')
+            logger.exception("list_cells failed")
             raise
 
-        for line in out.split('\n'):
-            columns = line.split('|')
+        for line in out.split("\n"):
+            columns = line.split("|")
             if len(columns) < 2:
                 continue
             columns = [c.strip() for c in columns]
             try:
                 uuid.UUID(columns[2].strip())
                 cells[columns[1]] = {
-                    'uuid': columns[2],
-                    'amqp': columns[3],
-                    'db': columns[4]}
+                    "uuid": columns[2],
+                    "amqp": columns[3],
+                    "db": columns[4],
+                }
             except ValueError:
                 pass
 
         return cells
 
     def configure_charm(self, event: ops.framework.EventBase) -> None:
+        """Callback handler for nova operator configuration."""
         if not self.peers.ready:
             return
         metadata_secret = self.get_shared_metadatasecret()
@@ -441,12 +498,7 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
         super().configure_charm(event)
 
 
-class NovaXenaOperatorCharm(NovaOperatorCharm):
-
-    openstack_release = 'xena'
-
-
 if __name__ == "__main__":
     # Note: use_juju_for_storage=True required per
     # https://github.com/canonical/operator/issues/506
-    main(NovaXenaOperatorCharm, use_juju_for_storage=True)
+    main(NovaOperatorCharm, use_juju_for_storage=True)
diff --git a/charms/nova-k8s/src/templates/parts/section-database b/charms/nova-k8s/src/templates/parts/section-database
deleted file mode 100644
index eb52f65e..00000000
--- a/charms/nova-k8s/src/templates/parts/section-database
+++ /dev/null
@@ -1,7 +0,0 @@
-[database]
-{% if database.connection -%}
-connection = {{ database.connection }}
-{% else -%}
-connection = sqlite:////var/lib/cinder/cinder.db
-{% endif -%}
-connection_recycle_time = 200
diff --git a/charms/nova-k8s/src/templates/parts/section-federation b/charms/nova-k8s/src/templates/parts/section-federation
deleted file mode 100644
index 65ee99ed..00000000
--- a/charms/nova-k8s/src/templates/parts/section-federation
+++ /dev/null
@@ -1,10 +0,0 @@
-{% if trusted_dashboards %}
-[federation]
-{% for dashboard_url in trusted_dashboards -%}
-trusted_dashboard = {{ dashboard_url }}
-{% endfor -%}
-{% endif %}
-{% for sp in fid_sps -%}
-[{{ sp['protocol-name'] }}]
-remote_id_attribute = {{ sp['remote-id-attribute'] }}
-{% endfor -%}
diff --git a/charms/nova-k8s/src/templates/parts/section-middleware b/charms/nova-k8s/src/templates/parts/section-middleware
deleted file mode 100644
index e65f1d98..00000000
--- a/charms/nova-k8s/src/templates/parts/section-middleware
+++ /dev/null
@@ -1,6 +0,0 @@
-{% for section in sections -%}
-[{{section}}]
-{% for key, value in sections[section].items() -%}
-{{ key }} = {{ value }}
-{% endfor %}
-{%- endfor %}
diff --git a/charms/nova-k8s/src/templates/parts/section-signing b/charms/nova-k8s/src/templates/parts/section-signing
deleted file mode 100644
index cb7d69ae..00000000
--- a/charms/nova-k8s/src/templates/parts/section-signing
+++ /dev/null
@@ -1,15 +0,0 @@
-{% if enable_signing -%}
-[signing]
-{% if certfile -%}
-certfile = {{ certfile }}
-{% endif -%}
-{% if keyfile -%}
-keyfile = {{ keyfile }}
-{% endif -%}
-{% if ca_certs -%}
-ca_certs = {{ ca_certs }}
-{% endif -%}
-{% if ca_key -%}
-ca_key = {{ ca_key }}
-{% endif -%}
-{% endif -%}
\ No newline at end of file
diff --git a/charms/nova-k8s/tests/unit/__init__.py b/charms/nova-k8s/tests/unit/__init__.py
index e69de29b..ac683aca 100644
--- a/charms/nova-k8s/tests/unit/__init__.py
+++ b/charms/nova-k8s/tests/unit/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2022 Canonical Ltd.
+#
+# Licensed 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.
+
+"""Unit tests for Nova operator."""
diff --git a/charms/nova-k8s/tests/unit/test_nova_charm.py b/charms/nova-k8s/tests/unit/test_nova_charm.py
index b8ea2db8..ac7583f4 100644
--- a/charms/nova-k8s/tests/unit/test_nova_charm.py
+++ b/charms/nova-k8s/tests/unit/test_nova_charm.py
@@ -14,13 +14,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import mock
+"""Unit tests for Nova operator."""
 
-import charm
+import mock
 import ops_sunbeam.test_utils as test_utils
 
+import charm
 
-class _NovaXenaOperatorCharm(charm.NovaXenaOperatorCharm):
+
+class _NovaTestOperatorCharm(charm.NovaOperatorCharm):
+    """Test Operator Charm for Nova Operator."""
 
     def __init__(self, framework):
         self.seen_events = []
@@ -39,24 +42,28 @@ class _NovaXenaOperatorCharm(charm.NovaXenaOperatorCharm):
 
 
 class TestNovaOperatorCharm(test_utils.CharmTestCase):
+    """Unit tests for Nova Operator."""
 
     PATCHES = []
 
     @mock.patch(
-        'charms.observability_libs.v0.kubernetes_service_patch.'
-        'KubernetesServicePatch')
+        "charms.observability_libs.v0.kubernetes_service_patch."
+        "KubernetesServicePatch"
+    )
     def setUp(self, mock_patch):
+        """Setup environment for unit test."""
         super().setUp(charm, self.PATCHES)
         self.harness = test_utils.get_harness(
-            _NovaXenaOperatorCharm,
-            container_calls=self.container_calls)
+            _NovaTestOperatorCharm, container_calls=self.container_calls
+        )
 
         # clean up events that were dynamically defined,
         # otherwise we get issues because they'll be redefined,
         # which is not allowed.
         from charms.data_platform_libs.v0.database_requires import (
-            DatabaseEvents
+            DatabaseEvents,
         )
+
         for attr in (
             "database_database_created",
             "database_endpoints_changed",
@@ -77,11 +84,13 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
         self.harness.begin()
 
     def test_pebble_ready_handler(self):
+        """Test pebble ready handler."""
         self.assertEqual(self.harness.charm.seen_events, [])
         test_utils.set_all_pebbles_ready(self.harness)
         self.assertEqual(len(self.harness.charm.seen_events), 3)
 
     def test_all_relations(self):
+        """Test all integrations for operator."""
         self.harness.set_leader()
         test_utils.set_all_pebbles_ready(self.harness)
         # this adds all the default/common relations
@@ -95,25 +104,40 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
         test_utils.add_db_relation_credentials(self.harness, rel_id)
 
         setup_cmds = [
-            ['a2ensite', 'wsgi-nova-api'],
-            ['sudo', '-u', 'nova', 'nova-manage', 'api_db', 'sync'],
+            ["a2ensite", "wsgi-nova-api"],
+            ["sudo", "-u", "nova", "nova-manage", "api_db", "sync"],
             [
-                'sudo', '-u', 'nova', 'nova-manage', 'cell_v2', 'map_cell0',
-                '--database_connection',
+                "sudo",
+                "-u",
+                "nova",
+                "nova-manage",
+                "cell_v2",
+                "map_cell0",
+                "--database_connection",
                 # values originate in test_utils.add_db_relation_credentials()
-                'mysql+pymysql://foo:hardpassword@10.0.0.10/nova_cell0'
+                "mysql+pymysql://foo:hardpassword@10.0.0.10/nova_cell0",
+            ],
+            ["sudo", "-u", "nova", "nova-manage", "db", "sync"],
+            [
+                "sudo",
+                "-u",
+                "nova",
+                "nova-manage",
+                "cell_v2",
+                "create_cell",
+                "--name",
+                "cell1",
+                "--verbose",
             ],
-            ['sudo', '-u', 'nova', 'nova-manage', 'db', 'sync'],
-            ['sudo', '-u', 'nova', 'nova-manage', 'cell_v2', 'create_cell',
-             '--name', 'cell1', '--verbose'],
         ]
         for cmd in setup_cmds:
-            self.assertIn(cmd, self.container_calls.execute['nova-api'])
+            self.assertIn(cmd, self.container_calls.execute["nova-api"])
         config_files = [
-            '/etc/apache2/sites-available/wsgi-nova-api.conf',
-            '/etc/nova/nova.conf']
+            "/etc/apache2/sites-available/wsgi-nova-api.conf",
+            "/etc/nova/nova.conf",
+        ]
         for f in config_files:
-            self.check_file('nova-api', f)
+            self.check_file("nova-api", f)
 
 
 def add_db_relation(harness, name) -> str:
diff --git a/charms/nova-k8s/tox.ini b/charms/nova-k8s/tox.ini
index cfa25bf3..ea682f9a 100644
--- a/charms/nova-k8s/tox.ini
+++ b/charms/nova-k8s/tox.ini
@@ -15,6 +15,8 @@ minversion = 3.18.0
 src_path = {toxinidir}/src/
 tst_path = {toxinidir}/tests/
 lib_path = {toxinidir}/lib/
+pyproject_toml = {toxinidir}/pyproject.toml
+all_path = {[vars]src_path} {[vars]tst_path}
 
 [testenv]
 basepython = python3
@@ -33,6 +35,15 @@ allowlist_externals =
 deps =
   -r{toxinidir}/test-requirements.txt
 
+[testenv:fmt]
+description = Apply coding style standards to code
+deps =
+    black
+    isort
+commands =
+    isort {[vars]all_path} --skip-glob {[vars]lib_path} --skip {toxinidir}/.tox
+    black --config {[vars]pyproject_toml} {[vars]all_path} --exclude {[vars]lib_path}
+
 [testenv:build]
 basepython = python3
 deps =
@@ -64,11 +75,6 @@ deps = {[testenv:py3]deps}
 basepython = python3.10
 deps = {[testenv:py3]deps}
 
-[testenv:pep8]
-basepython = python3
-deps = {[testenv]deps}
-commands = flake8 {posargs} {[vars]src_path} {[vars]tst_path}
-
 [testenv:cover]
 basepython = python3
 deps = {[testenv:py3]deps}
@@ -83,6 +89,31 @@ commands =
     coverage xml -o cover/coverage.xml
     coverage report
 
+[testenv:pep8]
+description = Alias for lint
+deps = {[testenv:lint]deps}
+commands = {[testenv:lint]commands}
+
+[testenv:lint]
+description = Check code against coding style standards
+deps =
+    black
+    # flake8==4.0.1 # Pin version until https://github.com/csachs/pyproject-flake8/pull/14 is merged
+    flake8
+    flake8-docstrings
+    flake8-copyright
+    flake8-builtins
+    pyproject-flake8
+    pep8-naming
+    isort
+    codespell
+commands =
+    codespell {[vars]all_path} 
+    # pflake8 wrapper supports config from pyproject.toml
+    pflake8 --exclude {[vars]lib_path} --config {toxinidir}/pyproject.toml {[vars]all_path}
+    isort --check-only --diff {[vars]all_path} --skip-glob {[vars]lib_path}
+    black  --config {[vars]pyproject_toml} --check --diff {[vars]all_path} --exclude {[vars]lib_path}
+
 [testenv:func-noop]
 basepython = python3
 commands =