~launchpad-pqm/launchpad/devel

3944.1.1 by Francis J. Lacoste
Use system version python2.4 for scripts.
1
#!/usr/bin/python2.4
8687.15.22 by Karl Fogel
Add the copyright header block to the remaining .py files.
2
#
3
# Copyright 2009 Canonical Ltd.  This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
3485.3.3 by James Henstridge
add zope spec metadata importer
5
6
# A script to import metadata about the Zope 3 specs into Launchpad
7
8
__metaclass__ = type
9
10
import itertools
11
import re
12
import sys
13
import urllib2
14
15
import _pythonpath
16
from zope.component import getUtility
17
from BeautifulSoup import BeautifulSoup
18
19
from canonical.lp import initZopeless
20
from canonical.lp.dbschema import (
21
    SpecificationStatus, SpecificationGoalStatus, SpecificationDelivery,
22
    SpecificationPriority)
23
from canonical.launchpad.scripts import execute_zcml_for_scripts
24
from canonical.launchpad.interfaces import (
25
    IPersonSet, IProductSet, ISpecificationSet)
26
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
27
WIKI_BASE = 'http://wiki.zope.org/zope3/'
3485.3.3 by James Henstridge
add zope spec metadata importer
28
PROPOSAL_LISTS = ['Zope3Proposals', 'OldProposals', 'DraftProposals']
29
specroot = WIKI_BASE + 'Zope3Proposals'
30
31
at_replacements = ['_at_', '(at)', '@']
32
author_email_pat = re.compile('[-.A-Za-z0-9]+(?:@|%s)[-.A-Za-z0-9]+' %
33
                              '|'.join([re.escape(replacement)
34
                                        for replacement in at_replacements]))
35
36
def getTextContent(tag):
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
37
    if tag is None:
38
        return ''
3485.3.3 by James Henstridge
add zope spec metadata importer
39
    if isinstance(tag, basestring):
40
        return tag
41
    return ''.join([e for e in tag.recursiveChildGenerator()
42
                    if isinstance(e, basestring)])
43
44
45
class ZopeSpec:
46
47
    def __init__(self, url, title, summary):
48
        self.url = url
49
        self.name = self.url.split('/')[-1]
50
        self.title = title
51
        self.summary = summary
52
        self.authors = set()
53
        self.statuses = set()
54
55
    def parseAuthorEmails(self, text):
56
        author_email_list = author_email_pat.findall(text)
57
        for author in author_email_list:
58
            # unmangle at symbol in email:
59
            for replacement in at_replacements:
60
                author = author.replace(replacement, '@')
61
            self.authors.add(author)
62
63
    def parseStatuses(self, soup):
64
        wiki_badges = [
65
            'IsWorkInProgress',
66
67
            'IsProposal',
68
            'IsRejectedProposal',
69
            'IsSupercededProposal',
70
            'IsRetractedProposal',
71
            'IsAcceptedProposal',
72
            'IsImplementedProposal',
73
            'IsExpiredProposal',
74
            'IsDraftProposal',
75
76
            'IsPlanned',
77
            'IsResolved',
78
            'IsImplemented',
79
80
            'IsReplaced',
81
            'IsOutdated',
82
            'IsDraft',
83
            'IsEditedDraft',
84
            'IsRoughDraft',
85
            ]
86
        for badge in wiki_badges:
87
            url = WIKI_BASE + badge
88
            if soup.fetch('a', {'href': url}):
89
                self.statuses.add(badge)
90
91
    def parseSpec(self):
92
        contents = urllib2.urlopen(self.url).read()
93
        soup = BeautifulSoup(contents)
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
94
        contentdivs = soup('div', {'class': 'content'})
3485.3.3 by James Henstridge
add zope spec metadata importer
95
        assert len(contentdivs) == 1
96
        contentdiv = contentdivs[0]
97
98
        # Specification statuses are represented by "wiki badges",
