"""Phabricator module."""
import configparser
import logging
import re
import phabricator
from wmflib.exceptions import WmflibError
logger = logging.getLogger(__name__)
[docs]
def create_phabricator(
bot_config_file: str,
*,
section: str = "phabricator_bot",
allow_empty_identifiers: bool = False,
dry_run: bool = True,
) -> phabricator.Phabricator:
"""Initialize the Phabricator client from the bot config file.
Examples:
::
>>> from wmflib.phabricator import create_phabricator, Phabricator
>>> phab_client = create_phabricator("/path/to/config.ini", dry_run=False)
>>> Phabricator.validate_task_id("T12345")
'T12345'
>>> if phab_client.task_accessible("T12345"):
... phab_client.task_comment("T12345", "Message")
Arguments:
bot_config_file (str): the path to the configuration file for the Phabricator bot, with the following
structure::
[section_name]
host = https://phabricator.example.com/api/
username = phab-bot
token = api-12345
section (str, optional): the name of the section of the configuration file where to find the required
parameters.
allow_empty_identifiers: if set to :py:data:`True` all the methods that require an ID (e.g. a task ID) will
also accept an empty string as identifier and act as noop in that case.
dry_run (bool, optional): whether this is a DRY-RUN.
Returns:
wmflib.phabricator.Phabricator: a Phabricator instance.
Raises:
wmflib.phabricator.PhabricatorError: if unable to get all the required parameters from the bot configuration
file, or to initialize the Phabricator client.
"""
parser = configparser.ConfigParser()
parser.read(bot_config_file)
required_options = ("host", "username", "token")
params = {}
try:
for option in required_options:
params[option] = parser.get(section, option)
except configparser.NoSectionError as e:
raise PhabricatorError(f"Unable to find section {section} in config file {bot_config_file}") from e
except configparser.NoOptionError as e:
raise PhabricatorError(
f"Unable to find all required options {required_options} in section {section} of config "
f"file {bot_config_file}"
) from e
try:
client = phabricator.Phabricator(**params)
except Exception as e:
raise PhabricatorError("Unable to instantiate Phabricator client") from e
return Phabricator(client, allow_empty_identifiers=allow_empty_identifiers, dry_run=dry_run)
[docs]
def validate_task_id(task_id: str, *, allow_empty_identifiers: bool = False) -> str:
r"""Phabricator task ID validator suitable to be used with :py:meth:`argparse.ArgumentParser.add_argument`.
Ensures that the task ID is properly formatted as T123456. Empty string are also accepted when setting
``allow_empty_identifiers=True``, suitable to be used with argparse when the argument is optional with
a default value of empty string.
Examples:
::
>>> validate_task_id("T1")
'T1'
>>> validate_task_id("T999999")
'T999999'
>>> validate_task_id("", allow_empty_identifiers=True)
''
>>> validate_task_id("T1234567")
Traceback (most recent call last):
[...SNIP...]
ValueError: Invalid Phabricator task ID, expected to match pattern 'T\d{1,6}$', got 'T1234567'
Arguments:
task_id: the Phabricator task ID to validate.
allow_empty_identifiers: if set to :py:data:`True` will consider an empty task as valid.
Raises:
ValueError: if the ``task_id`` is not properly formatted, so that argparse will properly format the error
message. See also the :py:meth:`argparse.ArgumentParser.add_argument` documentation.
Returns:
the task ID if valid.
"""
if allow_empty_identifiers and not task_id: # Accept empty strings
return task_id
pattern = r"T\d{1,6}$"
if re.match(pattern, task_id) is None:
raise ValueError(f"Invalid Phabricator task ID, expected to match pattern '{pattern}', got '{task_id}'")
return task_id
[docs]
class PhabricatorError(WmflibError):
"""Custom exception class for errors of the Phabricator class."""
[docs]
class Phabricator:
"""Class to interact with a Phabricator website."""
[docs]
def __init__(
self,
phabricator_client: phabricator.Phabricator,
*,
dry_run: bool = True,
allow_empty_identifiers: bool = False,
) -> None:
"""Initialize the Phabricator client from the bot config file.
Arguments:
phabricator_client (phabricator.Phabricator): a Phabricator client instance.
dry_run (bool, optional): whether this is a DRY-RUN.
allow_empty_identifiers: if set to :py:data:`True` all the methods that require an ID (e.g. a task ID) will
also accept an empty string as identifier and act as noop in that case.
"""
self._client = phabricator_client
self._dry_run = dry_run
self._allow_empty_identifiers = allow_empty_identifiers
[docs]
def task_accessible(self, task_id: str, *, raises: bool = True) -> bool:
"""Check if a task exists and is accessible to the curirent API user.
Examples:
::
>>> phab_client.task_accessible("T1")
True
>>> phab_client.task_accessible("T1", raises=False)
True
>>> phab_client.task_accessible("T999999")
False
>>> phab_client.task_accessible("") # with allow_empty_identifiers=True
DEBUG:wmflib.phabricator:No task specified and allow_empty_identifiers=True, nothing to check
False
>>> phab_client.task_accessible("", raises=False) # with allow_empty_identifiers=True
DEBUG:wmflib.phabricator:No task specified and allow_empty_identifiers=True, nothing to check
False
>>> phab_client.task_accessible("", raises=False) # with allow_empty_identifiers=False
ERROR:wmflib.phabricator:Unable to determine if the task '' is accessible (raises=False): Empty task ID
False
>>> phab_client.task_accessible("") # with allow_empty_identifiers=False
Traceback (most recent call last):
[...SNIP...]
wmflib.phabricator.PhabricatorError: Empty task ID
[...SNIP...]
wmflib.phabricator.PhabricatorError: Unable to determine if the task '' is accessible
>>> phab_client.task_accessible("T1")
Traceback (most recent call last):
[...SNIP...]
phabricator.APIError: ERR-INVALID-AUTH: API token "api-a" has the wrong length. [...SNIP...]
[...SNIP...]
wmflib.phabricator.PhabricatorError: Unable to determine if the task 'T1' is accessible
>>> phab_client.task_accessible("T1", raises=False)
ERROR:wmflib.phabricator:Unable to determine if the task T1 is accessible (raises=False): ERR-[...SNIP...]
ERROR:wmflib.phabricator:Unable to determine if the task 'T1' is accessible (raises=False): ERR[...SNIP...]
False
Arguments:
task_id: the Phabricator task ID (e.g. ``T12345``) to be updated. If empty string it will raise an
exception unless ``allow_empty_identifiers`` is set to :py:data:`True`. In this case it returns
:py:data:`False` and logs a message at debug level.
raises: if set to :py:data:`False` does not raise an exception and returns :py:data:`False` if unable
to check the task accessibility.
Raises:
wmflib.phabricator.PhabricatorError: if unable to verify if the task is accessible and ``raises`` is
set to :py:data:`True`.
Returns:
:py:data:`True` if the task exists and is accessible by the current user, :py:data:`False` if not
accessible, an empty string was passed and allow_empty_identifiers is set to :py:data:`True` or if unable
to verify if the task is accessible and ``raises`` is set to :py:data:`False`.
"""
if self._allow_empty_identifiers and not task_id:
logger.debug("No task specified and allow_empty_identifiers=True, nothing to check")
return False
try:
if not task_id:
raise PhabricatorError("Empty task ID") # noqa: TRY301
results = self._client.maniphest.search(constraints={"ids": [int(task_id[1:])]})
return len(results.data) == 1
except Exception as e:
message = f"Unable to determine if the task '{task_id}' is accessible"
if raises:
raise PhabricatorError(message) from e
logger.error("%s (raises=%s): %s", message, raises, e)
return False