##// END OF EJS Templates
ui: add environ property to access os.environ or wsgirequest.environ...
Sune Foldager -
r9887:38170eee default
parent child Browse files
Show More
@@ -1,322 +1,324
1 # hgweb/hgweb_mod.py - Web interface for a repository.
1 # hgweb/hgweb_mod.py - Web interface for a repository.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2, incorporated herein by reference.
7 # GNU General Public License version 2, incorporated herein by reference.
8
8
9 import os
9 import os
10 from mercurial import ui, hg, hook, error, encoding, templater
10 from mercurial import ui, hg, hook, error, encoding, templater
11 from common import get_mtime, ErrorResponse
11 from common import get_mtime, ErrorResponse
12 from common import HTTP_OK, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR
12 from common import HTTP_OK, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR
13 from common import HTTP_UNAUTHORIZED, HTTP_METHOD_NOT_ALLOWED
13 from common import HTTP_UNAUTHORIZED, HTTP_METHOD_NOT_ALLOWED
14 from request import wsgirequest
14 from request import wsgirequest
15 import webcommands, protocol, webutil
15 import webcommands, protocol, webutil
16
16
17 perms = {
17 perms = {
18 'changegroup': 'pull',
18 'changegroup': 'pull',
19 'changegroupsubset': 'pull',
19 'changegroupsubset': 'pull',
20 'unbundle': 'push',
20 'unbundle': 'push',
21 'stream_out': 'pull',
21 'stream_out': 'pull',
22 }
22 }
23
23
24 class hgweb(object):
24 class hgweb(object):
25 def __init__(self, repo, name=None):
25 def __init__(self, repo, name=None):
26 if isinstance(repo, str):
26 if isinstance(repo, str):
27 u = ui.ui()
27 u = ui.ui()
28 u.setconfig('ui', 'report_untrusted', 'off')
28 u.setconfig('ui', 'report_untrusted', 'off')
29 u.setconfig('ui', 'interactive', 'off')
29 u.setconfig('ui', 'interactive', 'off')
30 self.repo = hg.repository(u, repo)
30 self.repo = hg.repository(u, repo)
31 else:
31 else:
32 self.repo = repo
32 self.repo = repo
33
33
34 hook.redirect(True)
34 hook.redirect(True)
35 self.mtime = -1
35 self.mtime = -1
36 self.reponame = name
36 self.reponame = name
37 self.archives = 'zip', 'gz', 'bz2'
37 self.archives = 'zip', 'gz', 'bz2'
38 self.stripecount = 1
38 self.stripecount = 1
39 # a repo owner may set web.templates in .hg/hgrc to get any file
39 # a repo owner may set web.templates in .hg/hgrc to get any file
40 # readable by the user running the CGI script
40 # readable by the user running the CGI script
41 self.templatepath = self.config('web', 'templates')
41 self.templatepath = self.config('web', 'templates')
42
42
43 # The CGI scripts are often run by a user different from the repo owner.
43 # The CGI scripts are often run by a user different from the repo owner.
44 # Trust the settings from the .hg/hgrc files by default.
44 # Trust the settings from the .hg/hgrc files by default.
45 def config(self, section, name, default=None, untrusted=True):
45 def config(self, section, name, default=None, untrusted=True):
46 return self.repo.ui.config(section, name, default,
46 return self.repo.ui.config(section, name, default,
47 untrusted=untrusted)
47 untrusted=untrusted)
48
48
49 def configbool(self, section, name, default=False, untrusted=True):
49 def configbool(self, section, name, default=False, untrusted=True):
50 return self.repo.ui.configbool(section, name, default,
50 return self.repo.ui.configbool(section, name, default,
51 untrusted=untrusted)
51 untrusted=untrusted)
52
52
53 def configlist(self, section, name, default=None, untrusted=True):
53 def configlist(self, section, name, default=None, untrusted=True):
54 return self.repo.ui.configlist(section, name, default,
54 return self.repo.ui.configlist(section, name, default,
55 untrusted=untrusted)
55 untrusted=untrusted)
56
56
57 def refresh(self):
57 def refresh(self, request=None):
58 if request:
59 self.ui.environ = request.environ
58 mtime = get_mtime(self.repo.root)
60 mtime = get_mtime(self.repo.root)
59 if mtime != self.mtime:
61 if mtime != self.mtime:
60 self.mtime = mtime
62 self.mtime = mtime
61 self.repo = hg.repository(self.repo.ui, self.repo.root)
63 self.repo = hg.repository(self.repo.ui, self.repo.root)
62 self.maxchanges = int(self.config("web", "maxchanges", 10))
64 self.maxchanges = int(self.config("web", "maxchanges", 10))
63 self.stripecount = int(self.config("web", "stripes", 1))
65 self.stripecount = int(self.config("web", "stripes", 1))
64 self.maxshortchanges = int(self.config("web", "maxshortchanges", 60))
66 self.maxshortchanges = int(self.config("web", "maxshortchanges", 60))
65 self.maxfiles = int(self.config("web", "maxfiles", 10))
67 self.maxfiles = int(self.config("web", "maxfiles", 10))
66 self.allowpull = self.configbool("web", "allowpull", True)
68 self.allowpull = self.configbool("web", "allowpull", True)
67 encoding.encoding = self.config("web", "encoding",
69 encoding.encoding = self.config("web", "encoding",
68 encoding.encoding)
70 encoding.encoding)
69
71
70 def run(self):
72 def run(self):
71 if not os.environ.get('GATEWAY_INTERFACE', '').startswith("CGI/1."):
73 if not os.environ.get('GATEWAY_INTERFACE', '').startswith("CGI/1."):
72 raise RuntimeError("This function is only intended to be "
74 raise RuntimeError("This function is only intended to be "
73 "called while running as a CGI script.")
75 "called while running as a CGI script.")
74 import mercurial.hgweb.wsgicgi as wsgicgi
76 import mercurial.hgweb.wsgicgi as wsgicgi
75 wsgicgi.launch(self)
77 wsgicgi.launch(self)
76
78
77 def __call__(self, env, respond):
79 def __call__(self, env, respond):
78 req = wsgirequest(env, respond)
80 req = wsgirequest(env, respond)
79 return self.run_wsgi(req)
81 return self.run_wsgi(req)
80
82
81 def run_wsgi(self, req):
83 def run_wsgi(self, req):
82
84
83 self.refresh()
85 self.refresh(req)
84
86
85 # work with CGI variables to create coherent structure
87 # work with CGI variables to create coherent structure
86 # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME
88 # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME
87
89
88 req.url = req.env['SCRIPT_NAME']
90 req.url = req.env['SCRIPT_NAME']
89 if not req.url.endswith('/'):
91 if not req.url.endswith('/'):
90 req.url += '/'
92 req.url += '/'
91 if 'REPO_NAME' in req.env:
93 if 'REPO_NAME' in req.env:
92 req.url += req.env['REPO_NAME'] + '/'
94 req.url += req.env['REPO_NAME'] + '/'
93
95
94 if 'PATH_INFO' in req.env:
96 if 'PATH_INFO' in req.env:
95 parts = req.env['PATH_INFO'].strip('/').split('/')
97 parts = req.env['PATH_INFO'].strip('/').split('/')
96 repo_parts = req.env.get('REPO_NAME', '').split('/')
98 repo_parts = req.env.get('REPO_NAME', '').split('/')
97 if parts[:len(repo_parts)] == repo_parts:
99 if parts[:len(repo_parts)] == repo_parts:
98 parts = parts[len(repo_parts):]
100 parts = parts[len(repo_parts):]
99 query = '/'.join(parts)
101 query = '/'.join(parts)
100 else:
102 else:
101 query = req.env['QUERY_STRING'].split('&', 1)[0]
103 query = req.env['QUERY_STRING'].split('&', 1)[0]
102 query = query.split(';', 1)[0]
104 query = query.split(';', 1)[0]
103
105
104 # process this if it's a protocol request
106 # process this if it's a protocol request
105 # protocol bits don't need to create any URLs
107 # protocol bits don't need to create any URLs
106 # and the clients always use the old URL structure
108 # and the clients always use the old URL structure
107
109
108 cmd = req.form.get('cmd', [''])[0]
110 cmd = req.form.get('cmd', [''])[0]
109 if cmd and cmd in protocol.__all__:
111 if cmd and cmd in protocol.__all__:
110 if query:
112 if query:
111 raise ErrorResponse(HTTP_NOT_FOUND)
113 raise ErrorResponse(HTTP_NOT_FOUND)
112 try:
114 try:
113 if cmd in perms:
115 if cmd in perms:
114 try:
116 try:
115 self.check_perm(req, perms[cmd])
117 self.check_perm(req, perms[cmd])
116 except ErrorResponse, inst:
118 except ErrorResponse, inst:
117 if cmd == 'unbundle':
119 if cmd == 'unbundle':
118 req.drain()
120 req.drain()
119 raise
121 raise
120 method = getattr(protocol, cmd)
122 method = getattr(protocol, cmd)
121 return method(self.repo, req)
123 return method(self.repo, req)
122 except ErrorResponse, inst:
124 except ErrorResponse, inst:
123 req.respond(inst, protocol.HGTYPE)
125 req.respond(inst, protocol.HGTYPE)
124 if not inst.message:
126 if not inst.message:
125 return []
127 return []
126 return '0\n%s\n' % inst.message,
128 return '0\n%s\n' % inst.message,
127
129
128 # translate user-visible url structure to internal structure
130 # translate user-visible url structure to internal structure
129
131
130 args = query.split('/', 2)
132 args = query.split('/', 2)
131 if 'cmd' not in req.form and args and args[0]:
133 if 'cmd' not in req.form and args and args[0]:
132
134
133 cmd = args.pop(0)
135 cmd = args.pop(0)
134 style = cmd.rfind('-')
136 style = cmd.rfind('-')
135 if style != -1:
137 if style != -1:
136 req.form['style'] = [cmd[:style]]
138 req.form['style'] = [cmd[:style]]
137 cmd = cmd[style+1:]
139 cmd = cmd[style+1:]
138
140
139 # avoid accepting e.g. style parameter as command
141 # avoid accepting e.g. style parameter as command
140 if hasattr(webcommands, cmd):
142 if hasattr(webcommands, cmd):
141 req.form['cmd'] = [cmd]
143 req.form['cmd'] = [cmd]
142 else:
144 else:
143 cmd = ''
145 cmd = ''
144
146
145 if cmd == 'static':
147 if cmd == 'static':
146 req.form['file'] = ['/'.join(args)]
148 req.form['file'] = ['/'.join(args)]
147 else:
149 else:
148 if args and args[0]:
150 if args and args[0]:
149 node = args.pop(0)
151 node = args.pop(0)
150 req.form['node'] = [node]
152 req.form['node'] = [node]
151 if args:
153 if args:
152 req.form['file'] = args
154 req.form['file'] = args
153
155
154 ua = req.env.get('HTTP_USER_AGENT', '')
156 ua = req.env.get('HTTP_USER_AGENT', '')
155 if cmd == 'rev' and 'mercurial' in ua:
157 if cmd == 'rev' and 'mercurial' in ua:
156 req.form['style'] = ['raw']
158 req.form['style'] = ['raw']
157
159
158 if cmd == 'archive':
160 if cmd == 'archive':
159 fn = req.form['node'][0]
161 fn = req.form['node'][0]
160 for type_, spec in self.archive_specs.iteritems():
162 for type_, spec in self.archive_specs.iteritems():
161 ext = spec[2]
163 ext = spec[2]
162 if fn.endswith(ext):
164 if fn.endswith(ext):
163 req.form['node'] = [fn[:-len(ext)]]
165 req.form['node'] = [fn[:-len(ext)]]
164 req.form['type'] = [type_]
166 req.form['type'] = [type_]
165
167
166 # process the web interface request
168 # process the web interface request
167
169
168 try:
170 try:
169 tmpl = self.templater(req)
171 tmpl = self.templater(req)
170 ctype = tmpl('mimetype', encoding=encoding.encoding)
172 ctype = tmpl('mimetype', encoding=encoding.encoding)
171 ctype = templater.stringify(ctype)
173 ctype = templater.stringify(ctype)
172
174
173 # check read permissions non-static content
175 # check read permissions non-static content
174 if cmd != 'static':
176 if cmd != 'static':
175 self.check_perm(req, None)
177 self.check_perm(req, None)
176
178
177 if cmd == '':
179 if cmd == '':
178 req.form['cmd'] = [tmpl.cache['default']]
180 req.form['cmd'] = [tmpl.cache['default']]
179 cmd = req.form['cmd'][0]
181 cmd = req.form['cmd'][0]
180
182
181 if cmd not in webcommands.__all__:
183 if cmd not in webcommands.__all__:
182 msg = 'no such method: %s' % cmd
184 msg = 'no such method: %s' % cmd
183 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
185 raise ErrorResponse(HTTP_BAD_REQUEST, msg)
184 elif cmd == 'file' and 'raw' in req.form.get('style', []):
186 elif cmd == 'file' and 'raw' in req.form.get('style', []):
185 self.ctype = ctype
187 self.ctype = ctype
186 content = webcommands.rawfile(self, req, tmpl)
188 content = webcommands.rawfile(self, req, tmpl)
187 else:
189 else:
188 content = getattr(webcommands, cmd)(self, req, tmpl)
190 content = getattr(webcommands, cmd)(self, req, tmpl)
189 req.respond(HTTP_OK, ctype)
191 req.respond(HTTP_OK, ctype)
190
192
191 return content
193 return content
192
194
193 except error.LookupError, err:
195 except error.LookupError, err:
194 req.respond(HTTP_NOT_FOUND, ctype)
196 req.respond(HTTP_NOT_FOUND, ctype)
195 msg = str(err)
197 msg = str(err)
196 if 'manifest' not in msg:
198 if 'manifest' not in msg:
197 msg = 'revision not found: %s' % err.name
199 msg = 'revision not found: %s' % err.name
198 return tmpl('error', error=msg)
200 return tmpl('error', error=msg)
199 except (error.RepoError, error.RevlogError), inst:
201 except (error.RepoError, error.RevlogError), inst:
200 req.respond(HTTP_SERVER_ERROR, ctype)
202 req.respond(HTTP_SERVER_ERROR, ctype)
201 return tmpl('error', error=str(inst))
203 return tmpl('error', error=str(inst))
202 except ErrorResponse, inst:
204 except ErrorResponse, inst:
203 req.respond(inst, ctype)
205 req.respond(inst, ctype)
204 return tmpl('error', error=inst.message)
206 return tmpl('error', error=inst.message)
205
207
206 def templater(self, req):
208 def templater(self, req):
207
209
208 # determine scheme, port and server name
210 # determine scheme, port and server name
209 # this is needed to create absolute urls
211 # this is needed to create absolute urls
210
212
211 proto = req.env.get('wsgi.url_scheme')
213 proto = req.env.get('wsgi.url_scheme')
212 if proto == 'https':
214 if proto == 'https':
213 proto = 'https'
215 proto = 'https'
214 default_port = "443"
216 default_port = "443"
215 else:
217 else:
216 proto = 'http'
218 proto = 'http'
217 default_port = "80"
219 default_port = "80"
218
220
219 port = req.env["SERVER_PORT"]
221 port = req.env["SERVER_PORT"]
220 port = port != default_port and (":" + port) or ""
222 port = port != default_port and (":" + port) or ""
221 urlbase = '%s://%s%s' % (proto, req.env['SERVER_NAME'], port)
223 urlbase = '%s://%s%s' % (proto, req.env['SERVER_NAME'], port)
222 staticurl = self.config("web", "staticurl") or req.url + 'static/'
224 staticurl = self.config("web", "staticurl") or req.url + 'static/'
223 if not staticurl.endswith('/'):
225 if not staticurl.endswith('/'):
224 staticurl += '/'
226 staticurl += '/'
225
227
226 # some functions for the templater
228 # some functions for the templater
227
229
228 def header(**map):
230 def header(**map):
229 yield tmpl('header', encoding=encoding.encoding, **map)
231 yield tmpl('header', encoding=encoding.encoding, **map)
230
232
231 def footer(**map):
233 def footer(**map):
232 yield tmpl("footer", **map)
234 yield tmpl("footer", **map)
233
235
234 def motd(**map):
236 def motd(**map):
235 yield self.config("web", "motd", "")
237 yield self.config("web", "motd", "")
236
238
237 # figure out which style to use
239 # figure out which style to use
238
240
239 vars = {}
241 vars = {}
240 styles = (
242 styles = (
241 req.form.get('style', [None])[0],
243 req.form.get('style', [None])[0],
242 self.config('web', 'style'),
244 self.config('web', 'style'),
243 'paper',
245 'paper',
244 )
246 )
245 style, mapfile = templater.stylemap(styles, self.templatepath)
247 style, mapfile = templater.stylemap(styles, self.templatepath)
246 if style == styles[0]:
248 if style == styles[0]:
247 vars['style'] = style
249 vars['style'] = style
248
250
249 start = req.url[-1] == '?' and '&' or '?'
251 start = req.url[-1] == '?' and '&' or '?'
250 sessionvars = webutil.sessionvars(vars, start)
252 sessionvars = webutil.sessionvars(vars, start)
251
253
252 if not self.reponame:
254 if not self.reponame:
253 self.reponame = (self.config("web", "name")
255 self.reponame = (self.config("web", "name")
254 or req.env.get('REPO_NAME')
256 or req.env.get('REPO_NAME')
255 or req.url.strip('/') or self.repo.root)
257 or req.url.strip('/') or self.repo.root)
256
258
257 # create the templater
259 # create the templater
258
260
259 tmpl = templater.templater(mapfile,
261 tmpl = templater.templater(mapfile,
260 defaults={"url": req.url,
262 defaults={"url": req.url,
261 "staticurl": staticurl,
263 "staticurl": staticurl,
262 "urlbase": urlbase,
264 "urlbase": urlbase,
263 "repo": self.reponame,
265 "repo": self.reponame,
264 "header": header,
266 "header": header,
265 "footer": footer,
267 "footer": footer,
266 "motd": motd,
268 "motd": motd,
267 "sessionvars": sessionvars
269 "sessionvars": sessionvars
268 })
270 })
269 return tmpl
271 return tmpl
270
272
271 def archivelist(self, nodeid):
273 def archivelist(self, nodeid):
272 allowed = self.configlist("web", "allow_archive")
274 allowed = self.configlist("web", "allow_archive")
273 for i, spec in self.archive_specs.iteritems():
275 for i, spec in self.archive_specs.iteritems():
274 if i in allowed or self.configbool("web", "allow" + i):
276 if i in allowed or self.configbool("web", "allow" + i):
275 yield {"type" : i, "extension" : spec[2], "node" : nodeid}
277 yield {"type" : i, "extension" : spec[2], "node" : nodeid}
276
278
277 archive_specs = {
279 archive_specs = {
278 'bz2': ('application/x-tar', 'tbz2', '.tar.bz2', None),
280 'bz2': ('application/x-tar', 'tbz2', '.tar.bz2', None),
279 'gz': ('application/x-tar', 'tgz', '.tar.gz', None),
281 'gz': ('application/x-tar', 'tgz', '.tar.gz', None),
280 'zip': ('application/zip', 'zip', '.zip', None),
282 'zip': ('application/zip', 'zip', '.zip', None),
281 }
283 }
282
284
283 def check_perm(self, req, op):
285 def check_perm(self, req, op):
284 '''Check permission for operation based on request data (including
286 '''Check permission for operation based on request data (including
285 authentication info). Return if op allowed, else raise an ErrorResponse
287 authentication info). Return if op allowed, else raise an ErrorResponse
286 exception.'''
288 exception.'''
287
289
288 user = req.env.get('REMOTE_USER')
290 user = req.env.get('REMOTE_USER')
289
291
290 deny_read = self.configlist('web', 'deny_read')
292 deny_read = self.configlist('web', 'deny_read')
291 if deny_read and (not user or deny_read == ['*'] or user in deny_read):
293 if deny_read and (not user or deny_read == ['*'] or user in deny_read):
292 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
294 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
293
295
294 allow_read = self.configlist('web', 'allow_read')
296 allow_read = self.configlist('web', 'allow_read')
295 result = (not allow_read) or (allow_read == ['*'])
297 result = (not allow_read) or (allow_read == ['*'])
296 if not (result or user in allow_read):
298 if not (result or user in allow_read):
297 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
299 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
298
300
299 if op == 'pull' and not self.allowpull:
301 if op == 'pull' and not self.allowpull:
300 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
302 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
301 elif op == 'pull' or op is None: # op is None for interface requests
303 elif op == 'pull' or op is None: # op is None for interface requests
302 return
304 return
303
305
304 # enforce that you can only push using POST requests
306 # enforce that you can only push using POST requests
305 if req.env['REQUEST_METHOD'] != 'POST':
307 if req.env['REQUEST_METHOD'] != 'POST':
306 msg = 'push requires POST request'
308 msg = 'push requires POST request'
307 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
309 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
308
310
309 # require ssl by default for pushing, auth info cannot be sniffed
311 # require ssl by default for pushing, auth info cannot be sniffed
310 # and replayed
312 # and replayed
311 scheme = req.env.get('wsgi.url_scheme')
313 scheme = req.env.get('wsgi.url_scheme')
312 if self.configbool('web', 'push_ssl', True) and scheme != 'https':
314 if self.configbool('web', 'push_ssl', True) and scheme != 'https':
313 raise ErrorResponse(HTTP_OK, 'ssl required')
315 raise ErrorResponse(HTTP_OK, 'ssl required')
314
316
315 deny = self.configlist('web', 'deny_push')
317 deny = self.configlist('web', 'deny_push')
316 if deny and (not user or deny == ['*'] or user in deny):
318 if deny and (not user or deny == ['*'] or user in deny):
317 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
319 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
318
320
319 allow = self.configlist('web', 'allow_push')
321 allow = self.configlist('web', 'allow_push')
320 result = allow and (allow == ['*'] or user in allow)
322 result = allow and (allow == ['*'] or user in allow)
321 if not result:
323 if not result:
322 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
324 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
@@ -1,386 +1,389
1 # ui.py - user interface bits for mercurial
1 # ui.py - user interface bits for mercurial
2 #
2 #
3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
6 # GNU General Public License version 2, incorporated herein by reference.
7
7
8 from i18n import _
8 from i18n import _
9 import errno, getpass, os, socket, sys, tempfile, traceback
9 import errno, getpass, os, socket, sys, tempfile, traceback
10 import config, util, error
10 import config, util, error
11
11
12 _booleans = {'1': True, 'yes': True, 'true': True, 'on': True,
12 _booleans = {'1': True, 'yes': True, 'true': True, 'on': True,
13 '0': False, 'no': False, 'false': False, 'off': False}
13 '0': False, 'no': False, 'false': False, 'off': False}
14
14
15 class ui(object):
15 class ui(object):
16 def __init__(self, src=None):
16 def __init__(self, src=None):
17 self._buffers = []
17 self._buffers = []
18 self.quiet = self.verbose = self.debugflag = self.tracebackflag = False
18 self.quiet = self.verbose = self.debugflag = self.tracebackflag = False
19 self._reportuntrusted = True
19 self._reportuntrusted = True
20 self._ocfg = config.config() # overlay
20 self._ocfg = config.config() # overlay
21 self._tcfg = config.config() # trusted
21 self._tcfg = config.config() # trusted
22 self._ucfg = config.config() # untrusted
22 self._ucfg = config.config() # untrusted
23 self._trustusers = set()
23 self._trustusers = set()
24 self._trustgroups = set()
24 self._trustgroups = set()
25
25
26 if src:
26 if src:
27 self._tcfg = src._tcfg.copy()
27 self._tcfg = src._tcfg.copy()
28 self._ucfg = src._ucfg.copy()
28 self._ucfg = src._ucfg.copy()
29 self._ocfg = src._ocfg.copy()
29 self._ocfg = src._ocfg.copy()
30 self._trustusers = src._trustusers.copy()
30 self._trustusers = src._trustusers.copy()
31 self._trustgroups = src._trustgroups.copy()
31 self._trustgroups = src._trustgroups.copy()
32 self.environ = src.environ
32 self.fixconfig()
33 self.fixconfig()
33 else:
34 else:
35 # shared read-only environment
36 self.environ = os.environ
34 # we always trust global config files
37 # we always trust global config files
35 for f in util.rcpath():
38 for f in util.rcpath():
36 self.readconfig(f, trust=True)
39 self.readconfig(f, trust=True)
37
40
38 def copy(self):
41 def copy(self):
39 return self.__class__(self)
42 return self.__class__(self)
40
43
41 def _is_trusted(self, fp, f):
44 def _is_trusted(self, fp, f):
42 st = util.fstat(fp)
45 st = util.fstat(fp)
43 if util.isowner(st):
46 if util.isowner(st):
44 return True
47 return True
45
48
46 tusers, tgroups = self._trustusers, self._trustgroups
49 tusers, tgroups = self._trustusers, self._trustgroups
47 if '*' in tusers or '*' in tgroups:
50 if '*' in tusers or '*' in tgroups:
48 return True
51 return True
49
52
50 user = util.username(st.st_uid)
53 user = util.username(st.st_uid)
51 group = util.groupname(st.st_gid)
54 group = util.groupname(st.st_gid)
52 if user in tusers or group in tgroups or user == util.username():
55 if user in tusers or group in tgroups or user == util.username():
53 return True
56 return True
54
57
55 if self._reportuntrusted:
58 if self._reportuntrusted:
56 self.warn(_('Not trusting file %s from untrusted '
59 self.warn(_('Not trusting file %s from untrusted '
57 'user %s, group %s\n') % (f, user, group))
60 'user %s, group %s\n') % (f, user, group))
58 return False
61 return False
59
62
60 def readconfig(self, filename, root=None, trust=False,
63 def readconfig(self, filename, root=None, trust=False,
61 sections=None, remap=None):
64 sections=None, remap=None):
62 try:
65 try:
63 fp = open(filename)
66 fp = open(filename)
64 except IOError:
67 except IOError:
65 if not sections: # ignore unless we were looking for something
68 if not sections: # ignore unless we were looking for something
66 return
69 return
67 raise
70 raise
68
71
69 cfg = config.config()
72 cfg = config.config()
70 trusted = sections or trust or self._is_trusted(fp, filename)
73 trusted = sections or trust or self._is_trusted(fp, filename)
71
74
72 try:
75 try:
73 cfg.read(filename, fp, sections=sections, remap=remap)
76 cfg.read(filename, fp, sections=sections, remap=remap)
74 except error.ConfigError, inst:
77 except error.ConfigError, inst:
75 if trusted:
78 if trusted:
76 raise
79 raise
77 self.warn(_("Ignored: %s\n") % str(inst))
80 self.warn(_("Ignored: %s\n") % str(inst))
78
81
79 if trusted:
82 if trusted:
80 self._tcfg.update(cfg)
83 self._tcfg.update(cfg)
81 self._tcfg.update(self._ocfg)
84 self._tcfg.update(self._ocfg)
82 self._ucfg.update(cfg)
85 self._ucfg.update(cfg)
83 self._ucfg.update(self._ocfg)
86 self._ucfg.update(self._ocfg)
84
87
85 if root is None:
88 if root is None:
86 root = os.path.expanduser('~')
89 root = os.path.expanduser('~')
87 self.fixconfig(root=root)
90 self.fixconfig(root=root)
88
91
89 def fixconfig(self, root=None):
92 def fixconfig(self, root=None):
90 # translate paths relative to root (or home) into absolute paths
93 # translate paths relative to root (or home) into absolute paths
91 root = root or os.getcwd()
94 root = root or os.getcwd()
92 for c in self._tcfg, self._ucfg, self._ocfg:
95 for c in self._tcfg, self._ucfg, self._ocfg:
93 for n, p in c.items('paths'):
96 for n, p in c.items('paths'):
94 if p and "://" not in p and not os.path.isabs(p):
97 if p and "://" not in p and not os.path.isabs(p):
95 c.set("paths", n, os.path.normpath(os.path.join(root, p)))
98 c.set("paths", n, os.path.normpath(os.path.join(root, p)))
96
99
97 # update ui options
100 # update ui options
98 self.debugflag = self.configbool('ui', 'debug')
101 self.debugflag = self.configbool('ui', 'debug')
99 self.verbose = self.debugflag or self.configbool('ui', 'verbose')
102 self.verbose = self.debugflag or self.configbool('ui', 'verbose')
100 self.quiet = not self.debugflag and self.configbool('ui', 'quiet')
103 self.quiet = not self.debugflag and self.configbool('ui', 'quiet')
101 if self.verbose and self.quiet:
104 if self.verbose and self.quiet:
102 self.quiet = self.verbose = False
105 self.quiet = self.verbose = False
103 self._reportuntrusted = self.configbool("ui", "report_untrusted", True)
106 self._reportuntrusted = self.configbool("ui", "report_untrusted", True)
104 self.tracebackflag = self.configbool('ui', 'traceback', False)
107 self.tracebackflag = self.configbool('ui', 'traceback', False)
105
108
106 # update trust information
109 # update trust information
107 self._trustusers.update(self.configlist('trusted', 'users'))
110 self._trustusers.update(self.configlist('trusted', 'users'))
108 self._trustgroups.update(self.configlist('trusted', 'groups'))
111 self._trustgroups.update(self.configlist('trusted', 'groups'))
109
112
110 def setconfig(self, section, name, value):
113 def setconfig(self, section, name, value):
111 for cfg in (self._ocfg, self._tcfg, self._ucfg):
114 for cfg in (self._ocfg, self._tcfg, self._ucfg):
112 cfg.set(section, name, value)
115 cfg.set(section, name, value)
113 self.fixconfig()
116 self.fixconfig()
114
117
115 def _data(self, untrusted):
118 def _data(self, untrusted):
116 return untrusted and self._ucfg or self._tcfg
119 return untrusted and self._ucfg or self._tcfg
117
120
118 def configsource(self, section, name, untrusted=False):
121 def configsource(self, section, name, untrusted=False):
119 return self._data(untrusted).source(section, name) or 'none'
122 return self._data(untrusted).source(section, name) or 'none'
120
123
121 def config(self, section, name, default=None, untrusted=False):
124 def config(self, section, name, default=None, untrusted=False):
122 value = self._data(untrusted).get(section, name, default)
125 value = self._data(untrusted).get(section, name, default)
123 if self.debugflag and not untrusted and self._reportuntrusted:
126 if self.debugflag and not untrusted and self._reportuntrusted:
124 uvalue = self._ucfg.get(section, name)
127 uvalue = self._ucfg.get(section, name)
125 if uvalue is not None and uvalue != value:
128 if uvalue is not None and uvalue != value:
126 self.debug(_("ignoring untrusted configuration option "
129 self.debug(_("ignoring untrusted configuration option "
127 "%s.%s = %s\n") % (section, name, uvalue))
130 "%s.%s = %s\n") % (section, name, uvalue))
128 return value
131 return value
129
132
130 def configbool(self, section, name, default=False, untrusted=False):
133 def configbool(self, section, name, default=False, untrusted=False):
131 v = self.config(section, name, None, untrusted)
134 v = self.config(section, name, None, untrusted)
132 if v is None:
135 if v is None:
133 return default
136 return default
134 if v.lower() not in _booleans:
137 if v.lower() not in _booleans:
135 raise error.ConfigError(_("%s.%s not a boolean ('%s')")
138 raise error.ConfigError(_("%s.%s not a boolean ('%s')")
136 % (section, name, v))
139 % (section, name, v))
137 return _booleans[v.lower()]
140 return _booleans[v.lower()]
138
141
139 def configlist(self, section, name, default=None, untrusted=False):
142 def configlist(self, section, name, default=None, untrusted=False):
140 """Return a list of comma/space separated strings"""
143 """Return a list of comma/space separated strings"""
141 result = self.config(section, name, untrusted=untrusted)
144 result = self.config(section, name, untrusted=untrusted)
142 if result is None:
145 if result is None:
143 result = default or []
146 result = default or []
144 if isinstance(result, basestring):
147 if isinstance(result, basestring):
145 result = result.replace(",", " ").split()
148 result = result.replace(",", " ").split()
146 return result
149 return result
147
150
148 def has_section(self, section, untrusted=False):
151 def has_section(self, section, untrusted=False):
149 '''tell whether section exists in config.'''
152 '''tell whether section exists in config.'''
150 return section in self._data(untrusted)
153 return section in self._data(untrusted)
151
154
152 def configitems(self, section, untrusted=False):
155 def configitems(self, section, untrusted=False):
153 items = self._data(untrusted).items(section)
156 items = self._data(untrusted).items(section)
154 if self.debugflag and not untrusted and self._reportuntrusted:
157 if self.debugflag and not untrusted and self._reportuntrusted:
155 for k, v in self._ucfg.items(section):
158 for k, v in self._ucfg.items(section):
156 if self._tcfg.get(section, k) != v:
159 if self._tcfg.get(section, k) != v:
157 self.debug(_("ignoring untrusted configuration option "
160 self.debug(_("ignoring untrusted configuration option "
158 "%s.%s = %s\n") % (section, k, v))
161 "%s.%s = %s\n") % (section, k, v))
159 return items
162 return items
160
163
161 def walkconfig(self, untrusted=False):
164 def walkconfig(self, untrusted=False):
162 cfg = self._data(untrusted)
165 cfg = self._data(untrusted)
163 for section in cfg.sections():
166 for section in cfg.sections():
164 for name, value in self.configitems(section, untrusted):
167 for name, value in self.configitems(section, untrusted):
165 yield section, name, str(value).replace('\n', '\\n')
168 yield section, name, str(value).replace('\n', '\\n')
166
169
167 def username(self):
170 def username(self):
168 """Return default username to be used in commits.
171 """Return default username to be used in commits.
169
172
170 Searched in this order: $HGUSER, [ui] section of hgrcs, $EMAIL
173 Searched in this order: $HGUSER, [ui] section of hgrcs, $EMAIL
171 and stop searching if one of these is set.
174 and stop searching if one of these is set.
172 If not found and ui.askusername is True, ask the user, else use
175 If not found and ui.askusername is True, ask the user, else use
173 ($LOGNAME or $USER or $LNAME or $USERNAME) + "@full.hostname".
176 ($LOGNAME or $USER or $LNAME or $USERNAME) + "@full.hostname".
174 """
177 """
175 user = os.environ.get("HGUSER")
178 user = os.environ.get("HGUSER")
176 if user is None:
179 if user is None:
177 user = self.config("ui", "username")
180 user = self.config("ui", "username")
178 if user is None:
181 if user is None:
179 user = os.environ.get("EMAIL")
182 user = os.environ.get("EMAIL")
180 if user is None and self.configbool("ui", "askusername"):
183 if user is None and self.configbool("ui", "askusername"):
181 user = self.prompt(_("enter a commit username:"), default=None)
184 user = self.prompt(_("enter a commit username:"), default=None)
182 if user is None and not self.interactive():
185 if user is None and not self.interactive():
183 try:
186 try:
184 user = '%s@%s' % (util.getuser(), socket.getfqdn())
187 user = '%s@%s' % (util.getuser(), socket.getfqdn())
185 self.warn(_("No username found, using '%s' instead\n") % user)
188 self.warn(_("No username found, using '%s' instead\n") % user)
186 except KeyError:
189 except KeyError:
187 pass
190 pass
188 if not user:
191 if not user:
189 raise util.Abort(_('no username supplied (see "hg help config")'))
192 raise util.Abort(_('no username supplied (see "hg help config")'))
190 if "\n" in user:
193 if "\n" in user:
191 raise util.Abort(_("username %s contains a newline\n") % repr(user))
194 raise util.Abort(_("username %s contains a newline\n") % repr(user))
192 return user
195 return user
193
196
194 def shortuser(self, user):
197 def shortuser(self, user):
195 """Return a short representation of a user name or email address."""
198 """Return a short representation of a user name or email address."""
196 if not self.verbose: user = util.shortuser(user)
199 if not self.verbose: user = util.shortuser(user)
197 return user
200 return user
198
201
199 def _path(self, loc):
202 def _path(self, loc):
200 p = self.config('paths', loc)
203 p = self.config('paths', loc)
201 if p:
204 if p:
202 if '%%' in p:
205 if '%%' in p:
203 self.warn("(deprecated '%%' in path %s=%s from %s)\n" %
206 self.warn("(deprecated '%%' in path %s=%s from %s)\n" %
204 (loc, p, self.configsource('paths', loc)))
207 (loc, p, self.configsource('paths', loc)))
205 p = p.replace('%%', '%')
208 p = p.replace('%%', '%')
206 p = util.expandpath(p)
209 p = util.expandpath(p)
207 return p
210 return p
208
211
209 def expandpath(self, loc, default=None):
212 def expandpath(self, loc, default=None):
210 """Return repository location relative to cwd or from [paths]"""
213 """Return repository location relative to cwd or from [paths]"""
211 if "://" in loc or os.path.isdir(os.path.join(loc, '.hg')):
214 if "://" in loc or os.path.isdir(os.path.join(loc, '.hg')):
212 return loc
215 return loc
213
216
214 path = self._path(loc)
217 path = self._path(loc)
215 if not path and default is not None:
218 if not path and default is not None:
216 path = self._path(default)
219 path = self._path(default)
217 return path or loc
220 return path or loc
218
221
219 def pushbuffer(self):
222 def pushbuffer(self):
220 self._buffers.append([])
223 self._buffers.append([])
221
224
222 def popbuffer(self):
225 def popbuffer(self):
223 return "".join(self._buffers.pop())
226 return "".join(self._buffers.pop())
224
227
225 def write(self, *args):
228 def write(self, *args):
226 if self._buffers:
229 if self._buffers:
227 self._buffers[-1].extend([str(a) for a in args])
230 self._buffers[-1].extend([str(a) for a in args])
228 else:
231 else:
229 for a in args:
232 for a in args:
230 sys.stdout.write(str(a))
233 sys.stdout.write(str(a))
231
234
232 def write_err(self, *args):
235 def write_err(self, *args):
233 try:
236 try:
234 if not sys.stdout.closed: sys.stdout.flush()
237 if not sys.stdout.closed: sys.stdout.flush()
235 for a in args:
238 for a in args:
236 sys.stderr.write(str(a))
239 sys.stderr.write(str(a))
237 # stderr may be buffered under win32 when redirected to files,
240 # stderr may be buffered under win32 when redirected to files,
238 # including stdout.
241 # including stdout.
239 if not sys.stderr.closed: sys.stderr.flush()
242 if not sys.stderr.closed: sys.stderr.flush()
240 except IOError, inst:
243 except IOError, inst:
241 if inst.errno != errno.EPIPE:
244 if inst.errno != errno.EPIPE:
242 raise
245 raise
243
246
244 def flush(self):
247 def flush(self):
245 try: sys.stdout.flush()
248 try: sys.stdout.flush()
246 except: pass
249 except: pass
247 try: sys.stderr.flush()
250 try: sys.stderr.flush()
248 except: pass
251 except: pass
249
252
250 def interactive(self):
253 def interactive(self):
251 i = self.configbool("ui", "interactive", None)
254 i = self.configbool("ui", "interactive", None)
252 if i is None:
255 if i is None:
253 return sys.stdin.isatty()
256 return sys.stdin.isatty()
254 return i
257 return i
255
258
256 def _readline(self, prompt=''):
259 def _readline(self, prompt=''):
257 if sys.stdin.isatty():
260 if sys.stdin.isatty():
258 try:
261 try:
259 # magically add command line editing support, where
262 # magically add command line editing support, where
260 # available
263 # available
261 import readline
264 import readline
262 # force demandimport to really load the module
265 # force demandimport to really load the module
263 readline.read_history_file
266 readline.read_history_file
264 # windows sometimes raises something other than ImportError
267 # windows sometimes raises something other than ImportError
265 except Exception:
268 except Exception:
266 pass
269 pass
267 line = raw_input(prompt)
270 line = raw_input(prompt)
268 # When stdin is in binary mode on Windows, it can cause
271 # When stdin is in binary mode on Windows, it can cause
269 # raw_input() to emit an extra trailing carriage return
272 # raw_input() to emit an extra trailing carriage return
270 if os.linesep == '\r\n' and line and line[-1] == '\r':
273 if os.linesep == '\r\n' and line and line[-1] == '\r':
271 line = line[:-1]
274 line = line[:-1]
272 return line
275 return line
273
276
274 def prompt(self, msg, default="y"):
277 def prompt(self, msg, default="y"):
275 """Prompt user with msg, read response.
278 """Prompt user with msg, read response.
276 If ui is not interactive, the default is returned.
279 If ui is not interactive, the default is returned.
277 """
280 """
278 if not self.interactive():
281 if not self.interactive():
279 self.write(msg, ' ', default, "\n")
282 self.write(msg, ' ', default, "\n")
280 return default
283 return default
281 try:
284 try:
282 r = self._readline(msg + ' ')
285 r = self._readline(msg + ' ')
283 if not r:
286 if not r:
284 return default
287 return default
285 return r
288 return r
286 except EOFError:
289 except EOFError:
287 raise util.Abort(_('response expected'))
290 raise util.Abort(_('response expected'))
288
291
289 def promptchoice(self, msg, choices, default=0):
292 def promptchoice(self, msg, choices, default=0):
290 """Prompt user with msg, read response, and ensure it matches
293 """Prompt user with msg, read response, and ensure it matches
291 one of the provided choices. The index of the choice is returned.
294 one of the provided choices. The index of the choice is returned.
292 choices is a sequence of acceptable responses with the format:
295 choices is a sequence of acceptable responses with the format:
293 ('&None', 'E&xec', 'Sym&link') Responses are case insensitive.
296 ('&None', 'E&xec', 'Sym&link') Responses are case insensitive.
294 If ui is not interactive, the default is returned.
297 If ui is not interactive, the default is returned.
295 """
298 """
296 resps = [s[s.index('&')+1].lower() for s in choices]
299 resps = [s[s.index('&')+1].lower() for s in choices]
297 while True:
300 while True:
298 r = self.prompt(msg, resps[default])
301 r = self.prompt(msg, resps[default])
299 if r.lower() in resps:
302 if r.lower() in resps:
300 return resps.index(r.lower())
303 return resps.index(r.lower())
301 self.write(_("unrecognized response\n"))
304 self.write(_("unrecognized response\n"))
302
305
303
306
304 def getpass(self, prompt=None, default=None):
307 def getpass(self, prompt=None, default=None):
305 if not self.interactive(): return default
308 if not self.interactive(): return default
306 try:
309 try:
307 return getpass.getpass(prompt or _('password: '))
310 return getpass.getpass(prompt or _('password: '))
308 except EOFError:
311 except EOFError:
309 raise util.Abort(_('response expected'))
312 raise util.Abort(_('response expected'))
310 def status(self, *msg):
313 def status(self, *msg):
311 if not self.quiet: self.write(*msg)
314 if not self.quiet: self.write(*msg)
312 def warn(self, *msg):
315 def warn(self, *msg):
313 self.write_err(*msg)
316 self.write_err(*msg)
314 def note(self, *msg):
317 def note(self, *msg):
315 if self.verbose: self.write(*msg)
318 if self.verbose: self.write(*msg)
316 def debug(self, *msg):
319 def debug(self, *msg):
317 if self.debugflag: self.write(*msg)
320 if self.debugflag: self.write(*msg)
318 def edit(self, text, user):
321 def edit(self, text, user):
319 (fd, name) = tempfile.mkstemp(prefix="hg-editor-", suffix=".txt",
322 (fd, name) = tempfile.mkstemp(prefix="hg-editor-", suffix=".txt",
320 text=True)
323 text=True)
321 try:
324 try:
322 f = os.fdopen(fd, "w")
325 f = os.fdopen(fd, "w")
323 f.write(text)
326 f.write(text)
324 f.close()
327 f.close()
325
328
326 editor = self.geteditor()
329 editor = self.geteditor()
327
330
328 util.system("%s \"%s\"" % (editor, name),
331 util.system("%s \"%s\"" % (editor, name),
329 environ={'HGUSER': user},
332 environ={'HGUSER': user},
330 onerr=util.Abort, errprefix=_("edit failed"))
333 onerr=util.Abort, errprefix=_("edit failed"))
331
334
332 f = open(name)
335 f = open(name)
333 t = f.read()
336 t = f.read()
334 f.close()
337 f.close()
335 finally:
338 finally:
336 os.unlink(name)
339 os.unlink(name)
337
340
338 return t
341 return t
339
342
340 def traceback(self, exc=None):
343 def traceback(self, exc=None):
341 '''print exception traceback if traceback printing enabled.
344 '''print exception traceback if traceback printing enabled.
342 only to call in exception handler. returns true if traceback
345 only to call in exception handler. returns true if traceback
343 printed.'''
346 printed.'''
344 if self.tracebackflag:
347 if self.tracebackflag:
345 if exc:
348 if exc:
346 traceback.print_exception(exc[0], exc[1], exc[2])
349 traceback.print_exception(exc[0], exc[1], exc[2])
347 else:
350 else:
348 traceback.print_exc()
351 traceback.print_exc()
349 return self.tracebackflag
352 return self.tracebackflag
350
353
351 def geteditor(self):
354 def geteditor(self):
352 '''return editor to use'''
355 '''return editor to use'''
353 return (os.environ.get("HGEDITOR") or
356 return (os.environ.get("HGEDITOR") or
354 self.config("ui", "editor") or
357 self.config("ui", "editor") or
355 os.environ.get("VISUAL") or
358 os.environ.get("VISUAL") or
356 os.environ.get("EDITOR", "vi"))
359 os.environ.get("EDITOR", "vi"))
357
360
358 def progress(self, topic, pos, item="", unit="", total=None):
361 def progress(self, topic, pos, item="", unit="", total=None):
359 '''show a progress message
362 '''show a progress message
360
363
361 With stock hg, this is simply a debug message that is hidden
364 With stock hg, this is simply a debug message that is hidden
362 by default, but with extensions or GUI tools it may be
365 by default, but with extensions or GUI tools it may be
363 visible. 'topic' is the current operation, 'item' is a
366 visible. 'topic' is the current operation, 'item' is a
364 non-numeric marker of the current position (ie the currently
367 non-numeric marker of the current position (ie the currently
365 in-process file), 'pos' is the current numeric position (ie
368 in-process file), 'pos' is the current numeric position (ie
366 revision, bytes, etc.), unit is a corresponding unit label,
369 revision, bytes, etc.), unit is a corresponding unit label,
367 and total is the highest expected pos.
370 and total is the highest expected pos.
368
371
369 Multiple nested topics may be active at a time. All topics
372 Multiple nested topics may be active at a time. All topics
370 should be marked closed by setting pos to None at termination.
373 should be marked closed by setting pos to None at termination.
371 '''
374 '''
372
375
373 if pos == None or not self.debugflag:
376 if pos == None or not self.debugflag:
374 return
377 return
375
378
376 if unit:
379 if unit:
377 unit = ' ' + unit
380 unit = ' ' + unit
378 if item:
381 if item:
379 item = ' ' + item
382 item = ' ' + item
380
383
381 if total:
384 if total:
382 pct = 100.0 * pos / total
385 pct = 100.0 * pos / total
383 self.debug('%s:%s %s/%s%s (%4.2g%%)\n'
386 self.debug('%s:%s %s/%s%s (%4.2g%%)\n'
384 % (topic, item, pos, total, unit, pct))
387 % (topic, item, pos, total, unit, pct))
385 else:
388 else:
386 self.debug('%s:%s %s%s\n' % (topic, item, pos, unit))
389 self.debug('%s:%s %s%s\n' % (topic, item, pos, unit))
General Comments 0
You need to be logged in to leave comments. Login now