Merge lp:~adeuring/charm-tools/check-maintainer-branch-owner into lp:~charmers/charm-tools/stable

Proposed by Abel Deuring
Status: Rejected
Rejected by: Abel Deuring
Proposed branch: lp:~adeuring/charm-tools/check-maintainer-branch-owner
Merge into: lp:~charmers/charm-tools/stable
Diff against target: 357 lines (+215/-25)
4 files modified
charmtools/__init__.py (+2/-0)
charmtools/proof.py (+92/-23)
setup.py (+1/-2)
tests/proof/test_proof.py (+120/-0)
To merge this branch: bzr merge lp:~adeuring/charm-tools/check-maintainer-branch-owner
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+188586@code.launchpad.net

Description of the change

This branch adds a check for the maintainer name and the branch owner.

The main idea is that maintainer(s) as specified in metadata.yaml
should also be members of the team that owns the related Launchpad
branch.

I thought a bit if it makes sense to find the intended target
branch of the proofed branch: In theory, it is possible to look for
the parent branch, the "grandparent" branch etc until an LP branch
for a sourcepackage of the "charms" distribution is found. That _could_
be the the final merge target for the given branch, but that's not
always the case -- somebody might create a fork of another branch
with the intention to maintain this fork for a longer time -- and this
is probably the main use case for the new check: To ensure that the
maintainer mentioned in metadata.yaml has write access to the LP
branch of the charm.

Another option would have been to find a push branch on LP and to check
for merge proposals, to eventually find the "right" target. But that is
also not trivial -- and it does not work if the proofed branch has not
yet been pushed to Launchpad.

I other words: Lots of cumbersome "guess work" with limited success
probabilty.

So I took the most simple approach: The user must explicitly
specify the merge target of the given branch.

Currently, the proofer checks if the name of the main charm directory
matches the charm name. This does not make sense for branches, so this
required some changes how the directory name is handled in function run().

I found the distinction between charm_name (a directory path) and
charm_base_name a bit confusing, so I renamed charm_name to charm_path
and charm_basename to charm_name.

It seems that we do not have any infrastructure for unit tests of
functions that access the LP web API, so I wrote also a little mock
for the LP API.

To post a comment you must log in.
252. By Abel Deuring

Make the version of charmtools available in the package itself.

Revision history for this message
Abel Deuring (adeuring) wrote :

Another change: I made the package version available in the package itself. The branch changes the parameter passed to the function run() of the proofer. With Charmworld, there is at least one other project that uses the proofer. Having a simple way to discover the version of charmtools makes it easier for charmworld to figure out how to call proof.run().

Unmerged revisions

253. By Abel Deuring

reverted all changes except definition of the version number in charmtools/__init__.py

252. By Abel Deuring

Make the version of charmtools available in the package itself.

251. By Abel Deuring

