Skip to content

Commit

Permalink
Add a registry for SCMClient classes and move defaults into code.
Browse files Browse the repository at this point in the history
This introduces `rbtools.clients.base.registry.SCMClientRegistry`, which
keeps track of all the available `BaseSCMClient` subclasses. This
enables fetching a client by ID, iterating through all clients, and
registering new ones.

The registry is accessible via a `rbtools.clients.scmclient_registry`
instance.

`SCMClient` classes should now set a `scmclient_id` attribute. This will
be mandated in RBTools 5. For now, any loaded via entry point that lack
an ID will have one assigned, with a warning.

To improve performance and to avoid packaging-related complications,
the default list of clients are now fully inline, rather than
introspected via entrypoints. This matches what we've been doing within
Review Board. The registry still scans entrypoints for third-party
packages, but only when needed (if listing all clients or if a lookup
fails to find it in the built-in list).

Entry points use the modern `importlib.metadata.entry_points` API. This
has only solidified as of Python 3.10 (3.8/3.9 had it but it's not
compatible), so we pull in the official backport for those versions.

Testing Done:
Unit tests pass.

Posted this change for review.

Ran `mypy` and `pyright` on the new registry code, with no errors
or warnings.

Reviewed at https://reviews.reviewboard.org/r/12525/
  • Loading branch information
chipx86 committed Aug 16, 2022
1 parent 6137c4e commit d4d2e4f
Show file tree
Hide file tree
Showing 17 changed files with 593 additions and 20 deletions.
15 changes: 10 additions & 5 deletions rbtools/clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
~rbtools.clients.base.patch.PatchAuthor
~rbtools.clients.base.patch.PatchResult
~rbtools.clients.base.registry.scmclient_registry
~rbtools.clients.base.repository.RepositoryInfo
~rbtools.clients.base.scmclient.BaseSCMClient
Expand All @@ -36,6 +37,7 @@
import six

from rbtools.clients.base.patch import PatchAuthor, PatchResult
from rbtools.clients.base.registry import scmclient_registry
from rbtools.clients.base.repository import RepositoryInfo
from rbtools.clients.base.scmclient import BaseSCMClient
from rbtools.deprecation import RemovedInRBTools50Warning
Expand Down Expand Up @@ -91,13 +93,14 @@ def load_scmclients(config, options):

SCMCLIENTS = {}

for ep in pkg_resources.iter_entry_points(group='rbtools_scm_clients'):
for scmclient_cls in scmclient_registry:
try:
client = ep.load()(config=config, options=options)
client.entrypoint_name = ep.name
SCMCLIENTS[ep.name] = client
scmclient = scmclient_cls(config=config,
options=options)
SCMCLIENTS[scmclient_cls.scmclient_id] = scmclient
except Exception:
logging.exception('Could not load SCM Client "%s"', ep.name)
logging.exception('Could not load SCM Client "%s"',
scmclient_cls.scmclient_id)


def scan_usable_client(config, options, client_name=None):
Expand Down Expand Up @@ -264,6 +267,7 @@ def scan_usable_client(config, options, client_name=None):
'SCMClient',
'load_scmclients',
'scan_usable_client',
'scmclient_registry',
]


