Integrate automatic discovery of client versions
[snf-cloudcms] / cloudcms / clients.py
1 """
2 CMS dynamic application clients module
3
4 Helper module to automatically retrieve client download links from different
5 sources (e.g. redmine files page).
6 """
7
8 import urllib, urllib2, cookielib, urlparse
9
10 from datetime import datetime
11 from lxml import html
12
13 from synnefo import settings
14
15 CLIENTS_CACHE_TIMEOUT = getattr(settings, 'CLOUDCMS_CLIENTS_CACHE_TIMEOUT', 120)
16
17 class VersionSource(object):
18     """
19     Base class for the different version source handlers.
20     """
21     def __init__(self, link=None, os="linux", arch="all", regex=".", name=None,
22             cache_backend=None, extra_options={}):
23         self.os = os
24         self.arch = arch
25         self.link = link
26         self.versions = []
27         extra_options.update({'source_type': self.source_type, 'os': os})
28         self.extra_version_options = extra_options
29
30         self.cache_backend = cache_backend
31         self.cache_key = self.os + self.arch + self.link
32
33         if not name:
34             self.name = os
35
36         # generic urllib2 opener
37         self.opener = urllib2.build_opener(
38                     urllib2.HTTPRedirectHandler(),
39                     urllib2.HTTPHandler(debuglevel=0),
40                     urllib2.HTTPSHandler(debuglevel=0),
41                     urllib2.HTTPCookieProcessor(cookielib.CookieJar()))
42
43     def get_url(self, url):
44         """
45         Load url content and return the html etree object.
46         """
47         return html.document_fromstring(self.opener.open(url).read())
48
49     def load(self):
50         """
51         Fill self.versions attribute with dict objects of the following format
52
53         {'date': datetime.datetime(2012, 3, 16, 14, 29),
54          'link': 'http://www.domain.com/clientdownload.exe',
55          'name': 'Client download',
56          'os': 'windows',
57          'version': None}
58         """
59         raise NotImplemented
60
61     def update(self):
62         """
63         Load wrapper which handles versions caching if cache_backend is set
64         """
65         if self.cache_backend:
66             self.versions = self.cache_backend.get(self.cache_key)
67
68         if not self.versions:
69             self.load()
70
71         if self.cache_backend:
72             self.cache_backend.set(self.cache_key, self.versions, CLIENTS_CACHE_TIMEOUT)
73
74     def get_latest(self):
75         """
76         Return the latest versions
77         """
78
79         # update versions
80         self.update()
81
82         # check that at least one version is available
83         if len(self.versions):
84             version = self.versions[0]
85             version.update(self.extra_version_options)
86             return version
87
88         return None
89
90
91 class RedmineSource(VersionSource):
92     """
93     Parse a redmine project files page and return the list of existing files.
94     """
95     source_type = 'redmine_files'
96
97     def load(self):
98         """
99         Load redmine files url and extract downloads. Also parse date to be
100         able to identify latest download.
101         """
102         spliturl = urlparse.urlsplit(self.link)
103         baseurl = spliturl.geturl().replace(spliturl.path, '')
104         html = self.get_url(self.link)
105         files = html.xpath("//tr[contains(@class, 'file')]")
106
107         # helper lambdas
108         def _parse_row(row):
109             name = row.xpath("td[@class='filename']/a")[0].text
110             link = baseurl + row.xpath("td[@class='filename']/a")[0].attrib.get('href')
111             strdate = row.xpath("td[@class='created_on']")[0].text
112             date = datetime.strptime(strdate, '%m/%d/%Y %I:%M %p')
113             return {'name': name, 'link': link, 'date': date, 'version': None}
114
115         versions = map(_parse_row, files)
116         versions.sort(reverse=True, key=lambda r:r['date'])
117         self.versions = versions
118         return self
119
120
121 class DirectSource(VersionSource):
122     """
123     Direct link to a version. Dummy VersionSource which always returns one entry
124     for the provided link.
125     """
126     source_type = 'direct'
127
128     def load(self):
129         self.versions = [{'name': self.name, 'link': self.link, 'date': None}]
130         return self.versions
131
132
133 class LinkSource(DirectSource):
134     """
135     Used when version exists in some other url (e.g. apple store client)
136     """
137     source_type = 'link'
138
139
140 class ClientVersions(object):
141     """
142     Client versions manager. Given a sources dict like::
143
144     {'windows': {'source_type': 'direct', 'args':
145     ['http://clients.com/win.exe'], 'kwargs': {}},
146      'linux': {'redmine_files': 'direct',
147      'args': ['http://redmine.com/projects/client/files'],
148      'kwargs': {}}}
149
150     initializes a dict of proper VersionSource objects.
151     """
152
153     def __init__(self, sources, cache_backend=None):
154         self._sources = sources
155         self.sources = {}
156
157         for s in self._sources:
158             source_params = self._sources.get(s)
159             if source_params['type'] in SOURCE_TYPES:
160                 kwargs = {'os': s, 'cache_backend': cache_backend}
161                 args = source_params.get('args', [])
162                 self.sources[s] = SOURCE_TYPES[source_params['type']](*args, **kwargs)
163
164     def get_latest_versions(self):
165         """
166         Return the latest version of each version source.
167         """
168         for os, source in self.sources.iteritems():
169             yield source.get_latest()
170
171
172 # SOURCE TYPES CLASS MAP
173 SOURCE_TYPES = {
174     'redmine_files': RedmineSource,
175     'direct': DirectSource,
176     'link': LinkSource
177 }
178