Merge lp:~allenap/convoy/python3-clean-up into lp:convoy
- python3-clean-up
- Merge into trunk
Proposed by
Gavin Panella
Status: | Merged |
---|---|
Merged at revision: | 38 |
Proposed branch: | lp:~allenap/convoy/python3-clean-up |
Merge into: | lp:convoy |
Prerequisite: | lp:~allenap/convoy/python3 |
Diff against target: |
347 lines (+89/-65) 5 files modified
convoy/combo.py (+11/-5) convoy/meta.py (+15/-16) convoy/tests/test_combo.py (+54/-39) convoy/tests/test_meta.py (+8/-4) setup.py (+1/-1) |
To merge this branch: | bzr merge lp:~allenap/convoy/python3-clean-up |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Fabrice Matrat (community) | Approve | ||
Review via email: mp+277292@code.launchpad.net |
Commit message
Tidy up after the Python 3 porting effort.
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'convoy/combo.py' | |||
2 | --- convoy/combo.py 2015-11-11 19:53:19 +0000 | |||
3 | +++ convoy/combo.py 2015-11-11 19:53:19 +0000 | |||
4 | @@ -15,13 +15,17 @@ | |||
5 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
6 | 16 | 16 | ||
7 | 17 | 17 | ||
8 | 18 | import cgi | ||
9 | 19 | import logging | 18 | import logging |
10 | 20 | import os.path | 19 | import os.path |
11 | 21 | import re | 20 | import re |
12 | 22 | import sys | 21 | import sys |
13 | 23 | 22 | ||
14 | 24 | try: | 23 | try: |
15 | 24 | from urllib.parse import parse_qsl | ||
16 | 25 | except ImportError: | ||
17 | 26 | from cgi import parse_qsl | ||
18 | 27 | |||
19 | 28 | try: | ||
20 | 25 | import urllib.parse as urlparse | 29 | import urllib.parse as urlparse |
21 | 26 | except ImportError: | 30 | except ImportError: |
22 | 27 | import urlparse | 31 | import urlparse |
23 | @@ -56,7 +60,7 @@ | |||
24 | 56 | 60 | ||
25 | 57 | Returns the list of arguments in the original order. | 61 | Returns the list of arguments in the original order. |
26 | 58 | """ | 62 | """ |
28 | 59 | params = cgi.parse_qsl(query, keep_blank_values=True) | 63 | params = parse_qsl(query, keep_blank_values=True) |
29 | 60 | return tuple([param for param, value in params]) | 64 | return tuple([param for param, value in params]) |
30 | 61 | 65 | ||
31 | 62 | 66 | ||
32 | @@ -124,12 +128,13 @@ | |||
33 | 124 | parts = relative_parts + url.split(b"/") | 128 | parts = relative_parts + url.split(b"/") |
34 | 125 | result = [] | 129 | result = [] |
35 | 126 | for part in parts: | 130 | for part in parts: |
37 | 127 | if part == b".." and result and result[-1] != b"..": | 131 | if part == b".." and result[-1:] != [b".."]: |
38 | 128 | result.pop(-1) | 132 | result.pop(-1) |
39 | 129 | continue | 133 | continue |
40 | 130 | result.append(part) | 134 | result.append(part) |
41 | 131 | return b"url(x)".replace(b"x", b"/".join( | 135 | return b"url(x)".replace(b"x", b"/".join( |
43 | 132 | part for part in [resource_prefix] + result if part)) | 136 | part for part in [resource_prefix] + result |
44 | 137 | if part)) | ||
45 | 133 | 138 | ||
46 | 134 | file_content = URL_RE.sub(fix_relative_url, file_content) | 139 | file_content = URL_RE.sub(fix_relative_url, file_content) |
47 | 135 | yield file_content | 140 | yield file_content |
48 | @@ -143,7 +148,8 @@ | |||
49 | 143 | yield chunk | 148 | yield chunk |
50 | 144 | 149 | ||
51 | 145 | 150 | ||
53 | 146 | def combo_app(root, resource_prefix="", rewrite_urls=True, additional_headers=None): | 151 | def combo_app( |
54 | 152 | root, resource_prefix="", rewrite_urls=True, additional_headers=None): | ||
55 | 147 | """A simple YUI Combo Service WSGI app. | 153 | """A simple YUI Combo Service WSGI app. |
56 | 148 | 154 | ||
57 | 149 | Serves any files under C{root}, setting an appropriate | 155 | Serves any files under C{root}, setting an appropriate |
58 | 150 | 156 | ||
59 | === modified file 'convoy/meta.py' | |||
60 | --- convoy/meta.py 2015-11-11 19:53:19 +0000 | |||
61 | +++ convoy/meta.py 2015-11-11 19:53:19 +0000 | |||
62 | @@ -170,11 +170,10 @@ | |||
63 | 170 | return match.group(1) + literals_map[literal] + match.group(3) | 170 | return match.group(1) + literals_map[literal] + match.group(3) |
64 | 171 | return match.group(0) | 171 | return match.group(0) |
65 | 172 | 172 | ||
66 | 173 | |||
67 | 174 | linebreak = ",\n " | 173 | linebreak = ",\n " |
68 | 175 | variables_decl = "var SKIN_SAM_PREFIX = 'skin-sam-'" + linebreak | 174 | variables_decl = "var SKIN_SAM_PREFIX = 'skin-sam-'" + linebreak |
69 | 176 | if self.prefix: | 175 | if self.prefix: |
71 | 177 | variables_decl += "PREFIX = '%s'%s" % (self.prefix, linebreak) | 176 | variables_decl += "PREFIX = '%s'%s" % (self.prefix, linebreak) |
72 | 178 | extra_variables = [] | 177 | extra_variables = [] |
73 | 179 | for literal, variable in sorted(literals_map.items()): | 178 | for literal, variable in sorted(literals_map.items()): |
74 | 180 | extra_variable = "%s = %s" % ( | 179 | extra_variable = "%s = %s" % ( |
75 | @@ -220,8 +219,8 @@ | |||
76 | 220 | # It's easy to think that doing 'CORE_CSS + %(values)s' | 219 | # It's easy to think that doing 'CORE_CSS + %(values)s' |
77 | 221 | # instead of using concat would work, but it doesn't; | 220 | # instead of using concat would work, but it doesn't; |
78 | 222 | # you'll end up with a string instead of a list. | 221 | # you'll end up with a string instead of a list. |
81 | 223 | module_decl.append("after_list = CORE_CSS"); | 222 | module_decl.append("after_list = CORE_CSS") |
82 | 224 | module_decl.append("after_list.concat(%s)" % value); | 223 | module_decl.append("after_list.concat(%s)" % value) |
83 | 225 | value = "after_list" | 224 | value = "after_list" |
84 | 226 | if key == "path": | 225 | if key == "path": |
85 | 227 | value = value.replace( | 226 | value = value.replace( |
86 | @@ -369,15 +368,15 @@ | |||
87 | 369 | 368 | ||
88 | 370 | 369 | ||
89 | 371 | def main(): | 370 | def main(): |
102 | 372 | options, args = get_options() | 371 | options, args = get_options() |
103 | 373 | if options.src_dir is None: | 372 | if options.src_dir is None: |
104 | 374 | options.src_dir = os.getcwd() | 373 | options.src_dir = os.getcwd() |
105 | 375 | Builder( | 374 | Builder( |
106 | 376 | name=options.name, | 375 | name=options.name, |
107 | 377 | src_dir=os.path.abspath(options.src_dir), | 376 | src_dir=os.path.abspath(options.src_dir), |
108 | 378 | output=options.output, | 377 | output=options.output, |
109 | 379 | prefix=options.prefix, | 378 | prefix=options.prefix, |
110 | 380 | exclude_regex=options.exclude_regex, | 379 | exclude_regex=options.exclude_regex, |
111 | 381 | ext=options.ext, | 380 | ext=options.ext, |
112 | 382 | include_skin=not options.no_skin, | 381 | include_skin=not options.no_skin, |
113 | 383 | ).do_build() | 382 | ).do_build() |
114 | 384 | 383 | ||
115 | === modified file 'convoy/tests/test_combo.py' | |||
116 | --- convoy/tests/test_combo.py 2015-11-11 19:53:19 +0000 | |||
117 | +++ convoy/tests/test_combo.py 2015-11-11 19:53:19 +0000 | |||
118 | @@ -88,10 +88,11 @@ | |||
119 | 88 | "/* event-custom/event-custom-min.js */", | 88 | "/* event-custom/event-custom-min.js */", |
120 | 89 | "** event-custom-min **")) | 89 | "** event-custom-min **")) |
121 | 90 | self.assertEqual( | 90 | self.assertEqual( |
126 | 91 | b"".join(combine_files(["yui/yui-min.js", | 91 | b"".join(combine_files( |
127 | 92 | "oop/oop-min.js", | 92 | ["yui/yui-min.js", |
128 | 93 | "event-custom/event-custom-min.js"], | 93 | "oop/oop-min.js", |
129 | 94 | root=test_dir)).strip(), | 94 | "event-custom/event-custom-min.js"], |
130 | 95 | root=test_dir)).strip(), | ||
131 | 95 | expected.encode("ascii")) | 96 | expected.encode("ascii")) |
132 | 96 | 97 | ||
133 | 97 | def test_combine_files_yields_only_byte_strings(self): | 98 | def test_combine_files_yields_only_byte_strings(self): |
134 | @@ -144,9 +145,10 @@ | |||
135 | 144 | } | 145 | } |
136 | 145 | """ | 146 | """ |
137 | 146 | self.assertTextEquals( | 147 | self.assertTextEquals( |
141 | 147 | b"".join(combine_files(["widget/assets/skins/sam/widget.css", | 148 | b"".join(combine_files( |
142 | 148 | "editor/assets/skins/sam/editor.css"], | 149 | ["widget/assets/skins/sam/widget.css", |
143 | 149 | root=test_dir)).strip(), | 150 | "editor/assets/skins/sam/editor.css"], |
144 | 151 | root=test_dir)).strip(), | ||
145 | 150 | expected) | 152 | expected) |
146 | 151 | 153 | ||
147 | 152 | def test_combine_css_leaves_absolute_urls_untouched(self): | 154 | def test_combine_css_leaves_absolute_urls_untouched(self): |
148 | @@ -189,9 +191,10 @@ | |||
149 | 189 | } | 191 | } |
150 | 190 | """ | 192 | """ |
151 | 191 | self.assertTextEquals( | 193 | self.assertTextEquals( |
155 | 192 | b"".join(combine_files(["widget/assets/skins/sam/widget.css", | 194 | b"".join(combine_files( |
156 | 193 | "editor/assets/skins/sam/editor.css"], | 195 | ["widget/assets/skins/sam/widget.css", |
157 | 194 | root=test_dir)).strip(), | 196 | "editor/assets/skins/sam/editor.css"], |
158 | 197 | root=test_dir)).strip(), | ||
159 | 195 | expected) | 198 | expected) |
160 | 196 | 199 | ||
161 | 197 | def test_combine_css_leaves_data_uris_untouched(self): | 200 | def test_combine_css_leaves_data_uris_untouched(self): |
162 | @@ -234,9 +237,10 @@ | |||
163 | 234 | } | 237 | } |
164 | 235 | """ | 238 | """ |
165 | 236 | self.assertTextEquals( | 239 | self.assertTextEquals( |
169 | 237 | b"".join(combine_files(["widget/assets/skins/sam/widget.css", | 240 | b"".join(combine_files( |
170 | 238 | "editor/assets/skins/sam/editor.css"], | 241 | ["widget/assets/skins/sam/widget.css", |
171 | 239 | root=test_dir)).strip(), | 242 | "editor/assets/skins/sam/editor.css"], |
172 | 243 | root=test_dir)).strip(), | ||
173 | 240 | expected) | 244 | expected) |
174 | 241 | 245 | ||
175 | 242 | def test_no_parent_hack(self): | 246 | def test_no_parent_hack(self): |
176 | @@ -336,10 +340,10 @@ | |||
177 | 336 | } | 340 | } |
178 | 337 | """ | 341 | """ |
179 | 338 | self.assertTextEquals( | 342 | self.assertTextEquals( |
184 | 339 | b"".join(combine_files(["widget/assets/skins/sam/widget.css", | 343 | b"".join(combine_files( |
185 | 340 | "editor/assets/skins/sam/editor.css"], | 344 | ["widget/assets/skins/sam/widget.css", |
186 | 341 | root=test_dir, | 345 | "editor/assets/skins/sam/editor.css"], |
187 | 342 | resource_prefix="/static/")).strip(), | 346 | root=test_dir, resource_prefix="/static/")).strip(), |
188 | 343 | expected) | 347 | expected) |
189 | 344 | 348 | ||
190 | 345 | def test_combine_css_adds_custom_prefix_minified(self): | 349 | def test_combine_css_adds_custom_prefix_minified(self): |
191 | @@ -367,20 +371,18 @@ | |||
192 | 367 | def test_rewrite_url_normalizes_parent_references(self): | 371 | def test_rewrite_url_normalizes_parent_references(self): |
193 | 368 | """URL references in CSS files get normalized for parent dirs.""" | 372 | """URL references in CSS files get normalized for parent dirs.""" |
194 | 369 | test_dir = self.makeDir() | 373 | test_dir = self.makeDir() |
201 | 370 | files = [ | 374 | self.makeSampleFile( |
202 | 371 | self.makeSampleFile( | 375 | test_dir, os.path.join("yui", "base", "base.css"), |
203 | 372 | test_dir, | 376 | ".foo{background-image:url(../../img.png)}"), |
198 | 373 | os.path.join("yui", "base", "base.css"), | ||
199 | 374 | ".foo{background-image:url(../../img.png)}"), | ||
200 | 375 | ] | ||
204 | 376 | 377 | ||
205 | 377 | expected = """ | 378 | expected = """ |
206 | 378 | /* yui/base/base.css */ | 379 | /* yui/base/base.css */ |
207 | 379 | .foo{background-image:url(img.png)} | 380 | .foo{background-image:url(img.png)} |
208 | 380 | """ | 381 | """ |
209 | 381 | self.assertTextEquals( | 382 | self.assertTextEquals( |
212 | 382 | b"".join(combine_files(["yui/base/base.css"], | 383 | b"".join(combine_files( |
213 | 383 | root=test_dir)).strip(), | 384 | ["yui/base/base.css"], |
214 | 385 | root=test_dir)).strip(), | ||
215 | 384 | expected) | 386 | expected) |
216 | 385 | 387 | ||
217 | 386 | 388 | ||
218 | @@ -437,8 +439,10 @@ | |||
219 | 437 | ["yui/yui-min.js", | 439 | ["yui/yui-min.js", |
220 | 438 | "oop/oop-min.js", | 440 | "oop/oop-min.js", |
221 | 439 | "event-custom/event-custom-min.js"]), status=200) | 441 | "event-custom/event-custom-min.js"]), status=200) |
224 | 440 | self.assertEqual(res.headers, [("Content-Type", "text/javascript"), | 442 | self.assertEqual(res.headers, [ |
225 | 441 | ("X-Content-Type-Options", "nosniff")]) | 443 | ("Content-Type", "text/javascript"), |
226 | 444 | ("X-Content-Type-Options", "nosniff"), | ||
227 | 445 | ]) | ||
228 | 442 | self.assertEqual(res.body.strip(), expected.encode("ascii")) | 446 | self.assertEqual(res.body.strip(), expected.encode("ascii")) |
229 | 443 | 447 | ||
230 | 444 | def test_combo_app_sets_content_type_for_css(self): | 448 | def test_combo_app_sets_content_type_for_css(self): |
231 | @@ -452,8 +456,10 @@ | |||
232 | 452 | 456 | ||
233 | 453 | res = self.app.get("/?" + "&".join( | 457 | res = self.app.get("/?" + "&".join( |
234 | 454 | ["widget/skin/sam/widget.css"]), status=200) | 458 | ["widget/skin/sam/widget.css"]), status=200) |
237 | 455 | self.assertEqual(res.headers, [("Content-Type", "text/css"), | 459 | self.assertEqual(res.headers, [ |
238 | 456 | ("X-Content-Type-Options", "nosniff")]) | 460 | ("Content-Type", "text/css"), |
239 | 461 | ("X-Content-Type-Options", "nosniff"), | ||
240 | 462 | ]) | ||
241 | 457 | self.assertEqual(res.body.strip(), expected.encode("ascii")) | 463 | self.assertEqual(res.body.strip(), expected.encode("ascii")) |
242 | 458 | 464 | ||
243 | 459 | def test_no_filename_gives_404(self): | 465 | def test_no_filename_gives_404(self): |
244 | @@ -467,12 +473,16 @@ | |||
245 | 467 | Content-Type and X-Content-Type-Options headers set for | 473 | Content-Type and X-Content-Type-Options headers set for |
246 | 468 | non-existent files. | 474 | non-existent files. |
247 | 469 | """ | 475 | """ |
254 | 470 | res = self.app.get("/?" + "&".join( | 476 | res = self.app.get( |
255 | 471 | ["foo/bar/baz", | 477 | "/?" + "&".join([ |
256 | 472 | "<html><script>alert(document.domain)</script></html>"]), | 478 | "foo/bar/baz", |
257 | 473 | status=400) | 479 | "<html><script>alert(document.domain)</script></html>", |
258 | 474 | self.assertEqual(res.headers, [("Content-Type", "text/plain"), | 480 | ]), |
259 | 475 | ("X-Content-Type-Options", "nosniff")]) | 481 | status=400) |
260 | 482 | self.assertEqual(res.headers, [ | ||
261 | 483 | ("Content-Type", "text/plain"), | ||
262 | 484 | ("X-Content-Type-Options", "nosniff"), | ||
263 | 485 | ]) | ||
264 | 476 | 486 | ||
265 | 477 | def test_js_comment_escape_hack(self): | 487 | def test_js_comment_escape_hack(self): |
266 | 478 | """Attacks to break out of the JS comment will get a 400 error.""" | 488 | """Attacks to break out of the JS comment will get a 400 error.""" |
267 | @@ -528,9 +538,14 @@ | |||
268 | 528 | app.get("/../etc?yui-min&passwd", status=400) | 538 | app.get("/../etc?yui-min&passwd", status=400) |
269 | 529 | 539 | ||
270 | 530 | def test_cache_headers_set(self): | 540 | def test_cache_headers_set(self): |
272 | 531 | app = TestApp(combo_app(self.root, additional_headers=[('Cache-Control', 'max-age=3600, public')])) | 541 | app = TestApp(combo_app( |
273 | 542 | self.root, additional_headers=[ | ||
274 | 543 | ('Cache-Control', 'max-age=3600, public'), | ||
275 | 544 | ])) | ||
276 | 532 | res = app.get("/?" + "&".join( | 545 | res = app.get("/?" + "&".join( |
277 | 533 | ["widget/skin/sam/widget.css"]), status=400) | 546 | ["widget/skin/sam/widget.css"]), status=400) |
281 | 534 | self.assertEqual(res.headers, [("Content-Type", "text/css"), | 547 | self.assertEqual(res.headers, [ |
282 | 535 | ("X-Content-Type-Options", "nosniff"), | 548 | ("Content-Type", "text/css"), |
283 | 536 | ('Cache-Control', 'max-age=3600, public')]) | 549 | ("X-Content-Type-Options", "nosniff"), |
284 | 550 | ('Cache-Control', 'max-age=3600, public'), | ||
285 | 551 | ]) | ||
286 | 537 | 552 | ||
287 | === modified file 'convoy/tests/test_meta.py' | |||
288 | --- convoy/tests/test_meta.py 2015-11-11 19:53:19 +0000 | |||
289 | +++ convoy/tests/test_meta.py 2015-11-11 19:53:19 +0000 | |||
290 | @@ -126,7 +126,8 @@ | |||
291 | 126 | metadata = extract_metadata("""\ | 126 | metadata = extract_metadata("""\ |
292 | 127 | YUI.add('lazr.anim', function(Y){ | 127 | YUI.add('lazr.anim', function(Y){ |
293 | 128 | Y.log('Hello World'); | 128 | Y.log('Hello World'); |
295 | 129 | }, '0.1', {"use": ["dom"], "requires": [ "node" ,"anim" , "event" ]}); | 129 | }, '0.1', {"use": ["dom"], "requires": [ |
296 | 130 | "node" ,"anim" , "event" ]}); | ||
297 | 130 | """) | 131 | """) |
298 | 131 | 132 | ||
299 | 132 | self.assertEqual(len(metadata), 1) | 133 | self.assertEqual(len(metadata), 1) |
300 | @@ -141,7 +142,8 @@ | |||
301 | 141 | metadata = extract_metadata("""\ | 142 | metadata = extract_metadata("""\ |
302 | 142 | YUI.add('lazr.anim', function(Y){ | 143 | YUI.add('lazr.anim', function(Y){ |
303 | 143 | Y.log('Hello World'); | 144 | Y.log('Hello World'); |
305 | 144 | }, '0.1', {"requires": [ "node" ,"anim" , "event" ], "use": ["dom"]}); | 145 | }, '0.1', {"requires": [ "node" ,"anim" , "event" ], |
306 | 146 | "use": ["dom"]}); | ||
307 | 145 | """) | 147 | """) |
308 | 146 | 148 | ||
309 | 147 | self.assertEqual(len(metadata), 1) | 149 | self.assertEqual(len(metadata), 1) |
310 | @@ -177,7 +179,9 @@ | |||
311 | 177 | """ | 179 | """ |
312 | 178 | metadata = extract_metadata(""" | 180 | metadata = extract_metadata(""" |
313 | 179 | YUI.add('test', function(Y) { | 181 | YUI.add('test', function(Y) { |
315 | 180 | this._dds[h[i]] = new YAHOO.util.DragDrop(this._handles[h[i]], this.get('id') + '-handle-' + h, { useShim: this.get('useShim') }); | 182 | this._dds[h[i]] = new YAHOO.util.DragDrop( |
316 | 183 | this._handles[h[i]], this.get('id') + '-handle-' + h, { | ||
317 | 184 | useShim: this.get('useShim') }); | ||
318 | 181 | }, '1.0', {requires: ['dom']}); | 185 | }, '1.0', {requires: ['dom']}); |
319 | 182 | """) | 186 | """) |
320 | 183 | 187 | ||
321 | @@ -205,7 +209,6 @@ | |||
322 | 205 | self.assertEqual(metadata[0]["requires"], ["node", "anim", "event"]) | 209 | self.assertEqual(metadata[0]["requires"], ["node", "anim", "event"]) |
323 | 206 | self.assertEqual(metadata[0]["use"], ["dom"]) | 210 | self.assertEqual(metadata[0]["use"], ["dom"]) |
324 | 207 | 211 | ||
325 | 208 | |||
326 | 209 | def test_extract_has_no_quotes(self): | 212 | def test_extract_has_no_quotes(self): |
327 | 210 | """ | 213 | """ |
328 | 211 | If the javascript is using object literals, we should adjust for | 214 | If the javascript is using object literals, we should adjust for |
329 | @@ -312,6 +315,7 @@ | |||
330 | 312 | self.assertEqual(metadata[0]["supersedes"], ["old-anim"]) | 315 | self.assertEqual(metadata[0]["supersedes"], ["old-anim"]) |
331 | 313 | self.assertEqual(metadata[0]["after"], ["lazr.base"]) | 316 | self.assertEqual(metadata[0]["after"], ["lazr.base"]) |
332 | 314 | 317 | ||
333 | 318 | |||
334 | 315 | class GenerateMetadataTest(ConvoyTestCase): | 319 | class GenerateMetadataTest(ConvoyTestCase): |
335 | 316 | 320 | ||
336 | 317 | def readFile(self, path): | 321 | def readFile(self, path): |
337 | 318 | 322 | ||
338 | === modified file 'setup.py' | |||
339 | --- setup.py 2015-11-11 19:53:19 +0000 | |||
340 | +++ setup.py 2015-11-11 19:53:19 +0000 | |||
341 | @@ -1,5 +1,5 @@ | |||
342 | 1 | # Convoy is a WSGI app for loading multiple files in the same request. | 1 | # Convoy is a WSGI app for loading multiple files in the same request. |
344 | 2 | # Copyright (C) 2010-2015 Canonical, Ltd. | 2 | # Copyright (C) 2011-2015 Canonical, Ltd. |
345 | 3 | # | 3 | # |
346 | 4 | # This program is free software: you can redistribute it and/or modify | 4 | # This program is free software: you can redistribute it and/or modify |
347 | 5 | # it under the terms of the GNU Affero General Public License as | 5 | # it under the terms of the GNU Affero General Public License as |
LGTM