Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sagesmc
Path: blob/master/src/sage/dev/trac_interface.py
8815 views
1
r"""
2
Trac Interface
3
4
This module provides an interface to access sage's issue tracker 'trac' through
5
its RPC interface.
6
7
AUTHORS:
8
9
- David Roe, Julian Rueth, R. Andrew Ohana, Robert Bradshaw, Timo Kluck:
10
initial version
11
12
"""
13
#*****************************************************************************
14
# Copyright (C) 2013 David Roe <[email protected]>
15
# Julian Rueth <[email protected]>
16
# R. Andrew Ohana <[email protected]>
17
# Robert Bradshaw <[email protected]>
18
# Timo Kluck <[email protected]>
19
#
20
# Distributed under the terms of the GNU General Public License (GPL)
21
# as published by the Free Software Foundation; either version 2 of
22
# the License, or (at your option) any later version.
23
# http://www.gnu.org/licenses/
24
#*****************************************************************************
25
26
import os, re, time, datetime
27
FIELD_REGEX = re.compile("^([A-Za-z ]+):(.*)$")
28
ALLOWED_FIELDS = {
29
"author": "Authors",
30
"branch": "Branch",
31
"cc": "Cc",
32
"component": "Component",
33
"dependencies": "Dependencies",
34
"keywords": "Keywords",
35
"merged": "Merged in",
36
"milestone": "Milestone",
37
"owner": "Owned by",
38
"priority": "Priority",
39
"upstream": "Report Upstream",
40
"reviewer": "Reviewers",
41
"stopgaps": "Stopgaps",
42
"status": "Status",
43
"type": "Type",
44
"work_issues": "Work issues",
45
}
46
FIELDS_LOOKUP = {"summary":"summary"}
47
for _k, _v in ALLOWED_FIELDS.iteritems():
48
FIELDS_LOOKUP[_v.lower()] = _k
49
TICKET_FILE_GUIDE = r"""
50
# Lines starting with `#` are ignored.
51
# Lines at the beginning of this file starting with `Field: ` correspond to
52
# fields of the trac ticket, and can be followed by text on the same line.
53
# They will be assigned to the corresponding field on the trac ticket.
54
#
55
# Lines not following this format will be put into the ticket description. Trac
56
# markup is supported.
57
#
58
# An empty file aborts ticket creation/editing.
59
#
60
# The following trac fields only allow certain values.
61
# priority: blocker, critical, major, minor, trivial
62
# status: closed, needs_info, needs_review, needs_work, new,
63
# positive_review
64
# type: defect, enhancement, task
65
# milestone: sage-duplicate/invalid/wontfix, sage-feature,
66
# sage-pending, sage-wishlist, or sage-VERSION_NUMBER
67
# (e.g. sage-6.0)
68
# component: algebra, algebraic geometry, algebraic topology, basic
69
# arithmetic, build, calculus, categories, c_lib, coding
70
# theory, coercion, combinatorics, commutative algebra,
71
# cryptography, cython, distribution, doctest coverage,
72
# doctest framework, documentation, elliptic curves,
73
# factorization, finance, finite rings, fractals, geometry,
74
# graphics, graph theory, group theory, interact,
75
# interfaces, linear algebra, linear programming, matroid
76
# theory, memleak, misc, modular forms, notebook, number
77
# fields, number theory, numerical, packages: experimental,
78
# packages: huge, packages: optional, packages: standard,
79
# padics, performance, pickling, PLEASE CHANGE, porting,
80
# porting: AIX or HP-UX, porting: BSD, porting: Cygwin,
81
# porting: Solaris, quadratic forms, relocation, scripts,
82
# spkg-check, statistics, symbolics, user interface,
83
# website/wiki
84
"""
85
RESTRICTED_FIELDS = ["priority", "status", "type", "milestone", "component"]
86
ALLOWED_VALUES = {}
87
_a = 0
88
for _i, field in enumerate(RESTRICTED_FIELDS):
89
_a = TICKET_FILE_GUIDE.find(field + ":", _a) + len(field) + 2
90
if _i + 1 == len(RESTRICTED_FIELDS):
91
_b = -1
92
else:
93
_b = TICKET_FILE_GUIDE.find(RESTRICTED_FIELDS[_i+1], _a)
94
if field != "milestone":
95
ALLOWED_VALUES[field] = re.compile(TICKET_FILE_GUIDE[_a:_b].replace("\n# ","").replace("\n# ","").strip().replace(', ','|'))
96
ALLOWED_VALUES['milestone'] = re.compile("sage-(duplicate/invalid/wontfix|feature|pending|wishlist|\d+\.\d+)")
97
COMMENT_FILE_GUIDE = r"""
98
# Lines starting with `#` are ignored.
99
# An empty file aborts the comment.
100
"""
101
102
def _timerep(tm, curtime=None):
103
"""
104
Prints the given time in terms of a rounded-down number of time-units ago.
105
106
INPUT:
107
108
- ``tm`` -- a datetime.datetime instance
109
- ``curtime`` -- the current time as a datetime.datetime instance;
110
if ``None`` then it will be computed using time.gmtime()
111
112
EXAMPLES::
113
114
sage: from sage.dev.trac_interface import _timerep
115
sage: import datetime
116
sage: curtime = datetime.datetime(2013, 9, 5, 16)
117
sage: T = []
118
sage: T.append(datetime.datetime(2013, 9, 5, 15, 54, 22))
119
sage: T.append(datetime.datetime(2013, 9, 5, 10, 17, 17))
120
sage: T.append(datetime.datetime(2013, 8, 25, 9))
121
sage: T.append(datetime.datetime(2012, 11, 19))
122
sage: T.append(datetime.datetime(2010, 1, 2))
123
sage: for t in T: print _timerep(t, curtime)
124
5 minutes ago
125
5 hours ago
126
11 days ago
127
9 months ago
128
3 years ago
129
"""
130
year = datetime.timedelta(365)
131
month = datetime.timedelta(30)
132
day = datetime.timedelta(1)
133
hour = datetime.timedelta(0,3600)
134
minute = datetime.timedelta(0,60)
135
second = datetime.timedelta(0,1)
136
timelist = [(year, "year"), (month, "month"), (day, "day"), (hour, "hour"), (minute, "minute"), (second, "second")]
137
def timediv(a, b):
138
# Our version of datetime doesn't implement //
139
x = a.total_seconds() / b.total_seconds()
140
if x >= 0: return int(x)
141
raise NotImplementedError
142
if curtime is None:
143
curtime = datetime.datetime(*(time.gmtime()[:6]))
144
diff = curtime - tm
145
if diff.total_seconds() < 0:
146
return "in the future"
147
for period, name in timelist:
148
n = timediv(diff, period)
149
if n > 0:
150
return "%s %s%s ago"%(n, name, "s" if n > 1 else "")
151
152
class TicketSyntaxError(SyntaxError): # we don't want to catch normal syntax errors
153
r"""
154
A syntax error when parsing a ticket description modified by the user.
155
156
EXAMPLES::
157
158
sage: from sage.dev.trac_interface import TicketSyntaxError
159
sage: raise TicketSyntaxError()
160
Traceback (most recent call last):
161
...
162
TicketSyntaxError: None
163
"""
164
165
class TracInterface(object):
166
r"""
167
Wrapper around the XML-RPC interface of trac.
168
169
EXAMPLES::
170
171
sage: from sage.dev.test.config import DoctestConfig
172
sage: from sage.dev.test.user_interface import DoctestUserInterface
173
sage: from sage.dev.trac_interface import TracInterface
174
sage: config = DoctestConfig()
175
sage: trac = TracInterface(config['trac'], DoctestUserInterface(config['UI']))
176
sage: trac
177
<sage.dev.trac_interface.TracInterface object at 0x...>
178
"""
179
def __init__(self, config, UI):
180
r"""
181
Initialization.
182
183
TESTS::
184
185
sage: from sage.dev.test.config import DoctestConfig
186
sage: from sage.dev.test.user_interface import DoctestUserInterface
187
sage: from sage.dev.trac_interface import TracInterface
188
sage: config = DoctestConfig()
189
sage: trac = TracInterface(config['trac'], DoctestUserInterface(config['UI']))
190
sage: type(trac)
191
<class 'sage.dev.trac_interface.TracInterface'>
192
"""
193
self._UI = UI
194
self._config = config
195
196
self.__anonymous_server_proxy = None
197
self.__authenticated_server_proxy = None
198
199
self.__username = None
200
self.__password = None
201
self.__auth_timeout = None
202
203
# cache data of tickets locally here
204
# this cache is only used to speed
205
# up SageDev's local_tickets(), it is not doing any good invalidation
206
# and should never be relied on for something other than informational
207
# messages
208
import os
209
from saving_dict import SavingDict
210
from sage.env import DOT_SAGE
211
ticket_cache_file = self._config.get('ticket_cache', os.path.join(DOT_SAGE,'ticket_cache'))
212
self.__ticket_cache = SavingDict(ticket_cache_file)
213
214
@property
215
def _username(self):
216
r"""
217
A lazy property to get the username on trac.
218
219
EXAMPLES::
220
221
sage: from sage.dev.test.config import DoctestConfig
222
sage: from sage.dev.test.user_interface import DoctestUserInterface
223
sage: from sage.dev.trac_interface import TracInterface
224
sage: config = DoctestConfig()
225
sage: UI = DoctestUserInterface(config['UI'])
226
sage: trac = TracInterface(config['trac'], UI)
227
sage: trac._username # username is read from config
228
'doctest'
229
sage: trac.reset_username()
230
sage: UI.append('doctest2')
231
sage: trac._username # user is prompted for a username
232
Trac username: doctest2
233
# Your trac username has been written to a configuration file for future
234
sessions. To reset your username, use "dev.trac.reset_username()".
235
'doctest2'
236
sage: config['trac']['username']
237
'doctest2'
238
"""
239
if self.__username is None:
240
self.__username = self._config.get('username', None)
241
242
if self.__username is None:
243
self.__username = self._config['username'] = self._UI.get_input('Trac username:')
244
self._UI.info('Your trac username has been written to a configuration file for'
245
' future sessions. To reset your username, use "dev.trac.reset_username()".')
246
return self.__username
247
248
def reset_username(self):
249
r"""
250
Reset username and password stored in this object and in the
251
configuration.
252
253
EXAMPLES::
254
255
sage: from sage.dev.test.config import DoctestConfig
256
sage: from sage.dev.test.user_interface import DoctestUserInterface
257
sage: from sage.dev.trac_interface import TracInterface
258
sage: config = DoctestConfig()
259
sage: UI = DoctestUserInterface(config['UI'])
260
sage: trac = TracInterface(config['trac'], UI)
261
sage: trac.reset_username()
262
sage: UI.append("doctest2")
263
sage: trac._username
264
Trac username: doctest2
265
# Your trac username has been written to a configuration file for future
266
sessions. To reset your username, use "dev.trac.reset_username()".
267
'doctest2'
268
"""
269
self.__username = None
270
if 'username' in self._config:
271
del self._config['username']
272
273
self.reset_password()
274
275
@property
276
def _password(self):
277
r"""
278
A lazy property to get the password for trac.
279
280
EXAMPLES::
281
282
sage: from sage.dev.test.config import DoctestConfig
283
sage: from sage.dev.test.user_interface import DoctestUserInterface
284
sage: from sage.dev.trac_interface import TracInterface
285
sage: config = DoctestConfig()
286
sage: UI = DoctestUserInterface(config['UI'])
287
sage: trac = TracInterface(config['trac'], UI)
288
sage: UI.append('')
289
sage: UI.append('secret')
290
sage: trac._password
291
Trac password:
292
You can save your password in a configuration file. However, this file might be
293
readable by privileged users on this system.
294
Save password in file? [yes/No]
295
'secret'
296
sage: trac._password # password is stored for some time, so there is no need to type it immediately afterwards
297
'secret'
298
sage: config['trac']['password']
299
Traceback (most recent call last):
300
...
301
KeyError: 'password'
302
sage: trac.reset_password()
303
304
sage: UI.append('y')
305
sage: UI.append('secret')
306
sage: trac._password
307
Trac password:
308
You can save your password in a configuration file. However, this file might be
309
readable by privileged users on this system.
310
Save password in file? [yes/No] y
311
# Your trac password has been written to a configuration file. To reset your
312
password, use "dev.trac.reset_password()".
313
'secret'
314
sage: config['trac']['password']
315
'secret'
316
sage: trac._password
317
'secret'
318
"""
319
self._check_password_timeout()
320
321
if self.__password is None:
322
self.__password = self._config.get('password', None)
323
324
if self.__password is None:
325
self.__password = self._UI.get_password('Trac password:')
326
store_password = self._config.get('store_password', None)
327
if store_password is None:
328
self._UI.show("You can save your password in a configuration file."
329
" However, this file might be readable by privileged"
330
" users on this system.")
331
store_password = "yes" if self._UI.confirm(
332
"Save password in file?", default=False) else "no"
333
if store_password == "no":
334
# remember the user's decision (if negative) and do not ask every time
335
self._config['store_password'] = store_password
336
if store_password == "yes":
337
self._config['password'] = self.__password
338
self._UI.info('Your trac password has been written to a configuration file. To reset'
339
' your password, use "dev.trac.reset_password()".')
340
self._postpone_password_timeout()
341
return self.__password
342
343
def reset_password(self):
344
r"""
345
Reset password stored in this object and in the configuration.
346
347
EXAMPLES::
348
349
sage: from sage.dev.test.config import DoctestConfig
350
sage: from sage.dev.test.user_interface import DoctestUserInterface
351
sage: from sage.dev.trac_interface import TracInterface
352
sage: config = DoctestConfig()
353
sage: UI = DoctestUserInterface(config['UI'])
354
sage: trac = TracInterface(config['trac'], UI)
355
sage: UI.append('y')
356
sage: UI.append('secret')
357
sage: trac._password
358
Trac password:
359
You can save your password in a configuration file. However, this file might be
360
readable by privileged users on this system.
361
Save password in file? [yes/No] y
362
# Your trac password has been written to a configuration file. To reset your
363
password, use "dev.trac.reset_password()".
364
'secret'
365
sage: config['trac']['password']
366
'secret'
367
sage: trac.reset_password()
368
sage: config['trac']['password']
369
Traceback (most recent call last):
370
...
371
KeyError: 'password'
372
"""
373
self.__password = None
374
self.__authenticated_server_proxy = None
375
self.__auth_timeout = None
376
if 'password' in self._config:
377
del self._config['password']
378
if 'store_password' in self._config:
379
del self._config['store_password']
380
381
def _check_password_timeout(self):
382
r"""
383
Reset all attributes that depend on the saved password if it has timed
384
out (usually after 5 minutes without using it).
385
386
EXAMPLES::
387
388
sage: from sage.dev.test.config import DoctestConfig
389
sage: from sage.dev.test.user_interface import DoctestUserInterface
390
sage: from sage.dev.trac_interface import TracInterface
391
sage: config = DoctestConfig()
392
sage: UI = DoctestUserInterface(config['UI'])
393
sage: trac = TracInterface(config['trac'], UI)
394
sage: UI.append('')
395
sage: UI.append('secret')
396
sage: config['trac']['password_timeout'] = 0
397
sage: trac._password
398
Trac password:
399
You can save your password in a configuration file. However, this file might be
400
readable by privileged users on this system.
401
Save password in file? [yes/No]
402
'secret'
403
sage: UI.append('secret')
404
sage: trac._password # indirect doctest
405
Trac password:
406
'secret'
407
sage: trac.reset_password()
408
sage: UI.append('y')
409
sage: UI.append('secret')
410
sage: trac._password
411
Trac password:
412
You can save your password in a configuration file. However, this file might be
413
readable by privileged users on this system.
414
Save password in file? [yes/No] y
415
# Your trac password has been written to a configuration file. To reset your
416
password, use "dev.trac.reset_password()".
417
'secret'
418
419
The timeout has no effect if the password can be read from the
420
configuration file::
421
422
sage: trac._password
423
'secret'
424
"""
425
import time
426
if self.__auth_timeout is None or time.time() >= self.__auth_timeout:
427
self.__password = None
428
self.__authenticated_server_proxy = None
429
self.__auth_timeout = None
430
431
def _postpone_password_timeout(self):
432
r"""
433
Postpone the password timeout.
434
435
EXAMPLES::
436
437
sage: from sage.dev.test.config import DoctestConfig
438
sage: from sage.dev.test.user_interface import DoctestUserInterface
439
sage: from sage.dev.trac_interface import TracInterface
440
sage: config = DoctestConfig()
441
sage: UI = DoctestUserInterface(config['UI'])
442
sage: trac = TracInterface(config['trac'], UI)
443
sage: UI.append('')
444
sage: UI.append('secret')
445
sage: trac._password
446
Trac password:
447
You can save your password in a configuration file. However, this file might be
448
readable by privileged users on this system.
449
Save password in file? [yes/No]
450
'secret'
451
sage: trac._password # indirect doctest
452
'secret'
453
"""
454
import time
455
new_timeout = time.time() + float(self._config.get('password_timeout', 300))
456
457
if self.__auth_timeout is None or new_timeout > self.__auth_timeout:
458
self.__auth_timeout = new_timeout
459
460
@property
461
def _anonymous_server_proxy(self):
462
"""
463
Return a non-authenticated XML-RPC interface to trac.
464
465
.. NOTE::
466
467
Unlike the authenticated server proxy, this can be used in
468
doctesting. However, all doctests relying on it talking to the
469
actual trac server should be marked as ``optional: internet``.
470
471
EXAMPLES::
472
473
sage: from sage.dev.test.config import DoctestConfig
474
sage: from sage.dev.test.user_interface import DoctestUserInterface
475
sage: from sage.dev.trac_interface import TracInterface
476
sage: config = DoctestConfig()
477
sage: trac = TracInterface(config['trac'], DoctestUserInterface(config['UI']))
478
sage: repr(trac._anonymous_server_proxy)
479
'<ServerProxy for trac.sagemath.org/xmlrpc>'
480
"""
481
if self.__anonymous_server_proxy is None:
482
from sage.env import TRAC_SERVER_URI
483
server = self._config.get('server', TRAC_SERVER_URI)
484
import urlparse
485
url = urlparse.urljoin(server, 'xmlrpc')
486
from digest_transport import DigestTransport
487
transport = DigestTransport()
488
from xmlrpclib import ServerProxy
489
self.__anonymous_server_proxy = ServerProxy(url, transport=transport)
490
491
return self.__anonymous_server_proxy
492
493
@property
494
def _authenticated_server_proxy(self):
495
r"""
496
Get an XML-RPC proxy object that is authenticated using the users
497
username and password.
498
499
EXAMPLES::
500
501
sage: dev.trac._authenticated_server_proxy # not tested
502
Trac username: username
503
Trac password:
504
Should I store your password in a configuration file for future sessions? (This configuration file might be readable by privileged users on this system.) [yes/No]
505
<ServerProxy for trac.sagemath.org/login/xmlrpc>
506
507
TESTS:
508
509
To make sure that doctests do not tamper with the live trac
510
server, it is an error to access this property during a
511
doctest (The ``dev`` object during doctests is also modified
512
to prevent this)::
513
514
sage: from sage.dev.test.config import DoctestConfig
515
sage: from sage.dev.test.user_interface import DoctestUserInterface
516
sage: from sage.dev.trac_interface import TracInterface
517
sage: config = DoctestConfig()
518
sage: trac = TracInterface(config['trac'], DoctestUserInterface(config['UI']))
519
sage: trac._authenticated_server_proxy
520
Traceback (most recent call last):
521
...
522
AssertionError: doctest tried to access an authenticated session to trac
523
"""
524
import sage.doctest
525
assert not sage.doctest.DOCTEST_MODE, \
526
"doctest tried to access an authenticated session to trac"
527
528
self._check_password_timeout()
529
530
if self.__authenticated_server_proxy is None:
531
from sage.env import REALM
532
realm = self._config.get('realm', REALM)
533
from sage.env import TRAC_SERVER_URI
534
server = self._config.get('server', TRAC_SERVER_URI)
535
import os, urllib, urllib2, urlparse
536
url = urlparse.urljoin(server, urllib.pathname2url(os.path.join('login', 'xmlrpc')))
537
while True:
538
from xmlrpclib import ServerProxy
539
from digest_transport import DigestTransport
540
from trac_error import TracAuthenticationError
541
transport = DigestTransport()
542
transport.add_authentication(realm=realm, url=server, username=self._username, password=self._password)
543
proxy = ServerProxy(url, transport=transport)
544
try:
545
proxy.system.listMethods()
546
break
547
except TracAuthenticationError:
548
self._UI.error("Invalid username/password")
549
self.reset_username()
550
self.__authenticated_server_proxy = proxy
551
self._postpone_password_timeout()
552
return self.__authenticated_server_proxy
553
554
def create_ticket(self, summary, description, attributes={}):
555
r"""
556
Create a ticket on trac and return the new ticket number.
557
558
.. SEEALSO::
559
560
:meth:`create_ticket_interactive`
561
562
EXAMPLES::
563
564
sage: from sage.dev.test.config import DoctestConfig
565
sage: from sage.dev.test.user_interface import DoctestUserInterface
566
sage: from sage.dev.test.trac_interface import DoctestTracInterface
567
sage: from sage.dev.test.trac_server import DoctestTracServer
568
sage: config = DoctestConfig()
569
sage: config['trac']['password'] = 'secret'
570
sage: UI = DoctestUserInterface(config['UI'])
571
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
572
sage: trac.create_ticket('Summary', 'Description', {'type':'defect', 'component':'algebra'})
573
1
574
"""
575
return self._authenticated_server_proxy.ticket.create(
576
summary, description, attributes, True) # notification e-mail sent.
577
578
def add_comment(self, ticket, comment):
579
r"""
580
Add ``comment`` to ``ticket`` on trac.
581
582
.. SEEALSO::
583
584
:meth:`add_comment_interactive`
585
586
EXAMPLES::
587
588
sage: from sage.dev.test.config import DoctestConfig
589
sage: from sage.dev.test.user_interface import DoctestUserInterface
590
sage: from sage.dev.test.trac_interface import DoctestTracInterface
591
sage: from sage.dev.test.trac_server import DoctestTracServer
592
sage: config = DoctestConfig()
593
sage: config['trac']['password'] = 'secret'
594
sage: UI = DoctestUserInterface(config['UI'])
595
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
596
sage: ticket = trac.create_ticket('Summary', 'Description', {'type':'defect', 'component':'algebra'})
597
sage: trac.add_comment(ticket, "a comment")
598
"""
599
ticket = int(ticket)
600
attributes = self._get_attributes(ticket)
601
self._authenticated_server_proxy.ticket.update(ticket, comment, attributes, True) # notification e-mail sent
602
603
def _get_attributes(self, ticket, cached=False):
604
r"""
605
Retrieve the properties of ``ticket``.
606
607
INPUT:
608
609
- ``ticket`` -- an integer, the number of a ticket
610
611
- ``cached`` -- a boolean (default: ``False``), whether to take the
612
attributes from a local cache; used, e.g., by
613
:meth:`sagedev.SageDev.local_tickets` to speedup display of ticket
614
summaries.
615
616
OUTPUT:
617
618
Raises a ``KeyError`` if ``cached`` is ``True`` and the ticket could
619
not be found in the cache.
620
621
EXAMPLES::
622
623
sage: from sage.dev.test.sagedev import single_user_setup_with_internet
624
sage: dev = single_user_setup_with_internet()[0]
625
sage: dev.trac._get_attributes(1000) # optional: internet
626
{'status': 'closed',
627
'changetime': <DateTime '...' at ...>,
628
'description': '...',
629
'reporter': 'was',
630
'cc': '',
631
'type': 'defect',
632
'milestone': 'sage-2.10',
633
'_ts': '...',
634
'component': 'distribution',
635
'summary': 'Sage does not have 10000 users yet.',
636
'priority': 'major',
637
'owner': 'was',
638
'time': <DateTime '20071025T16:48:05' at ...>,
639
'keywords': '',
640
'resolution': 'fixed'}
641
"""
642
ticket = int(ticket) # must not pickle Sage integers in the ticket cache!
643
if not cached:
644
self.__ticket_cache[ticket] = self._anonymous_server_proxy.ticket.get(ticket)
645
if ticket not in self.__ticket_cache:
646
raise KeyError(ticket)
647
return self.__ticket_cache[ticket][3]
648
649
def show_ticket(self, ticket):
650
r"""
651
Show the important fields of the given ticket.
652
653
.. SEEALSO::
654
655
:meth:`_get_attributes`
656
:meth:`show_comments`
657
658
EXAMPLES::
659
660
sage: from sage.dev.test.sagedev import single_user_setup_with_internet
661
sage: dev = single_user_setup_with_internet()[0]
662
sage: dev.trac.show_ticket(101) # optional: internet
663
#101: closed enhancement
664
== graph theory -- create a graph theory package for SAGE ==
665
Opened: ... years ago
666
Closed: ... years ago
667
Priority: major
668
Milestone: sage-2.8.5
669
Component: combinatorics
670
----------------------------------------
671
See http://sage.math.washington.edu:9001/graph for
672
initial research that Robert Miller and Emily Kirkman are doing on this.
673
"""
674
a = self._get_attributes(ticket)
675
opentime = datetime.datetime(*(a['time'].timetuple()[:6]))
676
changetime = datetime.datetime(*(a['changetime'].timetuple()[:6]))
677
fields = ['Opened: %s'%(_timerep(opentime))]
678
if a['status'] == 'closed':
679
fields.append('Closed: %s'%(_timerep(changetime)))
680
else:
681
fields.append('Last modified: %s'%(_timerep(changetime)))
682
def cap(fieldname):
683
if fieldname == 'reporter': return "Reported by"
684
elif fieldname == 'merged': return "Merged in"
685
elif fieldname == 'work_issues': return "Work issues"
686
elif fieldname == 'upstream': return "Report upstream"
687
return fieldname.capitalize()
688
def add_field(fieldname):
689
try:
690
fieldval = a[fieldname]
691
if fieldval not in ['', 'N/A']:
692
fields.append(ALLOWED_FIELDS[fieldname] + ': ' + fieldval)
693
except KeyError:
694
pass
695
for field in ['priority','milestone','component','cc','merged','author',
696
'reviewer','upstream','work_issues','branch','dependencies','stopgaps']:
697
add_field(field)
698
self._UI.show("#%s: %s %s\n== %s ==\n%s\n%s\n%s"%(ticket, a['status'], a['type'], a['summary'],
699
'\n'.join(fields), '-'*40, a['description']))
700
701
def show_comments(self, ticket, ignore_git_user=True):
702
"""
703
Shows the comments on a given ticket.
704
705
INPUT:
706
707
- ``ticket`` -- the ticket number
708
- ``ignore_git_user`` -- whether to remove comments
709
automatically added when the branch is updated.
710
711
EXAMPLES::
712
713
sage: dev.trac.show_comments(100) # optional: internet
714
====================
715
was (6 years ago)
716
fixed
717
"""
718
comments = []
719
changelog = self._anonymous_server_proxy.ticket.changeLog(int(ticket))
720
for time, author, field, oldvalue, newvalue, permanent in changelog:
721
if field == 'comment' and newvalue and not (ignore_git_user and author == 'git'):
722
comments.append((_timerep(datetime.datetime(*(time.timetuple()[:6]))), author, newvalue))
723
self._UI.show('\n'.join(['====================\n{0} ({1})\n{2}'.format(author, time, comment)
724
for time, author, comment in reversed(comments)]))
725
726
def query(self, qstr):
727
"""
728
Return a list of ticket ids that match the given query string.
729
730
INPUT:
731
732
- ``qstr`` -- a query string. All queries will use stored
733
settings for maximum number of results per page and paging
734
options. Use max=n to define number of results to receive,
735
and use page=n to page through larger result sets. Using
736
max=0 will turn off paging and return all results.
737
738
EXAMPLES::
739
740
sage: dev.trac.query('status!=closed&(component=padics||component=misc)&max=3') # random, optional: internet
741
[329, 15130, 21]
742
"""
743
return self._anonymous_server_proxy.ticket.query(qstr)
744
745
def _branch_for_ticket(self, ticket):
746
r"""
747
Return the branch field for ``ticket`` or ``None`` if it is not set.
748
749
INPUT:
750
751
- ``ticket`` -- an int
752
753
EXAMPLES::
754
755
sage: from sage.dev.test.sagedev import single_user_setup_with_internet
756
sage: dev = single_user_setup_with_internet()[0]
757
sage: dev.trac._branch_for_ticket(1000) is None # optional: internet
758
True
759
"""
760
attributes = self._get_attributes(ticket)
761
if 'branch' in attributes:
762
return attributes['branch'] or None
763
else:
764
return None
765
766
def dependencies(self, ticket, recurse=False, seen=None):
767
r"""
768
Retrieve dependencies of ``ticket``, sorted by ticket number.
769
770
INPUT:
771
772
- ``ticket`` -- an integer, the number of the ticket
773
774
- ``recurse`` -- a boolean (default: ``False``), whether to get
775
indirect dependencies of ``ticket``
776
777
- ``seen`` -- a list (default: ``[]``), used internally to implement
778
``recurse``
779
780
EXAMPLES::
781
782
sage: from sage.dev.test.sagedev import single_user_setup_with_internet
783
sage: dev = single_user_setup_with_internet()[0]
784
sage: dev.trac.dependencies(1000) # optional: internet (an old ticket with no dependency field)
785
[]
786
sage: dev.trac.dependencies(13147) # optional: internet
787
[13579, 13681]
788
sage: dev.trac.dependencies(13147, recurse=True) # long time, optional: internet
789
[13579, 13631, 13681]
790
"""
791
ticket = int(ticket)
792
if seen is None:
793
seen = []
794
if ticket in seen:
795
return []
796
seen.append(ticket)
797
dependencies = self._get_attributes(ticket).get('dependencies','').strip()
798
dependencies = dependencies.split(',')
799
dependencies = [dep.strip() for dep in dependencies]
800
dependencies = [dep for dep in dependencies if dep]
801
if not all(dep[0]=="#" for dep in dependencies):
802
raise RuntimeError("malformatted dependency on ticket `%s`"%ticket)
803
dependencies = [dep[1:] for dep in dependencies]
804
try:
805
dependencies = [int(dep) for dep in dependencies]
806
except ValueError:
807
raise RuntimeError("malformatted dependency on ticket `%s`"%ticket)
808
809
if recurse:
810
for dep in dependencies:
811
self.dependencies(dep, recurse, seen)
812
else:
813
seen.extend(dependencies)
814
815
ret = sorted(seen)
816
ret.remove(ticket)
817
return ret
818
819
def attachment_names(self, ticket):
820
"""
821
Retrieve the names of the attachments for ``ticket``.
822
823
EXAMPLES::
824
825
sage: dev.trac.attachment_names(1000) # optional: internet
826
()
827
sage: dev.trac.attachment_names(13147) # optional: internet
828
('13147_move.patch',
829
'13147_lazy.patch',
830
'13147_lazy_spkg.patch',
831
'13147_new.patch',
832
'13147_over_13579.patch',
833
'trac_13147-ref.patch',
834
'trac_13147-rebased-to-13681.patch',
835
'trac_13681_root.patch')
836
"""
837
ticket = int(ticket)
838
return tuple(a[0] for a in self._anonymous_server_proxy.ticket.listAttachments(ticket))
839
840
def add_comment_interactive(self, ticket, comment=''):
841
r"""
842
Add a comment to ``ticket`` on trac.
843
844
INPUT:
845
846
- ``comment`` -- a string (default: ``''``), the default value for the
847
comment to add.
848
849
EXAMPLES::
850
851
sage: from sage.dev.test.config import DoctestConfig
852
sage: from sage.dev.test.user_interface import DoctestUserInterface
853
sage: from sage.dev.test.trac_interface import DoctestTracInterface
854
sage: from sage.dev.test.trac_server import DoctestTracServer
855
sage: config = DoctestConfig()
856
sage: config['trac']['password'] = 'secret'
857
sage: UI = DoctestUserInterface(config['UI'])
858
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
859
sage: ticket = trac.create_ticket('Summary', 'Description', {'type':'defect', 'component':'algebra'})
860
861
sage: UI.append("# empty comment")
862
sage: trac.add_comment_interactive(ticket)
863
Traceback (most recent call last):
864
...
865
OperationCancelledError: comment creation aborted
866
867
sage: UI.append("a comment")
868
sage: trac.add_comment_interactive(ticket)
869
"""
870
ticket = int(ticket)
871
872
attributes = self._get_attributes(ticket)
873
874
from sage.dev.misc import tmp_filename
875
filename = tmp_filename()
876
with open(filename, "w") as f:
877
f.write(comment)
878
f.write("\n")
879
f.write(COMMENT_FILE_GUIDE)
880
self._UI.edit(filename)
881
882
comment = list(open(filename).read().splitlines())
883
comment = [line for line in comment if not line.startswith("#")]
884
if all([line.strip()=="" for line in comment]):
885
from user_interface_error import OperationCancelledError
886
raise OperationCancelledError("comment creation aborted")
887
comment = "\n".join(comment)
888
889
url = self._authenticated_server_proxy.ticket.update(ticket, comment, attributes, True) # notification e-mail sent
890
self._UI.debug("Your comment has been recorded: %s"%url)
891
892
def edit_ticket_interactive(self, ticket):
893
r"""
894
Edit ``ticket`` on trac.
895
896
EXAMPLES::
897
898
sage: from sage.dev.test.config import DoctestConfig
899
sage: from sage.dev.test.user_interface import DoctestUserInterface
900
sage: from sage.dev.test.trac_interface import DoctestTracInterface
901
sage: from sage.dev.test.trac_server import DoctestTracServer
902
sage: config = DoctestConfig()
903
sage: config['trac']['password'] = 'secret'
904
sage: UI = DoctestUserInterface(config['UI'])
905
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
906
sage: ticket = trac.create_ticket('Summary', 'Description', {'type':'defect', 'component':'algebra'})
907
908
sage: UI.append("# empty")
909
sage: trac.edit_ticket_interactive(ticket)
910
Traceback (most recent call last):
911
...
912
OperationCancelledError: ticket edit aborted
913
914
sage: UI.append("Summary: summary\ndescription\n")
915
sage: trac.edit_ticket_interactive(ticket)
916
"""
917
ticket = int(ticket)
918
attributes = self._get_attributes(ticket)
919
summary = attributes.get('summary', 'No Summary')
920
description = attributes.get('description', 'No Description')
921
922
ret = self._edit_ticket_interactive(summary, description, attributes)
923
if ret is None:
924
from user_interface_error import OperationCancelledError
925
raise OperationCancelledError("edit aborted")
926
927
attributes['summary'] = ret[0]
928
attributes['description'] = ret[1]
929
attributes.update(ret[2])
930
931
url = self._authenticated_server_proxy.ticket.update(ticket, "", attributes, True) # notification e-mail sent.
932
self._UI.debug("Ticket modified: %s"%url)
933
934
def _edit_ticket_interactive(self, summary, description, attributes):
935
r"""
936
Helper method for :meth:`edit_ticket_interactive` and
937
:meth:`create_ticket_interactive`.
938
939
INPUT:
940
941
- ``summary`` -- a string, summary of ticket
942
943
- ``description`` -- a string, description of ticket
944
945
- ``attributes`` -- dictionary containing field, value pairs
946
947
OUTPUT:
948
949
A tuple ``(summary, description, attributes)``, the updated version of
950
input after user has edited the ticket.
951
952
TESTS::
953
954
sage: from sage.dev.test.config import DoctestConfig
955
sage: from sage.dev.test.user_interface import DoctestUserInterface
956
sage: from sage.dev.trac_interface import TracInterface
957
sage: config = DoctestConfig()
958
sage: UI = DoctestUserInterface(config['UI'])
959
sage: trac = TracInterface(config['trac'], UI)
960
sage: UI.append("# abort")
961
sage: trac._edit_ticket_interactive('summary', 'description', {'branch':'branch1'})
962
Traceback (most recent call last):
963
...
964
OperationCancelledError: ticket edit aborted
965
966
sage: UI.append("Summary: new summary\nBranch: branch2\nnew description")
967
sage: trac._edit_ticket_interactive('summary', 'description', {'branch':'branch1'})
968
('new summary', 'new description', {'branch': 'branch2'})
969
970
sage: UI.append("Summary: new summary\nBranch: branch2\nnew description")
971
sage: UI.append("")
972
sage: UI.append("Summary: new summary\nInvalid: branch2\nnew description")
973
sage: trac._edit_ticket_interactive('summary', 'description', {'branch':'branch1'})
974
Syntax error: field "Invalid" not supported on line 2
975
Edit ticket file again? [Yes/no]
976
('new summary', 'new description', {'branch': 'branch2'})
977
"""
978
from sage.dev.misc import tmp_filename
979
filename = tmp_filename()
980
try:
981
with open(filename, "w") as F:
982
F.write("Summary: %s\n"%summary.encode('utf-8'))
983
for k,v in attributes.items():
984
k = ALLOWED_FIELDS.get(k.lower())
985
if k is not None:
986
F.write("%s: %s\n"%(k.encode('utf-8'),v.encode('utf-8')))
987
988
if description is None or not description.strip():
989
description = "\nADD DESCRIPTION\n"
990
F.write("\n" + description.encode('utf-8') + "\n")
991
F.write(TICKET_FILE_GUIDE)
992
993
while True:
994
try:
995
self._UI.edit(filename)
996
ret = self._parse_ticket_file(filename)
997
break
998
except (RuntimeError, TicketSyntaxError) as error:
999
pass
1000
1001
self._UI.error("Syntax error: " + error.message)
1002
1003
if not self._UI.confirm("Edit ticket file again?", default=True):
1004
ret = None
1005
break
1006
1007
if ret is None:
1008
from user_interface_error import OperationCancelledError
1009
raise OperationCancelledError("ticket edit aborted")
1010
1011
finally:
1012
os.unlink(filename)
1013
return ret
1014
1015
def create_ticket_interactive(self):
1016
r"""
1017
Drop user into an editor for creating a ticket.
1018
1019
EXAMPLE::
1020
1021
sage: from sage.dev.test.config import DoctestConfig
1022
sage: from sage.dev.test.user_interface import DoctestUserInterface
1023
sage: from sage.dev.test.trac_interface import DoctestTracInterface
1024
sage: from sage.dev.test.trac_server import DoctestTracServer
1025
sage: config = DoctestConfig()
1026
sage: config['trac']['password'] = 'secret'
1027
sage: UI = DoctestUserInterface(config['UI'])
1028
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
1029
sage: UI.append("Summary: summary\nType: defect\nPriority: minor\nComponent: algebra\ndescription")
1030
sage: trac.create_ticket_interactive()
1031
1
1032
"""
1033
attributes = {
1034
"Type": "defect",
1035
"Priority": "major",
1036
"Component": "PLEASE CHANGE",
1037
"Reporter": self._username
1038
}
1039
1040
ret = self._edit_ticket_interactive("", None, attributes)
1041
1042
if ret is None:
1043
from user_interface_error import OperationCancelledError
1044
raise OperationCancelledError("ticket creation aborted")
1045
1046
ticket = self.create_ticket(*ret)
1047
import urlparse
1048
from sage.env import TRAC_SERVER_URI
1049
ticket_url = urlparse.urljoin(self._config.get('server', TRAC_SERVER_URI), str(ticket))
1050
self._UI.debug("Created ticket #%s (%s)."%(ticket, ticket_url))
1051
return ticket
1052
1053
@classmethod
1054
def _parse_ticket_file(cls, filename):
1055
r"""
1056
Parse ticket file ``filename``, helper for
1057
:meth:`create_ticket_interactive` and :meth:`edit_ticket_interactive`.
1058
1059
OUTPUT:
1060
1061
``None`` if the filename contains only comments; otherwise a triple
1062
``(summary, description, attributes)``, where ``summary`` is a string
1063
consisting of the ticket's summary, ``description`` is a string
1064
containing the ticket's description, and ``attributes`` is a dictionary
1065
with additional fields of the ticket.
1066
1067
TESTS::
1068
1069
sage: from sage.dev.trac_interface import TracInterface
1070
sage: tmp = tmp_filename()
1071
sage: with open(tmp, 'w') as f:
1072
....: f.write("no summary\n")
1073
sage: TracInterface._parse_ticket_file(tmp)
1074
Traceback (most recent call last):
1075
...
1076
TicketSyntaxError: no valid summary found
1077
sage: with open(tmp, 'w') as f:
1078
....: f.write("summary:no description\n")
1079
sage: TracInterface._parse_ticket_file(tmp)
1080
Traceback (most recent call last):
1081
...
1082
TicketSyntaxError: no description found
1083
sage: with open(tmp, 'w') as f:
1084
....: f.write("summary:double summary\n")
1085
....: f.write("summary:double summary\n")
1086
sage: TracInterface._parse_ticket_file(tmp)
1087
Traceback (most recent call last):
1088
...
1089
TicketSyntaxError: only one value for "summary" allowed on line 2
1090
sage: with open(tmp, 'w') as f:
1091
....: f.write("bad field:bad field entry\n")
1092
sage: TracInterface._parse_ticket_file(tmp)
1093
Traceback (most recent call last):
1094
...
1095
TicketSyntaxError: field "bad field" not supported on line 1
1096
sage: with open(tmp, 'w') as f:
1097
....: f.write("summary:a summary\n")
1098
....: f.write("branch:a branch\n")
1099
....: f.write("some description\n")
1100
....: f.write("#an ignored line\n")
1101
....: f.write("more description\n")
1102
....: f.write("\n")
1103
sage: TracInterface._parse_ticket_file(tmp)
1104
('a summary', 'some description\nmore description', {'branch': 'a branch'})
1105
sage: with open(tmp, 'w') as f:
1106
....: f.write("summary:a summary\n")
1107
....: f.write("some description\n")
1108
....: f.write("branch:a branch\n")
1109
sage: TracInterface._parse_ticket_file(tmp)
1110
('a summary', 'some description\nbranch:a branch', {})
1111
sage: os.unlink(tmp)
1112
"""
1113
lines = list(open(filename).read().splitlines())
1114
1115
if all(l.rstrip().startswith('#') for l in lines if l.rstrip()):
1116
return
1117
1118
fields = {}
1119
for i, line in enumerate(lines):
1120
line = line.strip()
1121
if not line or line.startswith('#'):
1122
continue
1123
1124
m = FIELD_REGEX.match(line)
1125
if m:
1126
display_field = m.groups()[0]
1127
try:
1128
field = FIELDS_LOOKUP[display_field.lower()]
1129
except KeyError:
1130
raise TicketSyntaxError('field "{0}" not supported on line {1}'
1131
.format(display_field, i+1))
1132
if field in fields:
1133
raise TicketSyntaxError('only one value for "{0}" allowed on line {1}'
1134
.format(display_field, i+1))
1135
else:
1136
value = m.groups()[1].strip()
1137
if field in RESTRICTED_FIELDS and not ALLOWED_VALUES[field].match(value):
1138
raise TicketSyntaxError('"{0}" is not a valid value for the field "{1}"'
1139
.format(value, field))
1140
fields[field] = value
1141
continue
1142
else:
1143
break
1144
else: # no description
1145
i += 1
1146
1147
# separate summary from other fields
1148
try:
1149
summary = fields.pop('summary')
1150
except KeyError:
1151
summary = None
1152
1153
description = [line.rstrip() for line in lines[i:]
1154
if not line.startswith('#')]
1155
1156
# remove leading and trailing empty newlines
1157
while description and not description[0]:
1158
description.pop(0)
1159
while description and not description[-1]:
1160
description.pop()
1161
1162
if not summary:
1163
raise TicketSyntaxError("no valid summary found")
1164
elif not description:
1165
raise TicketSyntaxError("no description found")
1166
else:
1167
return summary, "\n".join(description), fields
1168
1169
def set_attributes(self, ticket, comment='', notify=False, **kwds):
1170
"""
1171
Set attributes on a track ticket.
1172
1173
INPUT:
1174
1175
- ``ticket`` -- the ticket id
1176
- ``comment`` -- a comment when changing these attributes
1177
- ``kwds`` -- a dictionary of field:value pairs
1178
1179
.. SEEALSO::
1180
1181
:meth:`_get_attributes`
1182
1183
EXAMPLES::
1184
1185
sage: from sage.dev.test.config import DoctestConfig
1186
sage: from sage.dev.test.user_interface import DoctestUserInterface
1187
sage: from sage.dev.test.trac_interface import DoctestTracInterface
1188
sage: from sage.dev.test.trac_server import DoctestTracServer
1189
sage: config = DoctestConfig()
1190
sage: config['trac']['password'] = 'secret'
1191
sage: UI = DoctestUserInterface(config['UI'])
1192
sage: trac = DoctestTracInterface(config['trac'], UI, DoctestTracServer())
1193
sage: n = trac.create_ticket('Summary', 'Description', {'type':'defect', 'component':'algebra', 'status':'new'})
1194
sage: trac._get_attributes(n)['status']
1195
'new'
1196
sage: trac.set_attributes(n, status='needs_review')
1197
sage: trac._get_attributes(n)['status']
1198
'needs_review'
1199
1200
Some error checking is done:
1201
1202
sage: trac.set_attributes(n, status='invalid_status')
1203
Traceback (most recent call last):
1204
...
1205
TicketSyntaxError: "invalid_status" is not a valid value for the field "status"
1206
"""
1207
ticket = int(ticket)
1208
for field, value in kwds.iteritems():
1209
if field not in ALLOWED_FIELDS:
1210
raise TicketSyntaxError('field "{0}" not supported'.format(field))
1211
if field in ALLOWED_VALUES and not ALLOWED_VALUES[field].match(value):
1212
raise TicketSyntaxError('"{0}" is not a valid value for the field "{1}"'.format(value, field))
1213
attributes = self._get_attributes(ticket)
1214
attributes.update(**kwds)
1215
self._authenticated_server_proxy.ticket.update(ticket, comment, attributes, notify)
1216
if ticket in self.__ticket_cache:
1217
del self.__ticket_cache[ticket]
1218
1219