Expand All @@ -272,4 +276,5 @@ def scan_usable_client(config, options, client_name=None):
'PatchAuthor',
'PatchResult',
'RepositoryInfo',
'scmclient_registry',
]
264 changes: 264 additions & 0 deletions rbtools/clients/base/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Registry of available SCMClients.
Version Added:
4.0
"""

from __future__ import annotations

import importlib
import logging
import sys
from collections import OrderedDict
from typing import Iterator, Type

if sys.version_info[:2] >= (3, 10):
# Python >= 3.10
from importlib.metadata import entry_points
else:
# Python <= 3.9
from importlib_metadata import entry_points

from rbtools.clients.base.scmclient import BaseSCMClient
from rbtools.clients.errors import SCMClientNotFoundError
from rbtools.deprecation import RemovedInRBTools50Warning


logger = logging.getLogger(__name__)


class SCMClientRegistry:
"""A registry for looking up and fetching available SCMClients.
This keeps track of all available
:py:class:`~rbtools.clients.base.scmclient.BaseSCMClient` subclasses
available to RBTools. It supplies a built-in list of clients shipped with
RBTools and ones provided by Python packages supplying a
``rbtools_scm_clients`` entry point group.
Built-in SCMClients and ones in entry points are only loaded once per
registry, and only if needed based on the operations performed. Listing
will always ensure both sets of SCMClients are loaded.
Legacy SCMClients provided by entry points will be assigned a
:py:attr:`scmclient_id
<rbtools.clients.base.scmclient.BaseSCMClient.scmclient_id>` based on the
entry point name, if one is not already assigned, and will emit a warning.
Starting in RBTools 5.0, custom SCMClients will need to explicitly set an
ID.
Version Added:
4.0
"""

def __init__(self) -> None:
"""Initialize the registry."""
self._scmclient_classes: OrderedDict[str, Type[BaseSCMClient]] = \
OrderedDict()
self._builtin_loaded = False
self._entrypoints_loaded = False

def __contains__(
self,
scmclient: str | Type[BaseSCMClient],
) -> bool:
"""Return whether a SCMClient type or ID is in the registry.
Args:
scmclient (str or type):
The SCMClient ID or class type to check for.
Returns:
bool:
``True`` if the registry contains this client. ``False`` if it
does not.
Raises:
TypeError:
``scmclient`` is not an ID or a SCMClient class.
"""
if isinstance(scmclient, str):
scmclient_id = scmclient
else:
try:
scmclient_id = scmclient.scmclient_id
except AttributeError:
raise TypeError('%r is not a SCMClient ID or subclass.'
% scmclient)

try:
self.get(scmclient_id)

return True
except SCMClientNotFoundError:
return False

def __iter__(self) -> Iterator[Type[BaseSCMClient]]:
"""Iterate through all registered SCMClient classes.
This will yield each built-in SCMClient, followed by each one provided
by an entrypoint.
This will force both sets of SCMClients to load, if not already loaded.
Yields:
type:
A registered :py:class:`~rbtools.clients.base.scmclient
.BaseSCMClient` subclass.
"""
if not self._builtin_loaded:
self._populate_builtin()

if not self._entrypoints_loaded:
self._populate_entrypoints()

yield from self._scmclient_classes.values()

def get(
self,
scmclient_id: str,
) -> Type[BaseSCMClient]:
"""Return a SCMClient class with the given ID.
This will first check the built-in list of SCMClients. If not found,
entry points will be loaded (if not already loaded), and the ID will
be looked up amongst that set.
Args:
scmclient_id (str):
The ID of the SCMClient.
Returns:
type:
The registered :py:class:`~rbtools.clients.base.scmclient
.BaseSCMClient` subclass for the given ID.
Raises:
rbtools.clients.errors.SCMClientNotFoundError:
A client matching the ID could not be found.
"""
if not self._builtin_loaded:
self._populate_builtin()

try:
scmclient_cls = self._scmclient_classes[scmclient_id]
except KeyError:
scmclient_cls = None

if not self._entrypoints_loaded:
self._populate_entrypoints()

try:
scmclient_cls = self._scmclient_classes[scmclient_id]
except KeyError:
pass

if scmclient_cls is None:
raise SCMClientNotFoundError(scmclient_id)

return scmclient_cls

def register(
self,
scmclient_cls: Type[BaseSCMClient],
) -> None:
"""Register a SCMClient class.
The class must have :py:attr:`scmclient_id
<rbtools.clients.base.scmclient.BaseSCMClient.scmclient_id>` set, and
it must be unique.
Args:
scmclient_cls (type):
The class to register.
Raises:
ValueError:
The SCMClient ID is unset or not unique.
"""
if not self._builtin_loaded:
self._populate_builtin()

scmclient_id = getattr(scmclient_cls, 'scmclient_id', None)

if not scmclient_id:
raise ValueError(
'%s.%s.scmclient_id must be set, and must be a unique value.'
% (scmclient_cls.__module__,
scmclient_cls.__name__))

existing_cls = self._scmclient_classes.get(scmclient_id)

if existing_cls is not None:
if existing_cls is scmclient_cls:
raise ValueError('%s.%s is already registered.'
% (scmclient_cls.__module__,
scmclient_cls.__name__))
else:
raise ValueError(
'A SCMClient with an ID of "%s" is already registered: '
'%s.%s'
% (scmclient_id,
existing_cls.__module__,
existing_cls.__name__))

self._scmclient_classes[scmclient_id] = scmclient_cls

def _populate_builtin(self) -> None:
"""Populate the list of built-in SCMClient classes."""
assert not self._builtin_loaded

# Set this early, to avoid recursing when we call register().
self._builtin_loaded = True

builtin_scmclient_paths = (
('rbtools.clients.bazaar', 'BazaarClient'),
('rbtools.clients.clearcase', 'ClearCaseClient'),
('rbtools.clients.cvs', 'CVSClient'),
('rbtools.clients.git', 'GitClient'),
('rbtools.clients.mercurial', 'MercurialClient'),
('rbtools.clients.perforce', 'PerforceClient'),
('rbtools.clients.plastic', 'PlasticClient'),
('rbtools.clients.sos', 'SOSClient'),
('rbtools.clients.svn', 'SVNClient'),
('rbtools.clients.tfs', 'TFSClient'),
)

for mod_name, cls_name in builtin_scmclient_paths:
try:
mod = importlib.import_module(mod_name)
cls = getattr(mod, cls_name)

self.register(cls)
except Exception as e:
logger.exception('Unexpected error looking up built-in '
'SCMClient %s.%s: %s',
mod_name, cls_name, e)

def _populate_entrypoints(self) -> None:
"""Populate the list of entry point SCMClient classes."""
assert not self._entrypoints_loaded

self._entrypoints_loaded = True

for ep in entry_points(group='rbtools_scm_clients'):
try:
cls = ep.load()

if not getattr(cls, 'scmclient_id', None):
RemovedInRBTools50Warning.warn(
'%s.scmclient_id must be set, and must be a unique '
'value. You probably want to set it to "%s".'
% (cls.__name__, ep.name))

cls.scmclient_id = ep.name

self.register(cls)
except Exception as e:
logger.exception('Unexpected error loading non-default '
'SCMClient provided by Python entrypoint '
'%r: %s',
ep, e)


scmclient_registry = SCMClientRegistry()
31 changes: 31 additions & 0 deletions rbtools/clients/base/scmclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from rbtools.clients.base.patch import PatchResult
from rbtools.clients.errors import SCMError
from rbtools.deprecation import RemovedInRBTools50Warning
from rbtools.utils.process import execute


Expand All @@ -28,6 +29,16 @@ class BaseSCMClient(object):
``SCMClient`` to ``BaseSCMClient``.
"""

