Skip to content

Commit

Permalink
Replace bespoke plugin handling with stevedore
Browse files Browse the repository at this point in the history
Instead of using a custom-made plugin loader, use the more powerful and flexible
stevedore library.

Also, utilize the factory pattern to handle field data generation. This refactor
also addresses a shortcoming in the code it replaces wherein there was a hard-
coded assumption that a single plugin would provide field configuration for BOTH
jira and bugzilla. The new code supports separate plugins for each.

Note that even though the FieldDataGeneratorFactory will create a separate plugin
for each backend type, both plugins get their data from the same
contrib/sample_fields.json file. This is unlikely to be the approach in any
production use of bugjira; users will instead want to implement their own plugins
and "advertise" them to bugjira using the stevedore entry points approach.
  • Loading branch information
eggmaster authored and jpichon committed Nov 9, 2023
1 parent 33fb6d9 commit 646823a
Show file tree
Hide file tree
Showing 20 changed files with 585 additions and 437 deletions.
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@ Bugjira is an abstraction layer library for interacting with either bugzilla or

Bugjira users can perform common operations (lookup, query, modify, etc) on both bugs (bugzilla) and issues (jira) using the Bugjira object.

Configuration is provided with either a dict (see below) or a pathname to a config file containing a json dict (a sample config file is included in `contrib/bugjira.json`):
Configuration is provided with either a dict (see below) or a pathname to a bugjira config file containing a json dict (a sample config file is included in `contrib/bugjira.json`).

