Merge lp:~stevanr/linaro-license-protection/production-integration-tests into lp:~linaro-automation/linaro-license-protection/trunk

Proposed by Stevan Radaković
Status: Merged
Merged at revision: 74
Proposed branch: lp:~stevanr/linaro-license-protection/production-integration-tests
Merge into: lp:~linaro-automation/linaro-license-protection/trunk
Diff against target: 565 lines (+496/-3)
5 files modified
docs/releases.txt (+144/-0)
docs/snapshots.txt (+168/-0)
testing/__init__.py (+7/-1)
testing/doctest_production_browser.py (+172/-0)
testing/license_protected_file_downloader.py (+5/-2)
To merge this branch: bzr merge lp:~stevanr/linaro-license-protection/production-integration-tests
Reviewer Review Type Date Requested Status
Данило Шеган (community) Approve
Review via email: mp+105822@code.launchpad.net

Description of the change

Added doctest production tests for both snapshots.linaro.org and releases.linaro.org.
New helper class for directory/file navigation.
Changes to __init to include doctest.

To post a comment you must log in.
Revision history for this message
Данило Шеган (danilo) wrote :

So much nicer, thanks for working on this.

Now, considering what is mostly tested, I'd suggest you add a get_content_title() method which extracts the title tag (considering you are already using BeautifulSoup, that should be easy), so the output would be nicer and even more clear, for example:

    Browsing into the android/~linaro-android/*snowball* works without asking for any license acceptance:

        >>> browser.browse_to_relative("android/")
        >>> browser.get_content_title()
        u'Index of /android'
        >>> browser.browse_to_relative("~linaro-android")
        >>> browser.get_content_title()
        u'Index of /android/~linaro-android'
        >>> browser.browse_to_relative("snowball")
        >>> browser.get_content_title()
        u'Index of /android/~linaro-android/...snowball...'

As you can see, I am also suggesting joining a few steps behind a single narrative. I'd suggest you do the same for the target/product/* step as well.

I like all the other improvements (DoctestProductionBrowser(host), get_license() call etc).

It would still be nicer to print out the headers one-per-line instead of as a dict:

        >>> print browser.get_header_when_redirected()
        Content-Length: ...
        Location: http://snapshots.../boot.tar.bz2
        Content-Type: application/x-bzip2
        ...

Also note that there is no guarantee in what order headers will be returned, and this test might easily break, so I suggest get_headers_when_redirected sorts them by name before returning a string with one header per line.

Revision history for this message
Данило Шеган (danilo) wrote :

Uhm, make those browse_to_relative browse_to_next, mea culpa. :)

Revision history for this message
Данило Шеган (danilo) wrote :

Also, I think we are missing one step. We need to confirm there is an 'accept' link in the license, and we want to simulate clicking it. I am not sure how best to do that, but I am sure you can figure something out (we can simply check if the URL there is absolute or relative, and then browse to it).

Revision history for this message
James Tunnicliffe (dooferlad) wrote :

On 16 May 2012 11:34, Данило Шеган <email address hidden> wrote:
> Also, I think we are missing one step.  We need to confirm there is an
> 'accept' link in the license, and we want to simulate clicking it.  I am
> not sure how best to do that, but I am sure you can figure something out
> (we can simply check if the URL there is absolute or relative, and then
> browse to it).

Finding and clicking the accept link is exactly what the download
script does :-)

--
James Tunnicliffe

Revision history for this message
Данило Шеган (danilo) wrote :

Right, but this is about proving that this works from a users' perspective, not that we've got code which can get through the click-through.

Revision history for this message
James Tunnicliffe (dooferlad) wrote :

True, though my point was that the script emulates a user by finding
the link and following it. It may be useful in this case because it
would prove that the link exists and that clicking on it allows the
user to download the file they are expecting.

Revision history for this message
Данило Шеган (danilo) wrote :

Right, understood. However, since this is supposed to be a user-readable (and repeatable) test plan, I'd rather if all the steps are clear from the actual doctest.

Revision history for this message
Stevan Radaković (stevanr) wrote :

> True, though my point was that the script emulates a user by finding
> the link and following it. It may be useful in this case because it
> would prove that the link exists and that clicking on it allows the
> user to download the file they are expecting.

Imho, Danilo has the point, but James' script allows me to test this with get_protected_file method. It emulates a click to the accept license link and then gets the file requested. I'll use it in my browser class and that's it.

Revision history for this message
Данило Шеган (danilo) wrote :

This looks good. It would be nice to test for 404 errors ending up on the same URL, but definitely not for this branch. At this time, this is more than good enough.

We'll probably want to decouple these tests from the automated tests since they need to run at different times (most of them before landing, these tests after deployment).

review: Approve
81. By Stevan Radaković

PEP8 changes.

82. By Stevan Radaković

Add new failing test for link in platform directory.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'docs'
2=== added file 'docs/releases.txt'
3--- docs/releases.txt 1970-01-01 00:00:00 +0000
4+++ docs/releases.txt 2012-05-16 16:49:24 +0000
5@@ -0,0 +1,144 @@
6+Test releases.linaro.org production server
7+===========================================
8+
9+Navigate to the regular ST-E license-protected file and initiate download
10+-------------------------------------------------------------------------
11+
12+Import class we will use for this test and init browser object.
13+
14+ >>> from testing.doctest_production_browser import DoctestProductionBrowser
15+ >>> browser = DoctestProductionBrowser("http://releases.linaro.org/")
16+
17+Visiting homepage and check for title.
18+
19+ >>> print browser.get_content_title()
20+ Index of /
21+
22+Browsing into the latest/android/leb-snowball should work without any
23+license popping out.
24+
25+ >>> browser.browse_to_relative("latest/")
26+ >>> print browser.get_content_title()
27+ Index of /latest
28+ >>> browser.browse_to_relative("android/")
29+ >>> print browser.get_content_title()
30+ Index of /latest/android
31+ >>> browser.browse_to_relative("leb-snowball/")
32+ >>> print browser.get_content_title()
33+ Index of /latest/android/leb-snowball
34+
35+Mock the boot.tar.bz2 file download and check the license.
36+Check if the ST-E license is encountered.
37+
38+ >>> browser.browse_to_relative("boot.tar.bz2")
39+ >>> print browser.get_license_text()
40+ This Agreement is a legal...ST-Ericsson...GOVERNING LAW AND JURISDICTION...
41+ ...
42+
43+Now, emulate clicking on the Accept Licence link which redirects us to the
44+download file. Check if the headers of the requested file are in order.
45+
46+ >>> print browser.accept_license_get_header()
47+ Accept-Ranges:...
48+ Content-Type: application/x-bzip2...
49+ Location: http://releases...snowball...boot.tar.bz2...
50+ ...
51+
52+Now, emulate clicking on the Decline Licence link which redirects us to the
53+decline page.
54+
55+ >>> print browser.decline_license()
56+ License has not been accepted
57+
58+
59+Navigate to the regular Samsung license-protected file and initiate download
60+----------------------------------------------------------------------------
61+
62+Browsing back into the /latest/android/leb-origen. It should work
63+without any license popping out.
64+
65+ >>> browser.browse_to_absolute("latest/")
66+ >>> print browser.get_content_title()
67+ Index of /latest
68+ >>> browser.browse_to_relative("android/")
69+ >>> print browser.get_content_title()
70+ Index of /latest/android
71+ >>> browser.browse_to_relative("leb-origen/")
72+ >>> print browser.get_content_title()
73+ Index of /latest/android/leb-origen
74+
75+Mock the boot.tar.bz2 file download and check the license.
76+Check if the Samsung license is encountered.
77+
78+ >>> browser.browse_to_relative("boot.tar.bz2")
79+ >>> print browser.get_license_text()
80+ IMPORTANT...SAMSUNG ELECTRONICS...Entire Agreement...
81+ ...
82+
83+Now, emulate clicking on the Accept Licence link which redirects us to the
84+download file. Check if the headers of the requested file are in order.
85+
86+ >>> print browser.accept_license_get_header()
87+ Accept-Ranges:...
88+ Content-Type: application/x-bzip2...
89+ Location: http://releases...origen...boot.tar.bz2...
90+ ...
91+
92+Now, emulate clicking on the Decline Licence link which redirects us to the
93+decline page.
94+
95+ >>> print browser.decline_license()
96+ License has not been accepted
97+
98+
99+Navigate to the non-license-protected file and initiate download
100+----------------------------------------------------------------
101+
102+Browsing back into the latest/android/leb-panda. It should work
103+without any license popping out.
104+
105+ >>> browser.browse_to_absolute("latest/")
106+ >>> print browser.get_content_title()
107+ Index of /latest
108+ >>> browser.browse_to_relative("android/")
109+ >>> print browser.get_content_title()
110+ Index of /latest/android
111+ >>> browser.browse_to_relative("leb-panda/")
112+ >>> print browser.get_content_title()
113+ Index of /latest/android/leb-panda
114+
115+Mock the boot.tar.bz2 file download. There should not be any
116+license encountered.
117+
118+ >>> browser.browse_to_relative("boot.tar.bz2")
119+ >>> print browser.get_unprotected_file_header()
120+ Accept-Ranges:...
121+ Content-Type: application/x-bzip2...
122+ ...
123+
124+
125+Try accessing the leb-snowball link in platform latest android dir
126+------------------------------------------------------------------
127+
128+Browsing back into the platform/latest/android/latest. It should work
129+without any license popping out.
130+
131+ >>> browser.browse_to_absolute("platform/")
132+ >>> print browser.get_content_title()
133+ Index of /platform
134+ >>> browser.browse_to_relative("latest/")
135+ >>> print browser.get_content_title()
136+ Index of /platform/latest
137+ >>> browser.browse_to_relative("android/")
138+ >>> print browser.get_content_title()
139+ Index of /platform/latest/android
140+ >>> browser.browse_to_relative("latest/")
141+ >>> print browser.get_content_title()
142+ Index of /platform/latest/android/latest
143+
144+
145+Now try opening the leb-snowball link.
146+
147+ >>> browser.browse_to_relative("leb-snowball/")
148+ >>> print browser.get_content_title()
149+ Index of /platform/latest/android/latest/leb-snowball
150
151=== added file 'docs/snapshots.txt'
152--- docs/snapshots.txt 1970-01-01 00:00:00 +0000
153+++ docs/snapshots.txt 2012-05-16 16:49:24 +0000
154@@ -0,0 +1,168 @@
155+Test snapshots.linaro.org production server
156+===========================================
157+
158+Navigate to the regular ST-E license-protected file and initiate download
159+-------------------------------------------------------------------------
160+
161+Import class we will use for this test and init browser object.
162+
163+ >>> from testing.doctest_production_browser import DoctestProductionBrowser
164+ >>> browser = DoctestProductionBrowser("http://snapshots.linaro.org/")
165+
166+Visiting homepage and check for title.
167+
168+ >>> print browser.get_content_title()
169+ Index of /
170+
171+Browsing into the android/~linaro-android/*snowball* should work without any
172+license popping out.
173+
174+ >>> browser.browse_to_relative("android/")
175+ >>> print browser.get_content_title()
176+ Index of /android
177+ >>> browser.browse_to_relative("~linaro-android/")
178+ >>> print browser.get_content_title()
179+ Index of /android/~linaro-android
180+ >>> browser.browse_to_next("snowball")
181+ >>> print browser.get_content_title()
182+ Index of /android/~linaro-android/...snowball...
183+
184+Go to build number page. We don't know which are the build numbers so we
185+will visit the first directory link available. Next, go to target, then product
186+then snowball links, respectively.
187+
188+ >>> browser.browse_to_next("")
189+ >>> print browser.get_content_title()
190+ Index of /android/~linaro-android/...snowball...
191+ >>> browser.browse_to_relative("target/")
192+ >>> print browser.get_content_title()
193+ Index of /android/~linaro-android/...snowball...target
194+ >>> browser.browse_to_relative("product/")
195+ >>> print browser.get_content_title()
196+ Index of /android/~linaro-android/...snowball...target...product...
197+ >>> browser.browse_to_relative("snowball/")
198+ >>> print browser.get_content_title()
199+ Index of /android/~linaro-android/...snowball...product...snowball...
200+
201+Finally, mock the boot.tar.bz2 file download and check the license.
202+Check if the ST-E license is encountered.
203+
204+ >>> browser.browse_to_relative("boot.tar.bz2")
205+ >>> print browser.get_license_text()
206+ This Agreement is a legal...ST-Ericsson...GOVERNING LAW AND JURISDICTION...
207+ ...
208+
209+Now, emulate clicking on the Accept Licence link which redirects us to the
210+download file. Check if the headers of the requested file are in order.
211+
212+ >>> print browser.accept_license_get_header()
213+ Accept-Ranges:...
214+ Content-Type: application/x-bzip2...
215+ Location: http://snapshots...snowball...boot.tar.bz2...
216+ ...
217+
218+Now, emulate clicking on the Decline Licence link which redirects us to the
219+decline page.
220+
221+ >>> print browser.decline_license()
222+ License has not been accepted
223+
224+
225+Navigate to the regular Samsung license-protected file and initiate download
226+----------------------------------------------------------------------------
227+
228+Browsing back into the android/~linaro-android/*origen*. It should work
229+without any license popping out.
230+
231+ >>> browser.browse_to_absolute("android/")
232+ >>> print browser.get_content_title()
233+ Index of /android
234+ >>> browser.browse_to_relative("~linaro-android/")
235+ >>> print browser.get_content_title()
236+ Index of /android/~linaro-android
237+ >>> browser.browse_to_next("origen")
238+ >>> print browser.get_content_title()
239+ Index of /android/~linaro-android/...origen...
240+
241+Go to build number page. We don't know which are the build numbers so we
242+will visit the first directory link available. Next, go to target, then product
243+then origen links, respectively.
244+
245+ >>> browser.browse_to_next("")
246+ >>> print browser.get_content_title()
247+ Index of /android/~linaro-android/...origen...
248+ >>> browser.browse_to_relative("target/")
249+ >>> print browser.get_content_title()
250+ Index of /android/~linaro-android/...origen...target
251+ >>> browser.browse_to_relative("product/")
252+ >>> print browser.get_content_title()
253+ Index of /android/~linaro-android/...origen...target...product...
254+ >>> browser.browse_to_relative("origen/")
255+ >>> print browser.get_content_title()
256+ Index of /android/~linaro-android/...origen...product...origen...
257+
258+Finally, mock the boot.tar.bz2 file download and check the license.
259+Check if the Samsung license is encountered.
260+
261+ >>> browser.browse_to_relative("boot.tar.bz2")
262+ >>> print browser.get_license_text()
263+ IMPORTANT...SAMSUNG ELECTRONICS...Entire Agreement...
264+ ...
265+
266+Now, emulate clicking on the Accept Licence link which redirects us to the
267+download file. Check if the headers of the requested file are in order.
268+
269+ >>> print browser.accept_license_get_header()
270+ Accept-Ranges:...
271+ Content-Type: application/x-bzip2...
272+ Location: http://snapshots...origen...boot.tar.bz2...
273+ ...
274+
275+Now, emulate clicking on the Decline Licence link which redirects us to the
276+decline page.
277+
278+ >>> print browser.decline_license()
279+ License has not been accepted
280+
281+
282+Navigate to the non-license-protected file and initiate download
283+----------------------------------------------------------------
284+
285+Browsing back into the android/~linaro-android/*panda*. It should work
286+without any license popping out.
287+
288+ >>> browser.browse_to_absolute("android/")
289+ >>> print browser.get_content_title()
290+ Index of /android
291+ >>> browser.browse_to_relative("~linaro-android/")
292+ >>> print browser.get_content_title()
293+ Index of /android/~linaro-android
294+ >>> browser.browse_to_next("panda")
295+ >>> print browser.get_content_title()
296+ Index of /android/~linaro-android/...panda...
297+
298+Go to build number page. We don't know which are the build numbers so we
299+will visit the first directory link available. Next, go to target, then product
300+then pandaboard links, respectively.
301+
302+ >>> browser.browse_to_next("")
303+ >>> print browser.get_content_title()
304+ Index of /android/~linaro-android/...panda...
305+ >>> browser.browse_to_relative("target/")
306+ >>> print browser.get_content_title()
307+ Index of /android/~linaro-android/...panda...target
308+ >>> browser.browse_to_relative("product/")
309+ >>> print browser.get_content_title()
310+ Index of /android/~linaro-android/...panda...target...product...
311+ >>> browser.browse_to_next("panda")
312+ >>> print browser.get_content_title()
313+ Index of /android/~linaro-android/...panda...product...panda...
314+
315+Finally, mock the boot.tar.bz2 file download. There should not be any
316+license encountered.
317+
318+ >>> browser.browse_to_relative("boot.tar.bz2")
319+ >>> print browser.get_unprotected_file_header()
320+ Accept-Ranges:...
321+ Content-Type: application/x-bzip2...
322+ ...
323
324=== modified file 'testing/__init__.py'
325--- testing/__init__.py 2012-05-11 14:02:26 +0000
326+++ testing/__init__.py 2012-05-16 16:49:24 +0000
327@@ -1,9 +1,11 @@
328 import os
329 import unittest
330+import doctest
331
332 from testing.test_click_through_license import *
333 from testing.test_publish_to_snapshots import *
334
335+
336 def test_suite():
337 module_names = [
338 'testing.test_click_through_license.TestLicense',
339@@ -12,5 +14,9 @@
340 ]
341 loader = unittest.TestLoader()
342 suite = loader.loadTestsFromNames(module_names)
343+ for filename in os.listdir("docs/"):
344+ suite.addTest(doctest.DocFileSuite(
345+ 'docs/' + filename, module_relative=False,
346+ optionflags=doctest.ELLIPSIS)
347+ )
348 return suite
349-
350
351=== added file 'testing/doctest_production_browser.py'
352--- testing/doctest_production_browser.py 1970-01-01 00:00:00 +0000
353+++ testing/doctest_production_browser.py 2012-05-16 16:49:24 +0000
354@@ -0,0 +1,172 @@
355+from BeautifulSoup import BeautifulSoup
356+
357+from license_protected_file_downloader import LicenseProtectedFileFetcher
358+
359+
360+class EmptyDirectoryException(Exception):
361+ ''' Directory at the current URL is empty. '''
362+
363+
364+class NoLicenseException(Exception):
365+ ''' No license protecting the file. '''
366+
367+
368+class UnexpectedLicenseException(Exception):
369+ ''' License protecting non-licensed the file. '''
370+
371+
372+class DoctestProductionBrowser():
373+ """Doctest production testing browser class."""
374+
375+ def __init__(self, host_address):
376+ self.host_address = host_address
377+ self.current_url = host_address
378+ self.fetcher = LicenseProtectedFileFetcher()
379+
380+ def is_dir(self, link):
381+ """Check if the link is a directory."""
382+ return link[-1] == "/"
383+
384+ def get_header(self):
385+ """Get header from the current url."""
386+ return self.parse_header(self.fetcher.get_headers(self.current_url))
387+
388+ def get_license_text(self):
389+ """Get license from the current URL if it redirects to license."""
390+ license = self.fetcher.get_or_return_license(self.current_url)
391+ if license[0]:
392+ return license[0]
393+ else:
394+ raise NoLicenseException("License expected here.")
395+
396+ def get_unprotected_file_header(self):
397+ """Get headers from unprotected file."""
398+ page = self.fetcher.get_or_return_license(self.current_url)
399+ # Check if license with accept and decline links is returned.
400+ if len(page) == 3:
401+ raise UnexpectedLicenseException("License not expected here.")
402+ else:
403+ return self.parse_header(self.fetcher.header)
404+
405+ def get_content(self):
406+ """Get contents from the current url."""
407+ return self.fetcher.get(self.current_url)
408+
409+ def get_content_title(self):
410+ """Get content title from the current url."""
411+ return self.get_title(self.fetcher.get(self.current_url))
412+
413+ def get_header_when_redirected(self):
414+ """Get header when the client is redirected to the license."""
415+ self.fetcher.get(self.current_url)
416+ return self.parse_header(self.fetcher.header)
417+
418+ def accept_license_get_header(self):
419+ """Accept license and get header of the file it redirects to."""
420+ license = self.fetcher.get_or_return_license(self.current_url)
421+ # Second element in result is the accept link.
422+ if license[1]:
423+ self.fetcher.get_protected_file(license[1], self.current_url)
424+ return self.parse_header(self.fetcher.header)
425+ else:
426+ raise NoLicenseException("License expected here.")
427+
428+ def decline_license(self):
429+ """Decline license. Return title of the page."""
430+ return self.get_title(
431+ self.fetcher.get(self.current_url, accept_license=False)
432+ )
433+
434+ def parse_header(self, header):
435+ """Formats headers from dict form to the multi-line string."""
436+ header_str = ""
437+ for key in sorted(header.iterkeys()):
438+ header_str += "%s: %s\n" % (key, header[key])
439+ return header_str
440+
441+ def get_title(self, html):
442+ soup = BeautifulSoup(html)
443+ titles_all = soup.findAll('title')
444+ if len(titles_all) > 0:
445+ return titles_all[0].contents[0]
446+ else:
447+ return ""
448+
449+ def browse_to_relative(self, path):
450+ """Change current url relatively."""
451+ self.current_url += path
452+
453+ def browse_to_absolute(self, path):
454+ """Change current url to specified path."""
455+ self.current_url = self.host_address + path
456+
457+ def browse_to_next(self, condition):
458+ """Browse to next dir/build file that matches condition.
459+
460+ Set the current URL to to match the condition among the
461+ links in the current page with priority to build files.
462+ If there's no match, set link to build file if present.
463+ Otherwise, set link to first directory present.
464+ """
465+ links = self.find_links(self.get_content())
466+ link = self.find_link_with_condition(links, condition)
467+ if not link:
468+ # No link matching condition, get first build in list.
469+ link = self.find_build_tar_bz2(links)
470+ if not link:
471+ # Still no link, just get first dir in list.
472+ link = self.find_directory(links)
473+ if not link:
474+ # We found page with no directories nor builds.
475+ raise EmptyDirectoryException("Directory is empty.")
476+
477+ self.browse_to_relative(link)
478+
479+ def find_links(self, html):
480+ """Return list of links on the page with special conditions.
481+
482+ Return all links below the "Parent directory" link.
483+ Return whole list if there is no such link.
484+ """
485+ soup = BeautifulSoup(html)
486+ links_all = soup.findAll('a')
487+ had_parent = False
488+ links = []
489+ for link in links_all:
490+ if had_parent:
491+ links.append(link.get("href"))
492+ if link.contents[0] == "Parent Directory":
493+ had_parent = True
494+
495+ if had_parent:
496+ return links
497+ else:
498+ return [each.get('href') for each in links_all]
499+
500+ def find_link_with_condition(self, links, condition):
501+ """Finds a link which satisfies the condition.
502+
503+ Condition is actually to contain the string from the list.
504+ Build files (which end in .tar.bz2) have the priority.
505+ """
506+ for link in links:
507+ if condition in link and link[-7:] == "tar.bz2":
508+ return link
509+ for link in links:
510+ if condition in link:
511+ return link
512+ return None
513+
514+ def find_directory(self, links):
515+ """Finds a directory among list of links."""
516+ for link in links:
517+ if self.is_dir(link):
518+ return link
519+ return None
520+
521+ def find_build_tar_bz2(self, links):
522+ """Finds a file list of links which ends in tar.bz2."""
523+ for link in links:
524+ if link[-7:] == "tar.bz2":
525+ return link
526+ return None
527
528=== modified file 'testing/license_protected_file_downloader.py'
529--- testing/license_protected_file_downloader.py 2012-05-11 08:32:52 +0000
530+++ testing/license_protected_file_downloader.py 2012-05-16 16:49:24 +0000
531@@ -8,6 +8,7 @@
532 import html2text
533 from BeautifulSoup import BeautifulSoup
534
535+
536 class LicenseProtectedFileFetcher:
537 """Fetch a file from the web that may be protected by a license redirect
538
539@@ -124,7 +125,8 @@
540
541 return self.body
542
543- def get(self, url, file_name=None, ignore_license=False, accept_license=True):
544+ def get(self, url, file_name=None, ignore_license=False,
545+ accept_license=True):
546 """Fetch the requested URL, accepting licenses
547
548 Fetches the file at url. If a redirect is encountered, it is
549@@ -241,7 +243,7 @@
550
551 # Only buffer first 1MB of body. This should be plenty for anything
552 # we wish to parse internally.
553- if len(self.body) < 1024*1024*1024:
554+ if len(self.body) < 1024 * 1024 * 1024:
555 # XXX Would be nice to stop keeping the file in RAM at all and
556 # passing large buffers around. Perhaps only keep in RAM if
557 # file_name == None? (used for getting directory listings
558@@ -260,6 +262,7 @@
559 """Wrapper to close curl - this will allow curl to write out cookies"""
560 self.curl.close()
561
562+
563 def main():
564 """Download file specified on command line"""
565 parser = argparse.ArgumentParser(description="Download a file, accepting "

Subscribers

People subscribed via source and target branches