#: The unique ID of the client.
#:
#: Version Added:
#: 4.0:
#: This will be required in RBTools 5.0.
#:
#: Type:
#: str
scmclient_id: str = ''

#: The name of the client.
#:
#: Type:
Expand Down Expand Up @@ -147,6 +158,26 @@ def __init__(self, config=None, options=None):
self.options = options
self.capabilities = None

@property
def entrypoint_name(self) -> str:
"""An alias for the SCMClient ID.
This is here for backwards-compatibility purposes.
Deprecated:
4.0:
Callers should use :py:attr:`scmclient_id`. This attribute will
be removed in RBTools 5.0.
"""
cls_name = type(self).__name__

RemovedInRBTools50Warning.warn(
'%s.entrypoint_name is deprecated. Please use %s.scmclient_id '
'instead. This will be removed in RBTools 5.0.'
% (cls_name, cls_name))

return self.scmclient_id

def is_remote_only(self):
"""Return whether this repository is operating in remote-only mode.
Expand Down
1 change: 1 addition & 0 deletions rbtools/clients/bazaar.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class BazaarClient(BaseSCMClient):
Added support for Breezy.
"""

scmclient_id = 'bazaar'
name = 'Bazaar'
server_tool_names = 'Bazaar'
supports_diff_exclude_patterns = True
Expand Down
1 change: 1 addition & 0 deletions rbtools/clients/clearcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ class ClearCaseClient(BaseSCMClient):
is installed on Windows.
"""

scmclient_id = 'clearcase'
name = 'VersionVault / ClearCase'
server_tool_names = 'ClearCase,VersionVault / ClearCase'
supports_patch_revert = True
Expand Down
1 change: 1 addition & 0 deletions rbtools/clients/cvs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class CVSClient(BaseSCMClient):
information and generates compatible diffs.
"""

scmclient_id = 'cvs'
name = 'CVS'
server_tool_names = 'CVS'
supports_diff_exclude_patterns = True
Expand Down
Loading

0 comments on commit d4d2e4f

Please sign in to comment.