At a minimum, you will need to set the "field_data_path" config entry to point to a file containing valid field configuration information. See the [Field Configuration](#field-configuration) section below for more details. Once you have done that, you can create a bugjira instance like so:

```python
from bugjira.bugjira import Bugjira
config = {
"bugzilla": {
"URL": "https://bugzilla.yourdomain.com",
"api_key": "your_bugzilla_api_key"},
"URL": "https://bugzilla.redhat.com",
"api_key": "your_api_key_here",
"field_data_plugin_name": "default_bugzilla_field_data_plugin"
},
"jira": {
"URL": "https://jira.yourdomain.com",
"token_auth": "your_jira_personal_access_token"}
}
"URL": "https://issues.redhat.com",
"token_auth": "your_personal_auth_token_here",
"field_data_plugin_name": "default_jira_field_data_plugin"
},
"field_data_path": "/path/to/contrib/sample_fields.json"
}


bugjira_api = Bugjira(config_dict=config)
```
Expand Down Expand Up @@ -55,8 +63,8 @@ Users of the Bugjira library will be able to read and write field contents from

Bugzilla's issue (bug) attributes are relatively static, and they are named and accessed in a straightforward way. JIRA's issue attributes, in contrast, consist of a pre-defined set of attributes (e.g. "issuetype", "status", "assignee") and an arbitrarily large set of custom fields (with identifiers like "customfield_12345678"). Furthermore, the JIRA api requires multiple requests to obtain the necessary metadata to use custom fields.

Since both Bugzilla and JIRA allow field customization, and since it is cumbersome to obtain JIRA custom field metadata dynamically, the Bugjira library will rely on user-supplied configuration information to determine what fields are supported by the user's JIRA and Bugzilla instances. The data Bugjira requires to define fields is specified by the `BugzillaField` and `JiraField` classes in the `bugjira.field` module. Internally, Bugjira uses the `bugjira.field_factory` module as its source of field information.
Since both Bugzilla and JIRA allow field customization, and since it is cumbersome to obtain JIRA custom field metadata dynamically, the Bugjira library will rely on user-supplied configuration information to determine what fields are supported by the user's JIRA and Bugzilla instances. The data Bugjira requires to define fields is specified by the `BugzillaField` and `JiraField` classes in the `bugjira.field` module.

The `field_factory` module generates `BugzillaField` and `JiraField` objects using json retrieved from a plugin module loaded at runtime. The default plugin is defined by the included `bugjira.json_generator` module, which is specified in the config dict under the "json_generator_module" key. This module defines a class called `JsonGenerator` whose `get_bugzilla_field_json` and `get_jira_field_json` instance methods return json field information. The `bugjira.field_factory` module consumes that json to create lists of `BugzillaField` and `JiraField` objects.
Bugjira obtains its field configuration data from plugins which it loads using [stevedore](https://docs.openstack.org/stevedore/latest/). The plugins are defined in the stevedore `bugjira.field_data.plugins` namespace. The names of the plugins provided with the bugjira source code are referenced in the provided `config/bugjira.json` sample config under the `bugzilla.field_data_plugin_name` and `jira.field_data_plugin_name` attributes. To replace one of the default provided plugins, your plugin should implement the `bugjira.field_data_generator.FieldDataGeneratorInterface` interface and "advertise" itself in the `bugjira.field_data.plugins` namespace, and you should edit bugjira's sample config to indicate the names of the replacement plugins.

The `bugjira.json_generator.JsonGenerator` class loads its json data from a file whose path is (optionally) specified in the config dict under the "field_data_file_path" key. A sample file is provided in `contrib/sample_fields.json`. The field information in this file is not intended to be comprehensive; if you use the default `bugjira.json_generator` plugin, we encourage you to edit the sample fields file to support your JIRA instance and intended use cases.
The default field data generation plugin class loads data from a file whose path is specified in the config dict under the "field_data_path" key. A sample file is provided in `contrib/sample_fields.json`. The field information in this file is not intended to be comprehensive; if you use the default field data generation plugin, you should edit the sample fields file to support your JIRA and Bugzilla instances and your intended use cases.
7 changes: 4 additions & 3 deletions contrib/bugjira.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"bugzilla": {
"URL": "https://bugzilla.redhat.com",
"api_key": "your_api_key_here"
"api_key": "your_api_key_here",
"field_data_plugin_name": "default_bugzilla_field_data_plugin"
},
"jira": {
"URL": "https://issues.redhat.com",
"token_auth": "your_personal_auth_token_here"
"token_auth": "your_personal_auth_token_here",
"field_data_plugin_name": "default_jira_field_data_plugin"
},
"json_generator_module": "bugjira.json_generator",
"field_data_path": "/path/to/contrib/sample_fields.json"
}
6 changes: 6 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ install_requires =
pydantic
python-bugzilla
jira
stevedore

[options.entry_points]
bugjira.field_data.plugins =
default_bugzilla_field_data_plugin = bugjira.field_data_generator:BugzillaFieldDataGenerator
default_jira_field_data_plugin = bugjira.field_data_generator:JiraFieldDataGenerator

[options.extras_require]
devbase =
Expand Down
15 changes: 0 additions & 15 deletions src/bugjira/bugjira.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from bugjira import plugin_loader
from bugjira.broker import BugzillaBroker, JiraBroker
from bugjira.config import Config
from bugjira.exceptions import BrokerInitException, JsonGeneratorException
from bugjira.issue import Issue
from bugjira.util import is_bugzilla_key, is_jira_key

Expand Down Expand Up @@ -35,19 +33,6 @@ def __init__(
elif config_path:
self.config = Config.from_config(config_path=config_path)

try:
plugin_loader.load_plugin(self.config)
except IOError as io_error:
raise BrokerInitException(
"An IOError was raised when loading the field data "
"generator plugin."
) from io_error
except JsonGeneratorException as generator_error:
raise BrokerInitException(
"The field data json generator encountered a problem. Please "
"check the field data source."
) from generator_error

self._bugzilla_broker = BugzillaBroker(
config=self.config, backend=bugzilla
)
Expand Down
3 changes: 3 additions & 0 deletions src/bugjira/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
JIRA = "jira"
BUGZILLA = "bugzilla"
PLUGIN_NAMESPACE = "bugjira.field_data.plugins"
5 changes: 4 additions & 1 deletion src/bugjira/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,24 @@ class BugzillaConfig(BaseModel):

URL: constr(strip_whitespace=True, min_length=1)
api_key: constr(strip_whitespace=True, min_length=1)
field_data_plugin_name: constr(strip_whitespace=True, min_length=1)


class JiraConfig(BaseModel):
model_config = ConfigDict(extra='forbid')

URL: constr(strip_whitespace=True, min_length=1)
token_auth: constr(strip_whitespace=True, min_length=1)
field_data_plugin_name: constr(strip_whitespace=True, min_length=1)


class BugjiraConfigDict(BaseModel):
model_config = ConfigDict(extra='forbid')

bugzilla: BugzillaConfig
jira: JiraConfig
json_generator_module: constr(strip_whitespace=True, min_length=1)
# The field_data_path attribute is optional since it is only used by
# the default field data generator plugin.
field_data_path: str = None


Expand Down
2 changes: 1 addition & 1 deletion src/bugjira/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class BrokerLookupException(BrokerException):
pass


class JsonGeneratorException(Exception):
class FieldDataGeneratorException(Exception):
pass


Expand Down
165 changes: 165 additions & 0 deletions src/bugjira/field_data_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import abc
import json
from typing import List

from pydantic import BaseModel, ConfigDict, ValidationError
from stevedore import driver

from bugjira.common import BUGZILLA, JIRA, PLUGIN_NAMESPACE
from bugjira.exceptions import FieldDataGeneratorException
from bugjira.field import BugzillaField, JiraField


class ValidFieldData(BaseModel):
"""This class defines the valid format for the json data loaded by the
FieldDataGenerator classes in this module
"""
model_config = ConfigDict(extra="forbid")
bugzilla_field_data: List[BugzillaField]
jira_field_data: List[JiraField]


class FieldDataGeneratorInterface(metaclass=abc.ABCMeta):
"""This abstract base class represents the interface that
FieldDataGenerator classes should implement. Plugin classes for generating
field data should implement the get_field_data method.
"""

@abc.abstractmethod
def __init__(self, config):
"""Init method"""

@abc.abstractmethod
def get_field_data(self) -> List:
"""Return the field data as an Iterable
:return: List of raw field data to be used to instantiate subclasses of
the BugjiraField class
:rtype: List
"""


class FieldDataGenerator(FieldDataGeneratorInterface):
"""This is the superclass for the default/provided FieldDataGenerator
plugin classes, BugzillaFieldDataGenerator and JiraFieldDataGenerator.
Both subclasses use the same sample json field data file, so we load that
data in the superclass's __init__ method.
"""

def __init__(self, config):
self.field_data = {}
if config:
field_data_path = config.get("field_data_path", "")
if field_data_path:
with open(field_data_path, "r") as file:
field_data = json.load(file)
try:
# use pydantic class to validate the input data
ValidFieldData(**field_data)
except ValidationError as ve:
raise FieldDataGeneratorException(
"Invalid field data detected"
) from ve
self.field_data = field_data

def get_field_data(self) -> List:
# override in subclasses
pass


class BugzillaFieldDataGenerator(FieldDataGenerator):
"""This is the default plugin class for generating field data that can be
used to instantiate BugzillaField objects
"""

def get_field_data(self) -> List:
return self.field_data.get("bugzilla_field_data", [])


class JiraFieldDataGenerator(FieldDataGenerator):
"""This is the default plugin class for generating field data that can be
used to instantiate JiraField objects
"""

def get_field_data(self) -> List:
return self.field_data.get("jira_field_data", [])


class FieldDataGeneratorFactory:
"""Factory class that returns the FieldDataGenerator associated with the
input generator_type. Uses stevedore's DriverManager to load an instance of
the correct plugin class. The DriverManager looks in the namespace defined
in common.PLUGIN_NAMESPACE for the plugin named in the bugjira config file.
"""

def __init__(self):
self.field_data_generators = {}

def get_field_data_generator(self,
generator_type,
config) -> FieldDataGenerator:
"""Returns the FieldDataGenerator associated with the input
generator_type. Uses the _get_field_data_plugin_instance to get an
instance if it is not already present in the field_data_generators
dict.
:param generator_type: A generator type
:type generator_type: str
:param config: A valid bugjira config dict
:type config: dict
:return: The FieldDataGenerator associated with the generator type
:rtype: FieldDataGenerator
"""
generator = self.field_data_generators.get(generator_type, None)
if not generator:
generator = self._get_field_data_plugin_instance(generator_type,
config)
self.field_data_generators[generator_type] = generator
return generator

def _get_field_data_plugin_instance(self,
generator_type,
config) -> FieldDataGeneratorInterface:
"""Return an instance implementing the FieldDataGeneratorInterface that
matches the generator_type desired.
:param generator_type: The desired data generator type
:type generator_type: str
:param config: A valid bugjira config dict
:type config: dict
:return: A FieldDataGenerator instance corresponding to the generator
type
:rtype: FieldDataGeneratorInterface
"""
plugin_name = self._get_plugin_name_from_config(generator_type,
config)
# Use stevedore to load the plugin class and instantiate it using
# the supplied config dict
dm = driver.DriverManager(namespace=PLUGIN_NAMESPACE,
name=plugin_name,
invoke_on_load=True,
invoke_args=(config,))
return dm.driver

def _get_plugin_name_from_config(self, generator_type, config) -> str:
"""Return the plugin name defined in the supplied config that
corresponds to the desired generator type.
:param generator_type: The desired generator type
:type generator_type: str
:param config: A valid bugjira config dict
:type config: dict
:raises ValueError: Raised in an invalid generator type is supplied
:return: The name of the plugin defined the config file for the given
generator type
:rtype: str
"""
if generator_type == BUGZILLA:
return config.get("bugzilla").get("field_data_plugin_name")
elif generator_type == JIRA:
return config.get("jira").get("field_data_plugin_name")
else:
raise ValueError(generator_type)


factory = FieldDataGeneratorFactory()
32 changes: 0 additions & 32 deletions src/bugjira/field_factory.py

This file was deleted.

Loading

0 comments on commit 646823a

Please sign in to comment.