Skip to content
34 changes: 20 additions & 14 deletions bugwarrior/config/validation.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does seem to improve the outcome as shown in the tests, but can you explain it? I take it that the problem had something to do with the early return without the descriminator Field? But what is the fundamental difference between _MissingServiceDiscriminator and ServiceConfig?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's related to _format_service_error error handling. When going through the ServiceConfig block,

  1. We have an error where loc = (0, "service"), type = "missing" whereas the block we want in _format_service_error is if len(loc) == 1
  2. Even if we change the condition to handle this, we would also get a loc = (0, "username") type = "extra_forbidden" error, because ServiceConfig does not know the service-specific fields. When using a discriminator field, if the discriminator raises an error, pydantic stop there and does not go through other errors.

This can also be modified in the error handling function, though I thought it was simpler to simply have an empty discriminator class

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I basically follow that explanation, but how does _MissingServiceDiscriminator avoid those issues?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to have discriminated union to be as close as possible to the general case (when we have valid configs), for the reasons above.

so I guess your question is: why don't we fallback on service_config_classes = ServiceConfig instead of service_config_classes = _MissingServiceDiscriminator, when there is no valid service config ?

Simply because a discriminator requires the field (service here), to be a Literal, not a str

To validate models based on that information, you can set a common field on each model of the union (pet_type in the example below), typed as accepting one or multiple literal values.

The doc says:

To validate models based on that information, you can set a common field on each model of the union typed as accepting one or multiple literal values.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I get it now! I opened Lotram#2 as a proposed alternative which i find more readable and possibly more correct since service can't actually be an arbitrary str.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see a problem with your implementation: Literal["__bugwarrior_service_placeholder__"] (type from the base class) is not compatible with Literal["github"], so there is a theoretical typing problem.

I agree service can't be an arbitrary string, it should be the Union of all declared service types, but since this registration is dynamic (and we don't want to register unused services), we have a problem. So IMO, str is "truer" than Literal["__bugwarrior_service_placeholder__"]

Here is a minimal example to reproduce:

from typing import Literal

from pydantic import BaseModel


class _String(BaseModel):
    service: str


class _Literal(BaseModel):
    service: Literal["_error_"]


class Config(_Literal):
    pass


class GitHubConfig(Config):
    service: Literal["github"]


class GitlabConfig(Config):
    service: Literal["gitlab"]
  • ty is ok
  • both mypy and pyright complain about the type (mypy error is: Incompatible types in assignment (expression has type "Literal['github']", base class "Config" defined the type as "Literal['_error_']"))

