|
1 |
| -import abc |
2 |
| -import os |
3 |
| -import re |
| 1 | +""" |
| 2 | +Service API |
4 | 3 |
|
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