99
        # which are just hyperlinks to particular pages.
100
        self.parseStatuses(soup)
101
102
        # There are two styles of spec.  One of them has a table with
103
        # RFC-822 style headers in it.  The other has minor level headings
104
        # with text under the heading.
105
        tables = soup('table')
106
        # Every page has one table, for the main page layout.  So, if the page
107
        # has two tables, it means that it will be using the RFC-822 style.
108
        if len(tables) >= 2:
109
            # This is a spec with RFC-822 style headers.
110
            docinfo = tables[1]
111
            for row in docinfo('tr'):
112
                if len(row('th')) < 1 or len(row('td')) < 1:
113
                    continue
114
                key = row('th')[0].renderContents()
115
                if key.endswith(':'):
116
                    key = key[:-1]
117
                value = row('td')[0].renderContents()
118
119
                if 'Author' in key:
120
                    self.parseAuthorEmails(value)
121
        else:
122
            # This is a spec with minor level headings, or perhaps with no
123
            # headings at all.
124
125
            # Look for an author heading.
126
            author_headers = soup(text=re.compile('Author.*', re.I))
127
            if author_headers:
128
                author = author_headers[0].findNext().renderContents()
129
                self.parseAuthorEmails(author)
130
        
131
    @property
132
    def lpname(self):
133
        # add dashes before capitalised words
134
        name = re.sub(r'([^A-Z])([A-Z])', r'\1-\2', self.name)
135
        # lower case name
136
        name = name.lower()
137
        # remove leading dashes
138
        while name.startswith('-'):
139
            name = name[1:]
140
        # if name doesn't begin with an alphabetical character prefix it
141
        if not name[0].isalpha():
142
            name = 'x-' + name
143
        return name
144
145
    @property
146
    def lpstatus(self):
147
        # implemented and accepted specs => APPROVED
148
        for status in ['IsImplemented',
149
                       'IsImplementedProposal',
150
                       'IsAcceptedProposal']:
151
            if status in self.statuses:
152
                return SpecificationStatus.APPROVED
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
153
        # WIP => DISCUSSION
3485.3.3 by James Henstridge
add zope spec metadata importer
154
        if 'IsWorkInProgress' in self.statuses:
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
155
            return SpecificationStatus.DISCUSSION
3485.3.3 by James Henstridge
add zope spec metadata importer
156
        for status in ['IsSupercededProposal', 'IsReplaced']:
157
            if status in self.statuses:
158
                return SpecificationStatus.SUPERSEDED
159
        for status in ['IsExpiredProposal', 'IsOutdated']:
160
            if status in self.statuses:
161
                return SpecificationStatus.OBSOLETE
162
        # draft statuses:
163
        for status in ['IsDraftProposal',
164
                       'IsDraft',
165
                       'IsEditedDraft',
166
                       'IsRoughDraft']:
167
            if status in self.statuses:
168
                return SpecificationStatus.DRAFT
169
        # otherwise ...
170
        return SpecificationStatus.PENDINGREVIEW
171
172
    @property
173
    def lpgoalstatus(self):
174
        # implemented and accepted specs => ACCEPTED
175
        for status in ['IsImplemented',
176
                       'IsImplementedProposal',
177
                       'IsAcceptedProposal']:
178
            if status in self.statuses:
179
                return SpecificationGoalStatus.ACCEPTED
180
        # rejected or retracted => DECLINED
181
        for status in ['IsRetractedProposal', 'IsRejectedProposal']:
182
            if status in self.statuses:
183
                return SpecificationGoalStatus.DECLINED
184
185
        # otherwise ...
186
        return SpecificationGoalStatus.PROPOSED
187
188
    @property
189
    def lpdelivery(self):
190
        for status in ['IsImplemented',
191
                       'IsImplementedProposal']:
192
            if status in self.statuses:
193
                return SpecificationDelivery.IMPLEMENTED
194
        # otherwise ...
195
        return SpecificationDelivery.UNKNOWN