Note that pyright would still complain with the regular str instead of Literal, because "Variable is mutable so its type is invariant" (and it still complains when using frozen=True and `ClassVar), pyright complains a lot

So, as a conclusion, I'm ok with your implementation if you prefer it, for simplicity sake, but I think it's just a tiny bit more wrong

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, let's stick with the more correct implementation. My only ask would be, please add at least a sentence of explanation to the _MissingServiceDiscriminator docstring about how it's essential purpose is to make the service field a Literal so it will work as a discriminator. I think using the ServiceConfig base class as a fallback here is the intuitive implementation -- after all it's what you did initially -- so this is a natural place for anyone to wonder why this class is necessary.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import sys
from typing import TYPE_CHECKING, Annotated, Any, NoReturn, Union
from typing import Annotated, Any, Literal, NoReturn, Union

from pydantic import Field, TypeAdapter, ValidationError
from pydantic_core import ErrorDetails
Expand All @@ -14,10 +14,6 @@
get_service,
)

if TYPE_CHECKING:
ServiceConfigType = ServiceConfig


log = logging.getLogger(__name__)


Expand Down Expand Up @@ -123,6 +119,18 @@ def raise_validation_error(
sys.exit(1)


class _MissingServiceDiscriminator(BaseConfig):
"""
Placeholder schema used to report missing service discriminators.

We need 'service' to be a Literal, so we can use it in a discriminator Field.
Regular ServiceConfig base class defines it as a plain str, which does not work
with discriminators.
"""

service: Literal["__bugwarrior_missing_service__"]


def get_service_config_union_type(services: list[dict[str, Any]]) -> Any:
"""
Return a Union type of the ServiceConfig subclasses of the services actually configured.
Expand All @@ -134,16 +142,14 @@ def get_service_config_union_type(services: list[dict[str, Any]]) -> Any:
to ensure that each service configuration is validated against
the ServiceConfig corresponding to the correct service.
"""
service_config_classes = tuple(
get_service(service["service"]).CONFIG_SCHEMA
for service in services
if "service" in service
service_config_classes = (
tuple(
get_service(service["service"]).CONFIG_SCHEMA
for service in services
if "service" in service
)
or _MissingServiceDiscriminator
)

# Default to generic ServiceConfig if no service defined, mostly for tests
if not service_config_classes:
return ServiceConfig

return Annotated[Union[service_config_classes], Field(discriminator="service")]


Expand Down
6 changes: 3 additions & 3 deletions bugwarrior/docs/other-services/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,15 @@ If you're developing in the bugwarrior repo, you can simply add your entry to th



Create a test file and implement at least the minimal service tests by inheriting from ``AbstractServiceTest``.
Create a test file and implement at least the minimal service tests by inheriting from ``ServiceIssueTest``.

.. code:: bash

touch tests/test_gitbug.py
touch tests/services/test_gitbug.py

.. code:: python

class TestGitBugIssue(AbstractServiceTest, ServiceTest):
class TestGitBugIssue(ServiceIssueTest):
SERVICE_CONFIG = {
'service': 'gitbug',
'path': '/dev/null',
Expand Down
130 changes: 68 additions & 62 deletions tests/base.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import abc
import contextlib
import os.path
import shutil
import sys
import tempfile
import typing
import unittest
import unittest.mock

import pytest
import responses

from bugwarrior import config, services
from bugwarrior.config import schema, validation
from bugwarrior.config import validation
from bugwarrior.config.load import format_config


Expand All @@ -22,48 +23,81 @@ class DumbConfig(config.ServiceConfig):


class DumbIssue(services.Issue):
UDAS: dict = {}
UNIQUE_KEY: tuple[str, ...] = ("id",)
URL = "dumburl"
TYPE = "dumbtype"

UDAS = {
URL: {"type": "string", "label": "Dumb URL"},
TYPE: {"type": "string", "label": "Dumb Type"},
}
UNIQUE_KEY = (URL,)
PRIORITY_MAP: dict = {}

def get_default_description(self):
raise NotImplementedError
return self.build_default_description(
title=self.record.get("title", ""),
url=self.record.get("url", ""),
number=self.record.get("number", ""),
)

def to_taskwarrior(self):
raise NotImplementedError
return {
"project": self.extra.get("project"),
"priority": self.config.default_priority,
"annotations": self.extra.get("annotations", []),
"tags": self.get_tags_from_labels(self.record.get("labels", [])),
self.URL: self.record.get("url", ""),
self.TYPE: self.extra.get("type", "issue"),
}


class DumbService(services.Service):
API_VERSION = services.LATEST_API_VERSION
ISSUE_CLASS = DumbIssue
CONFIG_SCHEMA = DumbConfig

def get_owner(self, _):
raise NotImplementedError

def issues(self):
raise NotImplementedError


class AbstractServiceTest(abc.ABC):
"""Ensures that certain test methods are implemented for each service."""
#: Modules that import get_service by name and must be patched together so the
#: fake service resolves consistently across config loading, collection, and db.
_GET_SERVICE_MODULES = (
'bugwarrior.config.schema',
'bugwarrior.config.validation',
'bugwarrior.config',
'bugwarrior.db',
'bugwarrior.collect',
)

@abc.abstractmethod
def test_to_taskwarrior(self):
"""Test Service.to_taskwarrior()."""
raise NotImplementedError

@abc.abstractmethod
def test_issues(self):
"""
Test Service.issues().
@contextlib.contextmanager
def register_services(mapping=None):
"""
Make fake services resolvable via get_service for the duration of a block.

- When the API is accessed via requests, use the responses library to
mock requests.
- When the API is accessed via a third party library, substitute a fake
implementation class for it.
"""
raise NotImplementedError
Patches get_service in every module that imports it so that orchestration
code (config loading, collection, db) resolves the given name-to-class
mapping instead of the real entry points. Defaults to mapping the "test"
service to DumbService.
"""
mapping = mapping or {'test': DumbService}

def fake_get_service(name):
try:
return mapping[name]
except KeyError:
raise ValueError(
f"Configured service '{name}' not found. "
"Is it installed? Or misspelled?"
)

with contextlib.ExitStack() as stack:
for module in _GET_SERVICE_MODULES:
stack.enter_context(
unittest.mock.patch(f'{module}.get_service', fake_get_service)
)
yield


class ConfigTest(unittest.TestCase):
Expand Down Expand Up @@ -99,6 +133,14 @@ def tearDown(self):
def inject_fixtures(self, caplog):
self.caplog = caplog

if sys.version_info < (3, 11):

def enterContext(self, cm):
"""Backport of unittest.TestCase.enterContext for Python 3.10."""
value = cm.__enter__()
self.addCleanup(cm.__exit__, None, None, None)
return value

def validate(self) -> validation.Config:
config = self.config.copy()
config['general'] = config.get('general', {})
Expand All @@ -116,39 +158,3 @@ def assertValidationError(self, expected):

# We may want to use this assertion more than once per test.
self.caplog.clear()


class ServiceTest(ConfigTest):
GENERAL_CONFIG = {'annotation_length': 100, 'description_length': 100}
SERVICE_CONFIG = {}

@classmethod
def setUpClass(cls):
cls.maxDiff = None

def get_mock_service(
self,
service_class,
section='unspecified',
config_overrides=None,
general_overrides=None,
):
options = {
'general': {**self.GENERAL_CONFIG, 'targets': [section]},
section: {**self.SERVICE_CONFIG.copy(), 'target': section},
}
if config_overrides:
options[section].update(config_overrides)
if general_overrides:
options['general'].update(general_overrides)

service_config = service_class.CONFIG_SCHEMA(**options[section])
main_config = schema.MainSectionConfig(**options['general'])

return service_class(service_config, main_config)

@staticmethod
def add_response(url, method='GET', **kwargs):
responses.add(
responses.Response(url=url, method=method, match_querystring=True, **kwargs)
)
21 changes: 3 additions & 18 deletions tests/config/test_data.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import json
import os

from bugwarrior.config import data, validation
from bugwarrior.config.load import format_config
from bugwarrior.config import data, schema

from ..base import ConfigTest

Expand Down Expand Up @@ -41,24 +40,10 @@ def test_path_attribute(self):
class TestGetDataPath(ConfigTest):
def setUp(self):
super().setUp()
rawconfig = {
'general': {'targets': ['my_service']},
'my_service': {
'service': 'github',
'login': 'ralphbean',
'token': 'abc123',
'username': 'ralphbean',
},
}
formatted = format_config(rawconfig)
self.validated_config = validation.validate_config(
formatted, 'general', 'configpath'
)
self.main_config = schema.MainSectionConfig(targets=[])

def assertDataPath(self, expected_datapath):
self.assertEqual(
expected_datapath, data.get_data_path(self.validated_config.main.taskrc)
)
self.assertEqual(expected_datapath, data.get_data_path(self.main_config.taskrc))

def test_TASKDATA(self):
"""
Expand Down
Loading
Loading