Skip to content

Commit 1dbde51

Browse files
committed
Move services/__init__.py to services/base.py
Separate the base class implementations (and their utility functions, imports, etc) from the 'service' package's top-level namespace. This should encourage developers to be more conscientious about changing the "public" API and should make API users more conscientious about importing "private" symbols.
1 parent ef8c2bd commit 1dbde51

File tree

3 files changed

+291
-284
lines changed

3 files changed

+291
-284
lines changed

bugwarrior/services/__init__.py

+5-283
Original file line numberDiff line numberDiff line change
@@ -1,284 +1,6 @@
1-
import abc
2-
import os
3-
import re
1+
"""
2+
Service API
43
5-
from dateutil.parser import parse as parse_date
6-
from dateutil.tz import tzlocal
7-
import dogpile.cache
8-
from jinja2 import Template
9-
import pytz
10-
import requests
11-
12-
from bugwarrior.config import schema, secrets
13-
14-
import logging
15-
log = logging.getLogger(__name__)
16-
17-
DOGPILE_CACHE_PATH = os.path.expanduser(''.join([
18-
os.getenv('XDG_CACHE_HOME', '~/.cache'), '/dagd-py3.dbm']))
19-
20-
if not os.path.isdir(os.path.dirname(DOGPILE_CACHE_PATH)):
21-
os.makedirs(os.path.dirname(DOGPILE_CACHE_PATH))
22-
CACHE_REGION = dogpile.cache.make_region().configure(
23-
"dogpile.cache.dbm",
24-
arguments=dict(filename=DOGPILE_CACHE_PATH),
25-
)
26-
27-
28-
class URLShortener:
29-
_instance = None
30-
31-
def __new__(cls, *args, **kwargs):
32-
if not cls._instance:
33-
cls._instance = super().__new__(
34-
cls, *args, **kwargs
35-
)
36-
return cls._instance
37-
38-
@CACHE_REGION.cache_on_arguments()
39-
def shorten(self, url):
40-
if not url:
41-
return ''
42-
base = 'https://da.gd/s'
43-
return requests.get(base, params=dict(url=url)).text.strip()
44-
45-
46-
def get_processed_url(main_config: schema.MainSectionConfig, url: str):
47-
""" Returns a URL with conditional processing.
48-
49-
If the following config key are set:
50-
51-
- [general]shorten
52-
53-
returns a shortened URL; otherwise returns the URL unaltered.
54-
55-
"""
56-
if main_config.shorten:
57-
return URLShortener().shorten(url)
58-
return url
59-
60-
61-
class Service(abc.ABC):
62-
""" Abstract base class for each service """
63-
# Which class should this service instantiate for holding these issues?
64-
ISSUE_CLASS = None
65-
# Which class defines this service's configuration options?
66-
CONFIG_SCHEMA = None
67-
68-
def __init__(self, config, main_config):
69-
self.config = config
70-
self.main_config = main_config
71-
72-
log.info("Working on [%s]", self.config.target)
73-
74-
def get_password(self, key, login='nousername'):
75-
password = getattr(self.config, key)
76-
keyring_service = self.get_keyring_service(self.config)
77-
if not password or password.startswith("@oracle:"):
78-
password = secrets.get_service_password(
79-
keyring_service, login, oracle=password,
80-
interactive=self.main_config.interactive)
81-
return password
82-
83-
def get_issue_for_record(self, record, extra=None):
84-
return self.ISSUE_CLASS(
85-
record, self.config, self.main_config, extra=extra)
86-
87-
def build_annotations(self, annotations, url=None):
88-
final = []
89-
if url and self.main_config.annotation_links:
90-
final.append(get_processed_url(self.main_config, url))
91-
if self.main_config.annotation_comments:
92-
for author, message in annotations:
93-
message = message.strip()
94-
if not message or not author:
95-
continue
96-
97-
if not self.main_config.annotation_newlines:
98-
message = message.replace('\n', '').replace('\r', '')
99-
100-
annotation_length = self.main_config.annotation_length
101-
if annotation_length:
102-
message = '%s%s' % (
103-
message[:annotation_length],
104-
'...' if len(message) > annotation_length else ''
105-
)
106-
final.append('@%s - %s' % (author, message))
107-
return final
108-
109-
def include(self, issue):
110-
""" Return true if the issue in question should be included """
111-
if self.config.only_if_assigned:
112-
owner = self.get_owner(issue)
113-
include_owners = [self.config.only_if_assigned]
114-
115-
if self.config.also_unassigned:
116-
include_owners.append(None)
117-
118-
return owner in include_owners
119-
120-
return True
121-
122-
@abc.abstractmethod
123-
def get_owner(self, issue):
124-
""" Override this for filtering on tickets """
125-
raise NotImplementedError()
126-
127-
@abc.abstractmethod
128-
def issues(self):
129-
""" Returns a list of Issue instances representing issues from a remote service.
130-
131-
Each item in the list should be a dict that looks something like this:
132-
133-
{
134-
"description": "Some description of the issue",
135-
"project": "some_project",
136-
"priority": "H",
137-
"annotations": [
138-
"This is an annotation",
139-
"This is another annotation",
140-
]
141-
}
142-
143-
144-
The description can be 'anything' but must be consistent and unique for
145-
issues you're pulling from a remote service. You can and should use
146-
the ``.description(...)`` method to help format your descriptions.
147-
148-
The project should be a string and may be anything you like.
149-
150-
The priority should be one of "H", "M", or "L".
151-
"""
152-
raise NotImplementedError()
153-
154-
@staticmethod
155-
def get_keyring_service(service_config):
156-
""" Given the keyring service name for this service. """
157-
raise NotImplementedError
158-
159-
160-
class Issue(abc.ABC):
161-
# Set to a dictionary mapping UDA short names with type and long name.
162-
#
163-
# Example::
164-
#
165-
# {
166-
# 'project_id': {
167-
# 'type': 'string',
168-
# 'label': 'Project ID',
169-
# },
170-
# 'ticket_number': {
171-
# 'type': 'number',
172-
# 'label': 'Ticket Number',
173-
# },
174-
# }
175-
#
176-
# Note: For best results, dictionary keys should be unique!
177-
UDAS = {}
178-
# Should be a tuple of field names (can be UDA names) that are usable for
179-
# uniquely identifying an issue in the foreign system.
180-
UNIQUE_KEY = []
181-
# Should be a dictionary of value-to-level mappings between the foreign
182-
# system and the string values 'H', 'M' or 'L'.
183-
PRIORITY_MAP = {}
184-
185-
def __init__(self, foreign_record, config, main_config, extra=None):
186-
self.record = foreign_record
187-
self.config = config
188-
self.main_config = main_config
189-
self.extra = extra if extra else {}
190-
191-
@abc.abstractmethod
192-
def to_taskwarrior(self):
193-
""" Transform a foreign record into a taskwarrior dictionary."""
194-
raise NotImplementedError()
195-
196-
@abc.abstractmethod
197-
def get_default_description(self):
198-
raise NotImplementedError()
199-
200-
def get_tags_from_labels(self,
201-
labels,
202-
toggle_option='import_labels_as_tags',
203-
template_option='label_template',
204-
template_variable='label'):
205-
tags = []
206-
207-
if not getattr(self.config, toggle_option):
208-
return tags
209-
210-
context = self.record.copy()
211-
label_template = Template(getattr(self.config, template_option))
212-
213-
for label in labels:
214-
normalized_label = re.sub(r'[^a-zA-Z0-9]', '_', label)
215-
context.update({template_variable: normalized_label})
216-
tags.append(label_template.render(context))
217-
218-
return tags
219-
220-
def get_priority(self):
221-
return self.PRIORITY_MAP.get(
222-
self.record.get('priority'),
223-
self.config.default_priority
224-
)
225-
226-
def parse_date(self, date, timezone='UTC'):
227-
""" Parse a date string into a datetime object.
228-
229-
:param `date`: A time string parseable by `dateutil.parser.parse`
230-
:param `timezone`: The string timezone name (from `pytz.all_timezones`)
231-
to use as a default should the parsed time string not include
232-
timezone information.
233-
234-
"""
235-
if date:
236-
date = parse_date(date)
237-
if not date.tzinfo:
238-
if timezone == '':
239-
tzinfo = tzlocal()
240-
else:
241-
tzinfo = pytz.timezone(timezone)
242-
date = date.replace(tzinfo=tzinfo)
243-
return date
244-
return None
245-
246-
def build_default_description(
247-
self, title='', url='', number='', cls="issue"
248-
):
249-
cls_markup = {
250-
'issue': 'Is',
251-
'pull_request': 'PR',
252-
'merge_request': 'MR',
253-
'todo': '',
254-
'task': '',
255-
'subtask': 'Subtask #',
256-
}
257-
url_separator = ' .. '
258-
url = get_processed_url(self.main_config, url) if self.main_config.inline_links else ''
259-
desc_len = self.main_config.description_length
260-
return "(bw)%s#%s - %s%s%s" % (
261-
cls_markup.get(cls, cls.title()),
262-
number,
263-
title[:desc_len] if desc_len else title,
264-
url_separator if url else '',
265-
url,
266-
)
267-
268-
269-
class Client:
270-
""" Abstract class responsible for making requests to service API's. """
271-
@staticmethod
272-
def json_response(response):
273-
# If we didn't get good results, just bail.
274-
if response.status_code != 200:
275-
raise OSError(
276-
"Non-200 status code %r; %r; %r" % (
277-
response.status_code, response.url, response.text,
278-
))
279-
if callable(response.json):
280-
# Newer python-requests
281-
return response.json()
282-
else:
283-
# Older python-requests
284-
return response.json
4+
NOTE: This is a public API and should not be casually modified.
5+
"""
6+
from .base import Service, Issue, Client # noqa: F401

0 commit comments

Comments
 (0)