196
197
    def syncSpec(self):
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
198
        zope = getUtility(IProductSet).getByName('zope')
199
        zope_dev = getUtility(IPersonSet).getByName('zope-dev')
3485.3.3 by James Henstridge
add zope spec metadata importer
200
        # has the spec been created?
201
        lpspec = getUtility(ISpecificationSet).getByURL(self.url)
202
        if not lpspec:
203
            lpspec = getUtility(ISpecificationSet).new(
204
                name=self.lpname,
205
                title=self.title,
206
                specurl=self.url,
207
                summary=self.summary,
208
                priority=SpecificationPriority.UNDEFINED,
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
209
                status=SpecificationStatus.NEW,
3485.3.3 by James Henstridge
add zope spec metadata importer
210
                owner=zope_dev,
211
                product=zope)
212
213
        # synchronise
214
        lpspec.title = self.title
215
        lpspec.summary = self.summary
216
        lpspec.status = self.lpstatus
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
217
        newgoalstatus = self.lpgoalstatus
218
        if newgoalstatus != lpspec.goalstatus:
219
            if newgoalstatus == SpecificationGoalStatus.PROPOSED:
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
220
                lpspec.proposeGoal(None, zope_dev)
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
221
            elif newgoalstatus == SpecificationGoalStatus.ACCEPTED:
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
222
                lpspec.acceptBy(zope_dev)
3485.3.28 by James Henstridge
local import-zope-specs.py changes from carbon.ubuntu.com
223
            elif newgoalstatus == SpecificationGoalStatus.DECLINED:
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
224
                lpspec.declineBy(zope_dev)
3485.3.3 by James Henstridge
add zope spec metadata importer
225
        lpspec.delivery = self.lpdelivery
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
226
        lpspec.updateLifecycleStatus(zope_dev)
227
            
3485.3.3 by James Henstridge
add zope spec metadata importer
228
        # set the assignee to the first author email with an LP account
229
        for author in sorted(self.authors):
230
            person = getUtility(IPersonSet).getByEmail(author)
231
            if person is not None:
232
                lpspec.assignee = person
233
                break
234
235
236
def iter_spec_urls(url=specroot):
237
    contents = urllib2.urlopen(url)
238
    soup = BeautifulSoup(contents)
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
239
    contentdivs = soup('div', {'class': 'content'})
3485.3.3 by James Henstridge
add zope spec metadata importer
240
    assert len(contentdivs) == 1
241
    contentdiv = contentdivs[0]
242
    listofspecs = contentdiv('ul')[0]
243
244
    for listitem in listofspecs('li', recursive=False):
245
        anchors = listitem('a')
246
        if not anchors:
247
            continue
248
        specanchor = anchors[0]
249
        href = specanchor['href']
250
        # broken wiki link => ignore
3485.3.34 by James Henstridge
update import-zope-specs.py to work with current Launchpad and current Zope wiki
251
        if 'createform?page=' in href:
3485.3.3 by James Henstridge
add zope spec metadata importer
252
            continue
253
        title = getTextContent(specanchor)
254
        summary = ''.join([getTextContent(tag)
255
                               for tag in specanchor.nextSiblingGenerator()])
256
        yield ZopeSpec(href, title, summary.strip())
257
258
        
259
def main(argv):
260
    execute_zcml_for_scripts()
261
    ztm = initZopeless()
262
263
    for spec in itertools.chain(*[iter_spec_urls(WIKI_BASE + page)
264
                                  for page in PROPOSAL_LISTS]):
265
        # parse extra information from the spec body
266
        spec.parseSpec()
267
        # add its metadata to LP
268
        print 'Synchronising', spec.name
269
        ztm.begin()
270
        try:
271
            spec.syncSpec()
272
            ztm.commit()
273
        except:
274
            ztm.abort()
275
            raise
276
277
if __name__ == '__main__':
278
    sys.exit(main(sys.argv))