Check consistency of branch owner and maintainers specified in metadata.yaml

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmtools/__init__.py'
2--- charmtools/__init__.py 2013-09-13 16:38:52 +0000
3+++ charmtools/__init__.py 2013-10-07 13:11:20 +0000
4@@ -20,6 +20,8 @@
5
6 import subprocess
7
8+__version__ = '1.1.0'
9+
10 ext = ''
11 if os.name == 'nt':
12 ext = '.exe'
13
14=== modified file 'charmtools/proof.py'
15--- charmtools/proof.py 2013-09-17 15:31:55 +0000
16+++ charmtools/proof.py 2013-10-07 13:11:20 +0000
17@@ -17,6 +17,7 @@
18
19 import argparse
20 import email.utils
21+from launchpadlib.launchpad import Launchpad
22 import os
23 import re
24 from stat import ST_MODE
25@@ -118,8 +119,8 @@
26 return False
27
28 def check_relation_hooks(self, relations, subordinate, hooks_path):
29- template_interfaces = ('interface-name')
30- template_relations = ('relation-name')
31+ template_interfaces = ('interface-name', )
32+ template_relations = ('relation-name', )
33
34 for r in relations.items():
35 if type(r[1]) != dict:
36@@ -239,34 +240,96 @@
37 # value exists.
38 pass
39
40+ def check_branch_owner_maintainer(self, branch_owner_name, maintainers):
41+ lp = Launchpad.login_with(sys.argv[0], 'production')
42+ owners = [
43+ person for person in lp.people.find(text=branch_owner_name)
44+ if person.name == branch_owner_name]
45+ if not owners:
46+ self.warn(
47+ 'Launchpad does not know about the branch owner %s'
48+ % branch_owner_name)
49+ return
50+ [branch_owner] = owners
51+ # branch_owner may be a team; check the members in this case.
52+ participants = list(branch_owner.participants)
53+ if not participants:
54+ # lp_person.participants is empty for real persons.
55+ participants = [branch_owner]
56+ plausible = False
57+ for maintainer in maintainers:
58+ name, address = email.utils.parseaddr(maintainer)
59+ if address != '':
60+ lp_person = lp.people.getByEmail(email=address)
61+ if lp_person is None:
62+ self.warn(
63+ 'Maintainer %s has no Launchpad account' % maintainer)
64+ continue
65+ if lp_person in participants:
66+ plausible = True
67+ else:
68+ self.warn('Maintainer %s is not the branch owner or '
69+ 'a member of the branch owner team (%s).'
70+ % (maintainer, branch_owner_name))
71+ if not plausible:
72+ self.err(
73+ "None of the charm maintainers specified in metadata.yaml "
74+ "is a member of the Launchpad branch owner team.")
75+
76
77 def get_args():
78 parser = argparse.ArgumentParser(
79 description='Performs static analysis on charms')
80- parser.add_argument('charm_name', nargs='?',
81+ parser.add_argument('charm_name', nargs='?', default=os.getcwd(),
82 help='path of charm dir to check. Defaults to PWD')
83+ parser.add_argument(
84+ '-l', '--lp-branch', nargs=1, dest='lp_branch',
85+ help='The URL of a Launchpad branch into which the proofed branch '
86+ 'will be merged, e.g. '
87+ 'lp:~your_lp_name/charms/precise/apache2/trunk')
88 args = parser.parse_args()
89
90- if args.charm_name:
91- charm_name = args.charm_name
92- else:
93- charm_name = os.getcwd()
94-
95- return charm_name
96-
97-
98-def run(charm_name):
99+ return args
100+
101+
102+def run(args):
103+ charm_arg = args.charm_name
104+ # Remove a possibly existing trailing slash from the path name.
105+ dirname, basename = os.path.split(charm_arg)
106+ if basename == '':
107+ charm_arg = dirname
108 lint = Linter()
109- if os.path.isdir(charm_name):
110- charm_path = charm_name
111+ if os.path.isdir(charm_arg):
112+ charm_path = charm_arg
113 else:
114 charm_home = os.getenv('CHARM_HOME', '.')
115- charm_path = os.path.join(charm_home, charm_name)
116+ charm_path = os.path.join(charm_home, charm_arg)
117
118 if not os.path.isdir(charm_path):
119 lint.crit("%s is not a directory, Aborting" % charm_path)
120 return lint.lint, lint.exit_code
121
122+ bzr_path = os.path.join(charm_path, '.bzr')
123+ branch_owner = None
124+ if os.path.isdir(bzr_path):
125+ if args.lp_branch is None:
126+ lint.warn(
127+ "A full check of a charm's branch requires the parameter "
128+ "--lp-branch.")
129+ charm_name = os.path.basename(charm_path)
130+ else:
131+ mo = re.search(
132+ '^lp:~([^/]+)/charms/[^/]+/([^/]+)', args.lp_branch[0])
133+ if mo is None:
134+ lint.warn(
135+ "can't check the consistency of the charm's and "
136+ "maintainer's name: Unknown target branch specification.")
137+ charm_name = os.path.basename(charm_path)
138+ else:
139+ branch_owner, charm_name = mo.groups()
140+ else:
141+ charm_name = os.path.basename(charm_path)
142+
143 hooks_path = os.path.join(charm_path, 'hooks')
144 yaml_path = os.path.join(charm_path, 'metadata.yaml')
145 try:
146@@ -283,11 +346,14 @@
147 if key not in KNOWN_METADATA_KEYS:
148 lint.err("Unknown root metadata field (%s)" % key)
149
150- charm_basename = os.path.basename(charm_path)
151- if charm['name'] != charm_basename:
152- warn_msg = ("metadata name (%s) must match directory name (%s) "
153- "exactly for local deployment.") % (
154- charm['name'], charm_basename)
155+ if charm['name'] != charm_name:
156+ if branch_owner is None:
157+ warn_msg = ("metadata name (%s) must match directory name "
158+ "(%s) exactly for local deployment.") % (
159+ charm['name'], charm_name)
160+ else:
161+ warn_msg = ("metadata name (%s) must match the package name "
162+ "(%s) exactly.") % (charm['name'], charm_name)
163 lint.warn(warn_msg)
164
165 # summary should be short
166@@ -311,6 +377,9 @@
167 formatted))
168 lint.warn(warn_msg)
169
170+ if branch_owner is not None:
171+ lint.check_branch_owner_maintainer(branch_owner, maintainers)
172+
173 if 'categories' not in charm:
174 lint.warn('Metadata is missing categories.')
175 else:
176@@ -436,7 +505,7 @@
177 lint.check_hook('stop', hooks_path, required=False, recommended=True)
178 lint.check_hook('config-changed', hooks_path, required=False)
179 except IOError:
180- lint.err("could not find metadata file for " + charm_name)
181+ lint.err("could not find metadata file for " + charm_arg)
182 lint.exit_code = -1
183
184 rev_path = os.path.join(charm_path, 'revision')
185@@ -455,8 +524,8 @@
186
187
188 def main():
189- charm_name = get_args()
190- lint, exit_code = run(charm_name)
191+ args = get_args()
192+ lint, exit_code = run(args)
193 if lint:
194 print "\n".join(lint)
195 sys.exit(exit_code)
196
197=== modified file 'setup.py'
198--- setup.py 2013-09-13 16:42:10 +0000
199+++ setup.py 2013-10-07 13:11:20 +0000
200@@ -10,8 +10,7 @@
201 ez_setup.use_setuptools()
202
203 from setuptools import setup, find_packages
204-
205-__version__ = '1.0.0'
206+from charmtools import __version__
207
208
209 setup(
210
211=== modified file 'tests/proof/test_proof.py'
212--- tests/proof/test_proof.py 2013-09-17 15:31:55 +0000
213+++ tests/proof/test_proof.py 2013-10-07 13:11:20 +0000
214@@ -15,6 +15,7 @@
215 # You should have received a copy of the GNU General Public License
216 # along with this program. If not, see <http://www.gnu.org/licenses/>.
217
218+from contextlib import contextmanager
219 from os.path import abspath, dirname, join
220 from shutil import rmtree
221 import sys
222@@ -25,9 +26,77 @@
223 proof_path = dirname(dirname(dirname(abspath(__file__))))
224 proof_path = join(proof_path, 'charmtools')
225 sys.path.append(proof_path)
226+import proof
227 from proof import Linter
228
229
230+class BadLPAPICall(Exception):
231+ pass
232+
233+
234+def kwargs_only(f):
235+ def argcheck(*args, **kwargs):
236+ if len(args) > 1:
237+ raise BadLPAPICall('The LP API allows only keyword arguments.')
238+ return f(*args, **kwargs)
239+ return argcheck
240+
241+
242+class FakeLPPerson:
243+ def __init__(self, name, displayname, email, participants):
244+ self.name = name
245+ self.displayname=displayname
246+ self.email = email
247+ self.participants = participants
248+
249+
250+class FakeLPPeople:
251+ def __init__(self, persons):
252+ unique_names = set(person.name for person in persons)
253+ if len(unique_names) != len(persons):
254+ raise ValueError('Person names must be unique')
255+ unique_addresses = set(person.email for person in persons)
256+ if len(unique_addresses) != len(persons):
257+ raise ValueError('Person email adresses must be unique')
258+ self.persons = persons
259+
260+ @kwargs_only
261+ def find(self, text):
262+ text = text.lower()
263+ return [
264+ person for person in self.persons
265+ if (text in person.name.lower()
266+ or text in person.displayname.lower()
267+ or text in person.email.lower())
268+ ]
269+
270+ @kwargs_only
271+ def getByEmail(self, email):
272+ for person in self.persons:
273+ if person.email == email:
274+ return person
275+ return None
276+
277+
278+
279+class FakeLaunchpad:
280+ """Helper for tests that need to access the Launchpad web API."""
281+
282+ def __init__(self, persons):
283+ self.people = FakeLPPeople(persons)
284+
285+ def login_with(self, *args):
286+ return self
287+
288+
289+@contextmanager
290+def make_lp(persons):
291+ real_lp = proof.Launchpad
292+ proof.Launchpad = FakeLaunchpad(persons)
293+ yield
294+ proof.Launchpad = real_lp
295+
296+
297 class TestProof(TestCase):
298
299 def setUp(self):
300@@ -241,6 +310,57 @@
301 'the type of the default value is str')
302 self.assertEqual(expected, self.linter.lint[0])
303
304+ def make_people(self):
305+ b_owner = FakeLPPerson(
306+ 'branch-owner', 'Branch Owner', 'b.owner@ubuntu.com', [])
307+ j_doe = FakeLPPerson(
308+ 'johndoe', 'John Doe',' jdoe@example.com', [])
309+ j_doe2 = FakeLPPerson(
310+ 'johndoe2', 'John Doe (the other one)', 'jdoe@example.org', [])
311+ owner_team = FakeLPPerson(
312+ 'owner-team', 'Owner Team', 'all_does@ubuntu.com',
313+ [j_doe, j_doe2])
314+ maintainer = FakeLPPerson(
315+ 'maintainer', 'Main Tainer', 'maintainer@ubuntu.com', [])
316+ return [b_owner, j_doe, j_doe2, owner_team, maintainer]
317+
318+ def test_check_branch_owner__owner_not_found(self):
319+ with make_lp(self.make_people()):
320+ self.linter.check_branch_owner_maintainer(
321+ 'unknown', ['Bar <bar@example.com>'])
322+ self.assertEqual(
323+ ['W: Launchpad does not know about the branch owner unknown'],
324+ self.linter.lint)
325+
326+ def test_check_branch_owner__maintainer_unkown(self):
327+ with make_lp(self.make_people()):
328+ self.linter.check_branch_owner_maintainer(
329+ 'johndoe', ['Bar <bar@example.com>'])
330+ self.assertEqual(
331+ [
332+ 'W: Maintainer Bar <bar@example.com> has no Launchpad '
333+ 'account',
334+ 'E: None of the charm maintainers specified in '
335+ 'metadata.yaml is a member of the Launchpad branch '
336+ 'owner team.'
337+ ],
338+ self.linter.lint)
339+
340+ def test_check_branch_owner__owner_is_team_maint_not_in_team(self):
341+ with make_lp(self.make_people()):
342+ self.linter.check_branch_owner_maintainer(
343+ 'owner-team', ['Main Tainer <maintainer@ubuntu.com>'])
344+ self.assertEqual(
345+ [
346+ 'W: Maintainer Main Tainer <maintainer@ubuntu.com> is '
347+ 'not the branch owner or a member of the branch owner '
348+ 'team (owner-team).',
349+ 'E: None of the charm maintainers specified in '
350+ 'metadata.yaml is a member of the Launchpad branch '
351+ 'owner team.',
352+ ],
353+ self.linter.lint)
354+
355
356 if __name__ == '__main__':
357 main()

Subscribers

People subscribed via source and target branches

to all changes: