diff --git a/README.md b/README.md index dc23adb..eca3ae7 100644 --- a/README.md +++ b/README.md @@ -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) ``` @@ -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. diff --git a/contrib/bugjira.json b/contrib/bugjira.json index 4d9c293..7b0a2a7 100644 --- a/contrib/bugjira.json +++ b/contrib/bugjira.json @@ -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" } diff --git a/setup.cfg b/setup.cfg index bfc42c5..2beebc8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 = diff --git a/src/bugjira/bugjira.py b/src/bugjira/bugjira.py index 456e630..c2132c2 100644 --- a/src/bugjira/bugjira.py +++ b/src/bugjira/bugjira.py @@ -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 @@ -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 ) diff --git a/src/bugjira/common.py b/src/bugjira/common.py new file mode 100644 index 0000000..78af0c6 --- /dev/null +++ b/src/bugjira/common.py @@ -0,0 +1,3 @@ +JIRA = "jira" +BUGZILLA = "bugzilla" +PLUGIN_NAMESPACE = "bugjira.field_data.plugins" diff --git a/src/bugjira/config.py b/src/bugjira/config.py index 5f8543c..5688b96 100644 --- a/src/bugjira/config.py +++ b/src/bugjira/config.py @@ -8,6 +8,7 @@ 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): @@ -15,6 +16,7 @@ class JiraConfig(BaseModel): 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): @@ -22,7 +24,8 @@ class BugjiraConfigDict(BaseModel): 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 diff --git a/src/bugjira/exceptions.py b/src/bugjira/exceptions.py index 4d1d13f..7f4cae0 100644 --- a/src/bugjira/exceptions.py +++ b/src/bugjira/exceptions.py @@ -14,7 +14,7 @@ class BrokerLookupException(BrokerException): pass -class JsonGeneratorException(Exception): +class FieldDataGeneratorException(Exception): pass diff --git a/src/bugjira/field_data_generator.py b/src/bugjira/field_data_generator.py new file mode 100644 index 0000000..23ae95a --- /dev/null +++ b/src/bugjira/field_data_generator.py @@ -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() diff --git a/src/bugjira/field_factory.py b/src/bugjira/field_factory.py deleted file mode 100644 index 2040ac1..0000000 --- a/src/bugjira/field_factory.py +++ /dev/null @@ -1,32 +0,0 @@ -from bugjira.field import BugzillaField, JiraField - -_json_generator = None - - -def register_json_generator(generator, config): - """Registers a generator class to serve as the source for json that - specifies field configuration data. - - :param generator: The generator to register - :type generator: class - :param config: The config to use when instantiating the generator class - :type config: dict - """ - global _json_generator - _json_generator = generator(config=config) - - -def get_bugzilla_fields() -> list[BugzillaField]: - fields = [] - if _json_generator is not None: - for field_data in _json_generator.get_bugzilla_fields_json(): - fields.append(BugzillaField(**field_data)) - return fields - - -def get_jira_fields() -> list[JiraField]: - fields = [] - if _json_generator is not None: - for field_data in _json_generator.get_jira_fields_json(): - fields.append(JiraField(**field_data)) - return fields diff --git a/src/bugjira/field_generator.py b/src/bugjira/field_generator.py new file mode 100644 index 0000000..cb42fd8 --- /dev/null +++ b/src/bugjira/field_generator.py @@ -0,0 +1,94 @@ +from bugjira.common import BUGZILLA, JIRA +from bugjira.field import BugjiraField, BugzillaField, JiraField +from bugjira.field_data_generator import factory \ + as field_data_generator_factory + + +class FieldGenerator: + """Instances of this class obtain a FieldDataGenerator from the + field_data_generator_factory, and they use the raw field data from + the FieldDataGenerator to return a list of BugjiraField objects via the + get_fields method. + """ + + def __init__(self, generator_type, config): + """Init method + + :param generator_type: The generator type for this FieldGenerator + :type generator_type: str + :param config: a valid Bugjira config dict + :type config: dict + """ + self.config = config + self.field_data_generator = field_data_generator_factory\ + .get_field_data_generator(generator_type, config) + self.field_class = self._get_field_class(generator_type) + + def _field_data_to_class(self, field_data) -> [BugjiraField]: + """Returns a list of BugjiraField objects that correspond to the + generator type used to instantiate the class + + :param field_data: A list of dicts representing data that can be used + to instantiate BugjiraField subclasses + :type field_data: [dict] + :return: A list of BugjiraField instances + :rtype: [BugjiraField] + """ + return [self.field_class(**data) for data in field_data] + + def _get_field_class(self, generator_type) -> BugjiraField: + """Given a generator type, return the BugjiraField subclass that + corresponds to the generator type. + + :param generator_type: A valid generator type + :type generator_type: str + :raises ValueError: Raised if an invalid generator type is passed in + :return: The BugjiraField subclass corresponding to the generator type + :rtype: BugjiraField + """ + if generator_type == BUGZILLA: + return BugzillaField + elif generator_type == JIRA: + return JiraField + else: + raise ValueError(generator_type) + + def get_fields(self) -> [BugjiraField]: + """Get the data for instantiating BugzillaField or JiraField objects + from the field_data_generator plugin's get_field_data method and then + use the _field_data_to_class method to return a list of instances. + + :return: a list of instances whose superclass is BugjiraField + :rtype: [BugjiraField] + """ + field_data = self.field_data_generator.get_field_data() + return self._field_data_to_class(field_data) + + +class FieldGeneratorFactory: + """Factory class that returns the FieldGenerator associated with the input + generator_type. + """ + def __init__(self): + self._generators = {} + + def get_field_generator(self, generator_type, config) -> FieldGenerator: + """Return the FieldGenerator associated with the generator_type. + Instantiate the FieldGenerator first if it is not already in the + factory's _generators dict under the generator_type key. + + :param generator_type: The desired field generator type + :type generator_type: str + :param config: A valid bugjira config dict + :type config: dict + :return: The FieldGenerator corresponding to the generator type + :rtype: FieldGenerator + """ + generator = self._generators.get(generator_type, None) + if not generator: + generator = FieldGenerator(generator_type, config) + self._generators[generator_type] = generator + return generator + + +factory = FieldGeneratorFactory() diff --git a/src/bugjira/json_generator.py b/src/bugjira/json_generator.py deleted file mode 100644 index b50772e..0000000 --- a/src/bugjira/json_generator.py +++ /dev/null @@ -1,65 +0,0 @@ -import json -from typing import List - -from pydantic import BaseModel, ConfigDict, ValidationError - -from bugjira import field_factory -from bugjira.exceptions import JsonGeneratorException -from bugjira.field import BugzillaField, JiraField - - -def get_generator(): - """Plugin modules must define the get_generator() method, which should - return a generator class. - """ - return JsonGenerator - - -class JsonGenerator: - """This is the default plugin class that provides json to field_factory - module for generating lists of BugjiraField objects. It loads its data - from a json file, specified in the input config dict under the - "field_data_path" top level key. - - The static `register` method registers the class with the field_factory - module. - - Replacement plugin classes should implement the `get_bugzilla_fields_json` - and `get_jira_fields_json` instance methods, as well as the static - `register` method. - """ - def __init__(self, config={}): - self.config = 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: - raise JsonGeneratorException( - "Invalid field data detected" - ) - self.field_data = field_data - - def get_bugzilla_fields_json(self): - return self.field_data.get("bugzilla_field_data", []) - - def get_jira_fields_json(self): - return self.field_data.get("jira_field_data", []) - - @staticmethod - def register(config): - field_factory.register_json_generator(JsonGenerator, config) - - -class ValidFieldData(BaseModel): - """This class defines the valid format for the json data loaded by the - JsonGenerator class - """ - model_config = ConfigDict(extra="forbid") - bugzilla_field_data: List[BugzillaField] - jira_field_data: List[JiraField] diff --git a/src/bugjira/plugin_loader.py b/src/bugjira/plugin_loader.py deleted file mode 100644 index d5d7308..0000000 --- a/src/bugjira/plugin_loader.py +++ /dev/null @@ -1,38 +0,0 @@ -import importlib - -from bugjira.exceptions import PluginLoaderException - - -def import_module(name): - """Use importlib.import_module to import a plugin class - - :param name: The fully qualified module name to import - :type name: str - :return: The module returned from the importlib.import_module method - :rtype: module - """ - return importlib.import_module(name) - - -def load_plugin(config={}): - """Load a plugin using configuration in the supplied config dict. An empty - config will result in loading the default json_generator module. - - :param config: A Bugjira config dict, defaults to {}. Cannot be None. - :type config: dict, optional - """ - - if config is None: - raise PluginLoaderException("cannot load plugin with config=None") - - # default to the bugjira-supplied default json_generator module - module_name = config.get("json_generator_module", - "bugjira.json_generator") - try: - imported_module = import_module(module_name) - plugin = imported_module.get_generator() - except ModuleNotFoundError: - raise PluginLoaderException( - f"Could not load module named '{module_name}'" - ) - plugin.register(config=config) diff --git a/tests/data/config/good_config.json b/tests/data/config/good_config.json index 20507ff..81939ef 100644 --- a/tests/data/config/good_config.json +++ b/tests/data/config/good_config.json @@ -1,9 +1,13 @@ -{"bugzilla": { - "URL": "https://bugzilla.yourdomain.com", - "api_key": "your_bugzilla_api_key"}, - "jira": { - "URL": "https://jira.yourdomain.com", - "token_auth": "your_jira_personal_access_token"}, - "json_generator_module": "bugjira.json_generator", - "field_data_path": "/path/to/contrib/sample_fields.json" +{ + "bugzilla": { + "URL": "https://bugzilla.redhat.com", + "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", + "field_data_plugin_name": "default_jira_field_data_plugin" + }, + "field_data_path": "/path/to/contrib/sample_fields.json" } diff --git a/tests/data/config/missing_config.json b/tests/data/config/missing_config.json index 4c9fd3d..0abde27 100644 --- a/tests/data/config/missing_config.json +++ b/tests/data/config/missing_config.json @@ -1,8 +1,10 @@ {"bugzilla": { "URL": "", - "api_key": ""}, + "api_key": "", + "field_data_plugin_name": ""}, "jira": { "URL": "", - "token_auth": ""}, - "json_generator_module": "" + "token_auth": "", + "field_data_plugin_name": ""}, + "field_data_path": "" } diff --git a/tests/unit/test_bugjira.py b/tests/unit/test_bugjira.py index 83aa105..16f9134 100644 --- a/tests/unit/test_bugjira.py +++ b/tests/unit/test_bugjira.py @@ -1,5 +1,5 @@ from copy import deepcopy -from unittest.mock import Mock, create_autospec, patch +from unittest.mock import Mock, create_autospec from xmlrpc.client import Fault import pytest @@ -8,9 +8,8 @@ from jira.exceptions import JIRAError import bugjira.broker as broker -import bugjira.bugjira as bugjira from bugjira.exceptions import ( - BrokerLookupException, BrokerAddCommentException, JsonGeneratorException + BrokerLookupException, BrokerAddCommentException ) from bugjira.bugjira import Bugjira from bugjira.issue import Issue, BugzillaIssue, JiraIssue @@ -25,7 +24,6 @@ def setup(monkeypatch): """ monkeypatch.setattr(broker, "Bugzilla", create_autospec(Bugzilla)) monkeypatch.setattr(broker, "JIRA", create_autospec(JIRA)) - monkeypatch.setattr(bugjira, "plugin_loader", Mock()) @pytest.fixture(scope="function") @@ -85,48 +83,6 @@ def test_init_with_both_path_and_dict(good_config_file_path, good_config_dict): assert bugjira.config == edited_dict -def test_init_plugin_loader_called(good_config_file_path): - """ - GIVEN the Bugjira class' constructor - WHEN we call it with a good config path - THEN the plugin_loader.load_plugin method should be called once - """ - with patch("bugjira.bugjira.plugin_loader") as plugin_loader: - Bugjira(config_path=good_config_file_path) - assert plugin_loader.load_plugin.call_count == 1 - - -def test_init_plugin_loader_io_error(good_config_dict): - """ - GIVEN the Bugjira class' constructor - WHEN we call it with a valid config dict - AND the plugin_loader.load_plugin method raises an IOError - THEN a BrokerInitException should be raised - AND the error message should reflect that an IOError was handled - """ - - with patch("bugjira.bugjira.plugin_loader.load_plugin", - side_effect=IOError): - with pytest.raises(broker.BrokerInitException, - match="An IOError was raised"): - Bugjira(config_dict=good_config_dict) - - -def test_init_plugin_loader_json_generator_error(good_config_dict): - """ - GIVEN the Bugjira class' constructor - WHEN we call it with a valid config dict - AND the plugin_loader.load_plugin method raises a JsonGeneratorException - THEN a BrokerInitException should be raised - AND the error message should reflect that the generator had a problem - """ - with patch("bugjira.bugjira.plugin_loader.load_plugin", - side_effect=JsonGeneratorException): - with pytest.raises(broker.BrokerInitException, - match="json generator encountered a problem"): - Bugjira(config_dict=good_config_dict) - - def test_init_with_no_parameters(): """ GIVEN the Bugjira class' constructor diff --git a/tests/unit/test_field_data_generator.py b/tests/unit/test_field_data_generator.py new file mode 100644 index 0000000..1294f92 --- /dev/null +++ b/tests/unit/test_field_data_generator.py @@ -0,0 +1,107 @@ +import pytest +from unittest.mock import mock_open, patch + +from bugjira.common import BUGZILLA, JIRA +from bugjira.exceptions import FieldDataGeneratorException +from bugjira.field_data_generator import ( + FieldDataGenerator, BugzillaFieldDataGenerator, JiraFieldDataGenerator, + FieldDataGeneratorFactory +) + +EXPECTED_BZ_FIELD_COUNT = 3 +EXPECTED_JIRA_FIELD_COUNT = 2 + +GENERATOR_CLASSES = [BugzillaFieldDataGenerator, JiraFieldDataGenerator] +KEYS = [BUGZILLA, JIRA] + + +def test_init_good_config(good_config_dict, good_sample_fields_file_path): + """ + GIVEN the FieldDataGenerator init method + WHEN we call it with a config containing a valid path to a valid sample + fields file + THEN the field_data attribute should be set + """ + good_config_dict["field_data_path"] = good_sample_fields_file_path + generator = FieldDataGenerator(good_config_dict) + assert generator.field_data is not None + assert generator.field_data != {} + + +def test_init_bad_data(good_config_dict): + """ + GIVEN the FieldDataGenerator class + WHEN the data it loads fails the ValidFieldData validation + THEN a FieldDataGeneratorException is raised + """ + with patch("bugjira.field_data_generator.open", + mock_open(read_data="{}")): + with pytest.raises(FieldDataGeneratorException): + FieldDataGenerator(good_config_dict) + + +def test_bugzilla_get_field_data(good_config_dict, + good_sample_fields_file_path): + """ + GIVEN the BugzillaFieldDataGenerator class + WHEN we instantiate it with a good config with a path to a good data file + THEN the get_field_data method should return the expected number of fields + """ + good_config_dict["field_data_path"] = good_sample_fields_file_path + generator = BugzillaFieldDataGenerator(good_config_dict) + assert len(generator.get_field_data()) == EXPECTED_BZ_FIELD_COUNT + + +def test_jira_get_field_data(good_config_dict, good_sample_fields_file_path): + """ + GIVEN the JiraFieldDataGenerator class + WHEN we instantiate it with a good config with a path to a good data file + THEN the get_field_data method should return the expected number of fields + """ + good_config_dict["field_data_path"] = good_sample_fields_file_path + generator = JiraFieldDataGenerator(good_config_dict) + assert len(generator.get_field_data()) == EXPECTED_JIRA_FIELD_COUNT + + +def test_get_field_data_generator(): + """ + GIVEN a new instance of FieldDataGeneratorFactory + WHEN we call the get_field_data_generator method twice + THEN the _get_field_data_plugin_instance method should be called only once + AND the object that is returned should match what is returned by + the _get_field_data_plugin_instance method + """ + return_value = "return_value" + factory = FieldDataGeneratorFactory() + with patch.object(FieldDataGeneratorFactory, + "_get_field_data_plugin_instance", + return_value=return_value) as patched: + foo = factory.get_field_data_generator("foobar", {}) + assert foo == return_value + foo = factory.get_field_data_generator("foobar", {}) + assert foo == return_value + assert patched.call_count == 1 + + +@pytest.mark.parametrize("key", KEYS) +def test_get_plugin_name_from_config(good_config_dict, key): + """ + GIVEN an instance of FieldDataGeneratorFactory + WHEN we call _get_plugin_name_from_config with good keys and a good config + THEN a non-empty string should be returned + """ + factory = FieldDataGeneratorFactory() + result = factory._get_plugin_name_from_config(key, good_config_dict) + assert isinstance(result, str) + assert len(result) > 0 + + +def test_get_plugin_name_from_config_bad_key(): + """ + GIVEN an instance of FieldDataGeneratorFactory + WHEN we call the _get_plugin_name_from_config method with an invalid key + THEN a ValueError is raised + """ + factory = FieldDataGeneratorFactory() + with pytest.raises(ValueError): + factory._get_plugin_name_from_config("foobar_key", {}) diff --git a/tests/unit/test_field_factory.py b/tests/unit/test_field_factory.py deleted file mode 100644 index d7bb497..0000000 --- a/tests/unit/test_field_factory.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest - -from unittest.mock import patch, Mock - -from bugjira.field import BugzillaField, JiraField -from bugjira.field_factory import get_bugzilla_fields, get_jira_fields - - -@pytest.fixture -def mock_bz_field_json(): - return [{"name": "bugzilla"}, {"name": "status"}, {"name": "assignee"}] - - -@pytest.fixture -def mock_jira_field_json(): - return [{"name": "jira", "jira_field_id": "jira_field_id"}, - {"name": "jira2", "jira_field_id": "jira2_field_id"}] - - -@pytest.fixture -def mock_json_generator(mock_bz_field_json, mock_jira_field_json): - mock_generator = Mock() - attrs = {'get_bugzilla_fields_json.return_value': mock_bz_field_json, - 'get_jira_fields_json.return_value': mock_jira_field_json} - mock_generator.configure_mock(**attrs) - return mock_generator - - -def test_get_bugzilla_fields(mock_json_generator, mock_bz_field_json): - """ - GIVEN the get_bugzilla_fields method and a json generator that produces - a known set of valid field input records - WHEN we call get_bugzilla_fields - THEN the returned list of BugzillaField objects has the same length as the - input data - AND all the returned BugzillaField objects correspond to a record in the - input data - """ - with patch("bugjira.field_factory._json_generator", mock_json_generator): - assert len(get_bugzilla_fields()) == len(mock_bz_field_json) - for field in get_bugzilla_fields(): - assert isinstance(field, BugzillaField) - assert field.model_dump() in mock_bz_field_json - - -def test_get_jira_fields(mock_json_generator, mock_jira_field_json): - """ - GIVEN the get_jira_fields method and a json generator that produces - a known set of valid field input records - WHEN we call get_jira_fields - THEN the returned list of JiraField objects has the same length as the - input data - AND all the returned JiraField objects correspond to a record in the - input data - """ - with patch("bugjira.field_factory._json_generator", mock_json_generator): - assert len(get_jira_fields()) == len(mock_jira_field_json) - for field in get_jira_fields(): - assert isinstance(field, JiraField) - assert field.model_dump() in mock_jira_field_json - - -def test_get_fields_with_none_generator(): - """ - GIVEN the get_bugzilla_fields and get_jira_fields methods - AND the fields_factory._json_generator attribute set to 'None' - WHEN we call the methods - THEN an empty list is returned - """ - for method in get_bugzilla_fields, get_jira_fields: - assert method() == [] diff --git a/tests/unit/test_field_generator.py b/tests/unit/test_field_generator.py new file mode 100644 index 0000000..47354af --- /dev/null +++ b/tests/unit/test_field_generator.py @@ -0,0 +1,165 @@ +import pytest +from unittest.mock import patch +from typing import List + +from bugjira.common import BUGZILLA, JIRA +from bugjira.field_data_generator import ( + BugzillaFieldDataGenerator, + JiraFieldDataGenerator, + FieldDataGeneratorFactory +) +from bugjira.field_generator import FieldGenerator, FieldGeneratorFactory +from bugjira.field import BugzillaField, JiraField + + +def get_field_instance_data_dict_from_field_class(field_class): + """A convenience method that uses introspection to generate a dict of + valid parameters for the attributes in the passed-in BugjiraField subclass. + This dict can then be used to create an instance of the class without + raising a pydantic validation error. Note that only simple attribute types + are supported currently. If/when the *Field classes have attributes of + other types, then the 'values' dict will need to be updated here. + """ + if field_class not in [BugzillaField, JiraField]: + raise ValueError() + + values = { + int: 1, + float: 2.1, + str: "a string" + } + return {a[0]: values.get(a[1].annotation) for a + in iter(field_class.model_fields.items())} + + +class StubbedFieldGeneratorFactory(FieldDataGeneratorFactory): + """This class is used to patch out the FieldDataGeneratorFactory. It + instantiates the default FieldDataGenerator plugins and pre-populates the + dict from which the get_field_data_generator method retrieves them. It + uses the get_field_instance_dict_from_pydantic_class method to pre-load + some valid field data that the FieldDataGenerator subclasses can return + via the get_field_data method. + """ + def __init__(self): + field_data = { + "bugzilla_field_data": [ + get_field_instance_data_dict_from_field_class(BugzillaField) + ], + "jira_field_data": [ + get_field_instance_data_dict_from_field_class(JiraField) + ] + } + + bzfg = BugzillaFieldDataGenerator({}) + jfg = JiraFieldDataGenerator({}) + for instance in bzfg, jfg: + instance.field_data = field_data + + self.field_data_generators = { + BUGZILLA: bzfg, + JIRA: jfg + } + + +@pytest.fixture(autouse=True) +def patch_data_generator_factory(): + """This fixture replaces the field_data_factory used in the field_generator + module. In its place we put an instance of StubbedFieldGeneratorFactory. + """ + patcher = patch("bugjira.field_generator.field_data_generator_factory", + StubbedFieldGeneratorFactory()) + patcher.start() + yield + patcher.stop() + + +@pytest.mark.parametrize("generator_type,field_class,data_generator_class", + [ + (BUGZILLA, BugzillaField, + BugzillaFieldDataGenerator), + (JIRA, JiraField, + JiraFieldDataGenerator) + ]) +def test_field_generator_init(generator_type, field_class, + data_generator_class): + """ + GIVEN the FieldGenerator class + WHEN we initialize it + THEN the instance's config, field_data_generator, and field_class + attributes should be set to expected values + """ + config = {"expected": "config"} + fg = FieldGenerator(generator_type, config) + assert fg.config == config + assert isinstance(fg.field_data_generator, data_generator_class) + assert fg.field_class == field_class + + +def test_field_generator_init_bad_generator_type(): + """ + GIVEN the FieldGenerator class + WHEN we call init with an invalid generator_type + THEN a ValueError is raised + """ + with pytest.raises(ValueError): + FieldGenerator(generator_type="bad generator type", + config={}) + + +@pytest.mark.parametrize("generator_type,expected_value", + [(BUGZILLA, BugzillaField), + (JIRA, JiraField)]) +def test_get_field_class(generator_type, expected_value): + """ + GIVEN an instance of the FieldGenerator class + WHEN we call _get_field_class with a valid generator_type + THEN the correct BugjiraField subclass associated with the type is returned + """ + fg = FieldGenerator(generator_type, {}) + assert fg._get_field_class(generator_type) == expected_value + + +def test_get_field_class_bad_key(): + """ + GIVEN an instance of the FieldGenerator class + WHEN we call _get_field_class with an invalid generator_type + THEN a ValueError is raised + """ + # first instantiate the FieldGenerator with a good generator_type + fg = FieldGenerator(BUGZILLA, {}) + # now call _get_field_class with a bad generator_type parameter + with pytest.raises(ValueError): + fg._get_field_class("bad key") + + +@pytest.mark.parametrize("generator_type,field_type", + [(BUGZILLA, BugzillaField), (JIRA, JiraField)]) +def test_get_fields(generator_type, field_type): + """ + GIVEN an instance of the FieldGenerator class + WHEN we call its get_fields method + THEN the returned value should be a List containing instances of the + correct BugjiraField subclass corresponding to the generator_type used + to instantiate the FieldGenerator class + """ + bz = FieldGenerator(generator_type, {}) + fields = bz.get_fields() + assert isinstance(fields, List) + for field in fields: + assert isinstance(field, field_type) + + +@pytest.mark.parametrize("generator_type,expected_field_class", + [(BUGZILLA, BugzillaField), + (JIRA, JiraField)]) +def test_get_field_generator(generator_type, expected_field_class): + """ + GIVEN an instance of the FieldGeneratorFactory class + WHEN we call get_field_generator with a valid generator_type + THEN a FieldGenerator instance is returned whose field_class attribute is + the correct BugjiraField subclass for the generator_type + """ + fgf = FieldGeneratorFactory() + generator = fgf.get_field_generator(generator_type, {}) + assert isinstance(generator, FieldGenerator) + assert generator.field_class == expected_field_class diff --git a/tests/unit/test_json_generator.py b/tests/unit/test_json_generator.py deleted file mode 100644 index fd354f2..0000000 --- a/tests/unit/test_json_generator.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest -from unittest.mock import patch, mock_open - -from bugjira import json_generator -from bugjira.exceptions import JsonGeneratorException -from bugjira.json_generator import JsonGenerator - -EXPECTED_BZ_FIELD_COUNT = 3 -EXPECTED_JIRA_FIELD_COUNT = 2 - - -def test_init_good_config(good_config_dict, good_sample_fields_file_path): - """ - GIVEN the JsonGenerator init method - WHEN we call it with a config containing a valid path to a valid sample - fields file - THEN the resulting instance's config should match the input - AND the field_data attribute should be set - AND the get_*_fields_json methods should return lists with the correct - number of records based on the sample fields file contents - """ - good_config_dict["field_data_path"] = good_sample_fields_file_path - generator = JsonGenerator(good_config_dict) - assert generator.config == good_config_dict - assert generator.field_data is not None - assert generator.field_data != {} - assert len(generator.get_bugzilla_fields_json()) == EXPECTED_BZ_FIELD_COUNT - assert len(generator.get_jira_fields_json()) == EXPECTED_JIRA_FIELD_COUNT - - -def test_init_bad_file_path(good_config_dict): - """ - GIVEN the JsonGenerator init method - WHEN we call it with a config whose field_data_path attribute points to a - non-existent file - THEN an IOError should be raised - """ - good_config_dict["field_data_path"] = "/foobar/file/does/not/exist" - with pytest.raises(IOError): - JsonGenerator(good_config_dict) - - -def test_init_invalid_json(good_config_dict): - """ - GIVEN the JsonGenerator init method - WHEN we patch builtins.open to return invalid sample field data - AND we initialize the class with a good config file - THEN a JsonGeneratorException should be raised with an appropriate message - """ - with patch("builtins.open", mock_open(read_data="{}")): - with pytest.raises(JsonGeneratorException, - match="Invalid field data detected"): - JsonGenerator(good_config_dict) - - -def test_init_empty_config(): - """ - GIVEN the JsonGenerator init method - WHEN we call it with an empty config dict - THEN the config and field_data instance fields should be empty dicts - AND the get_*_fields_json methods should return empty lists - """ - generator = JsonGenerator(config={}) - assert generator.config == {} - assert generator.field_data == {} - assert generator.get_bugzilla_fields_json() == [] - assert generator.get_jira_fields_json() == [] - - -def test_init_none_config(): - """ - GIVEN the JsonGenerator init method - WHEN we call it with an empty config dict - THEN the config attribute should be None - AND the field_data attribute should be an empty dict - AND the get_*_fields_json methods should return empty lists - """ - generator = JsonGenerator(config=None) - assert generator.config is None - assert generator.field_data == {} - assert generator.get_bugzilla_fields_json() == [] - assert generator.get_jira_fields_json() == [] - - -def test_register(good_config_dict, good_sample_fields_file_path): - """ - GIVEN the static register method - WHEN we call it with a good config including a path to a valid fields file - THEN the field_factory module's _json_generator attribute should be set to - an instance of JsonGenerator - """ - good_config_dict["field_data_path"] = good_sample_fields_file_path - JsonGenerator.register(good_config_dict) - assert isinstance(json_generator.field_factory._json_generator, - JsonGenerator) diff --git a/tests/unit/test_plugin_loader.py b/tests/unit/test_plugin_loader.py deleted file mode 100644 index 2b78c0a..0000000 --- a/tests/unit/test_plugin_loader.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from unittest.mock import patch - -from bugjira.exceptions import PluginLoaderException -from bugjira.plugin_loader import load_plugin - - -def test_load_plugin_with_none_config(): - """ - GIVEN the load_plugin method from bugjira.plugin_loader - WHEN we call it with config=None - THEN a PluginLoaderException is raised - """ - with pytest.raises(PluginLoaderException): - load_plugin(config=None) - - -def test_load_plugin_with_good_config(good_config_dict): - """ - GIVEN the load_plugin method from bugjira.plugin_loader - WHEN we call it with a good config file - THEN the bugjira.json_generator.register method should be called once - """ - with patch("bugjira.json_generator.JsonGenerator.register") as register: - load_plugin(good_config_dict) - assert register.call_count == 1 - - -def test_load_plugin_with_empty_config(good_config_dict): - """ - GIVEN the load_plugin method from bugjira.plugin_loader - WHEN we call it with config={} - THEN the bugjira.json_generator.register method should be called once - """ - with patch("bugjira.json_generator.JsonGenerator.register") as register: - load_plugin({}) - assert register.call_count == 1 - - -def test_load_plugin_with_bad_module_name(good_config_dict): - """ - GIVEN the load_plugin method from bugjira.plugin_loader - WHEN we call it with a config where the value for "json_generator_module" - is the name of a non-existent module - THEN a PluginLoaderException should be raised with the correct message - """ - good_config_dict["json_generator_module"] = "foo.bar.nexiste.pas" - with pytest.raises(PluginLoaderException, - match="Could not load module"): - load_plugin(good_config_dict)