├── .gitignore
├── .python-version
├── .travis.yml
├── LICENSE
├── README.rst
├── concierge
├── __init__.py
├── core
│ ├── __init__.py
│ ├── exceptions.py
│ ├── lexer.py
│ ├── parser.py
│ └── processor.py
├── endpoints
│ ├── __init__.py
│ ├── check.py
│ ├── cli.py
│ ├── common.py
│ ├── daemon.py
│ └── templates.py
├── notifications.py
├── templater.py
└── utils.py
├── setup.cfg
├── setup.py
├── test-requirements.txt
├── tests
├── conftest.py
├── test_core_lexer.py
├── test_endpoints_app.py
├── test_endpoints_check.py
├── test_endpoints_cli.py
├── test_endpoints_daemon.py
├── test_endpoints_templates.py
├── test_parser.py
├── test_parser_host.py
├── test_processor.py
├── test_templater.py
└── test_utils.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | #Ipython Notebook
62 | .ipynb_checkpoints
63 |
64 | tags
65 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | system
2 | 3.3.6
3 | 3.4.5
4 | 3.5.2
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | sudo: false
3 | cache: pip
4 | python: 3.5
5 |
6 | env:
7 | - TOXENV=static
8 | - TOXENV=metrics
9 | - TOXENV=py33
10 | - TOXENV=py34
11 | - TOXENV=py35
12 |
13 | before_install:
14 | - pip install codecov
15 |
16 | install: pip install tox
17 | script: tox
18 |
19 | after_success:
20 | - codecov
21 |
22 | notifications:
23 | email:
24 | - nineseconds@yandex.ru
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Sergey Arkhipov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | concierge
2 | *********
3 |
4 | |PyPI| |Build Status| |Code Coverage|
5 |
6 | ``concierge`` is a small utility/daemon which is intended to help humans
7 | to maintain their SSH configs.
8 |
9 | .. contents::
10 | :depth: 2
11 | :backlinks: none
12 |
13 |
14 | Introduction
15 | ============
16 |
17 | There is not problems with SSH config format: it works for decades and
18 | is going to work for my children I guess. This utility will die, but one
19 | will update his ``~/.ssh/config`` to access some network server.
20 |
21 | The problem with SSH that it really hard to scale. I am not quite sure
22 | about other people jobs, but on my current and previous jobs I was
23 | used to maintain quite large sets of records in SSH configs. Usual
24 | deployment of some modern app consist several machines (let's say ``X``)
25 | and during development we are using several stage environments (let's
26 | say ``Y``). So, frankly, you need to have ``X * Y`` records in your
27 | ``~/.ssh/config``. Only for work.
28 |
29 | Sometimes you need to jugle with jump hosts. Sometimes your stage is
30 | moving to another set of IPs. Sometimes life happens and it is quite
31 | irritating to manage these configuration manually.
32 |
33 | I did a lot of CSS stylesheets and SSH config management is pretty close
34 | to that. I want to have SASS_ for SSH config. The main goal of this
35 | tool is to provide user with some templating and clutter-free config
36 | management in SASS way.
37 |
38 |
39 | Demo
40 | ====
41 |
42 | .. image:: https://asciinema.org/a/dqxhschtqyx7lxfda25irbgh5.png
43 | :alt: Asciinema screencast
44 | :width: 700
45 | :target: https://asciinema.org/a/dqxhschtqyx7lxfda25irbgh5
46 |
47 |
48 | Installation
49 | ============
50 |
51 | Installation is quite trivial:
52 |
53 | .. code-block:: shell
54 |
55 | $ pip install concierge
56 |
57 | or if you want to install it manually, do following:
58 |
59 | .. code-block:: shell
60 |
61 | $ git clone https://github.com/9seconds/concierge.git
62 | $ cd concierge
63 | $ python setup.py install
64 |
65 | By default, no template support is going to be installed. If you want to
66 | use Mako_ or Jinja2_, please refer to `Templaters`_ section.
67 |
68 | Also, it is possible to install support of `libnotify
69 | `_. Please install tool like
70 | this:
71 |
72 | .. code-block:: shell
73 |
74 | $ pip install concierge[libnotify]
75 |
76 | In that case, you will have a desktop notifications about any problem
77 | with parsing of your ``~/.conciergerc``. Yep, these Ubuntu popups on the
78 | right top of the screen.
79 |
80 | If you have a problems with Pip installation (with modifiers, for
81 | example), please update your pip and setuptools first.
82 |
83 | .. code-block:: shell
84 |
85 | $ pip install --upgrade pip setuptools
86 |
87 | Eventually there will be no such problem anywhere.
88 |
89 | Please be noticed, that ``concierge`` is **Python 3** only tool. It
90 | should work on ``cPython >= 3.3`` without any problems. Come on, Python
91 | 3.4 is bundled even with CentOS 7!
92 |
93 | After installation, 2 utilities will be available:
94 |
95 | * ``concierge-check``
96 | * ``concierge``
97 |
98 |
99 | Templaters
100 | ----------
101 |
102 | ``concierge`` comes with support of additional templaters, you may plug
103 | them in installing the packages from PyPI. At the time of writing,
104 | support of following templaters was done:
105 |
106 | * `concierge-mako `_ -
107 | support of Mako_ templates
108 | * `concierge-jinja `_ -
109 | support of Jinja2_ templates
110 |
111 | To install them just do
112 |
113 | .. code-block:: shell
114 |
115 | $ pip install concierge-mako
116 |
117 | And ``concierge`` will automatically recognizes support of Mako and now
118 | one may use ``concierge -u mako`` for her ``~/.conciergerc``.
119 |
120 |
121 | concierge-check
122 | ---------------
123 |
124 | ``concierge-check`` is a tool to verify syntax of your
125 | ``~/.conciergerc`` file. Please check `Syntax description`_ to get on
126 | speed.
127 |
128 | Also, it supports a number of options but they are pretty trivial.
129 |
130 | Please remember, that both ``concierge-check`` and ``concierge``
131 | use syslog for logging data in process. Options like ``--debug`` or
132 | ``--verbose`` will affect only stderr logging, syslog will have only
133 | errors.
134 |
135 |
136 | concierge
137 | ---------
138 |
139 | ``concierge`` is intended to work in daemon mode. It converts between
140 | your ``~/.conciergerc`` and destination ``~/.ssh/config`` (so
141 | `Installation`_ magic work in that way).
142 |
143 | I use systemd so ``concierge`` is bundled to support it. To get an
144 | instructions of how to use the tool with systemd, please run following:
145 |
146 | .. code-block:: shell
147 |
148 | $ concierge --systemd
149 |
150 | It will printout an instructions. If you do not care, please run following:
151 |
152 | .. code-block:: shell
153 |
154 | $ eval "$(concierge --systemd --curlsh)"
155 |
156 | It will install systemd user unit and run concierge daemon automatically.
157 |
158 | ``concierge`` supports the same options and behavior as
159 | `concierge-check`_ so please track your syslog for problems.
160 |
161 |
162 | Syntax description
163 | ==================
164 |
165 | Well, there is no big difference between plain old ``ssh_config(5)`` and
166 | ``concierge`` style. Base is the same so please check the table with
167 | examples to understand what is going to be converted and how.
168 |
169 | Syntax came from the way I structure my SSH configs for a long time .
170 | Basically I am trying to keep it in the way it looks like hierarchical .
171 |
172 | Let's grow the syntax. Consider following config
173 |
174 | ::
175 |
176 | Host m
177 | HostName 127.0.0.1
178 |
179 | Host me0
180 | HostName 10.10.0.0
181 |
182 | Host me1
183 | HostName 10.10.0.1
184 |
185 | Host m me0 me1
186 | Compression no
187 | ProxyCommand ssh -W %h:%p env1
188 | User nineseconds
189 |
190 | Host *
191 | Compression yes
192 | CompressionLevel 9
193 |
194 |
195 | So far so good. Now let's... indent!
196 |
197 | ::
198 |
199 | Host m
200 | HostName 127.0.0.1
201 |
202 | Host me0
203 | HostName 10.10.0.0
204 | ProxyCommand ssh -W %h:%p env1
205 |
206 | Host me1
207 | HostName 10.10.0.1
208 | ProxyCommand ssh -W %h:%p env1
209 |
210 | Host m me0 me1
211 | Compression no
212 | User nineseconds
213 |
214 | Host *
215 | Compression yes
216 | CompressionLevel 9
217 |
218 |
219 | It is still valid SSH config. And valid ``concierge`` config. Probably
220 | you already do similar indentation to visually differ different server
221 | groups. Let's check what do we have here: we have prefixes, right. And
222 | most of options are quite common to the server groups (environments).
223 |
224 | Now let's eliminate ``Host m me0 me1`` block. This would be invalid SSH
225 | config but valid ``conciergerc`` config. Also I am going to get rid of
226 | useless prefixes and use hierarchy to determine full name (``fullname =
227 | name + parent_name``).
228 |
229 | Please be noticed that all operations maintain effectively the same
230 | ``conciergerc`` config.
231 |
232 | ::
233 |
234 | Host m
235 | Compression no
236 | HostName 127.0.0.1
237 | User nineseconds
238 |
239 | Host e0
240 | HostName 10.10.0.0
241 | ProxyCommand ssh -W %h:%p env1
242 |
243 | Host e1
244 | HostName 10.10.0.1
245 | ProxyCommand ssh -W %h:%p env1
246 |
247 | Host *
248 | Compression yes
249 | CompressionLevel 9
250 |
251 |
252 | Okay. Do we need rudiment ``Host *`` section? No, let's move everything
253 | on the top. Idea is the same, empty prefix is ``*``.
254 |
255 | ::
256 |
257 | Compression yes
258 | CompressionLevel 9
259 |
260 | Host m
261 | Compression no
262 | HostName 127.0.0.1
263 | User nineseconds
264 |
265 | Host e0
266 | HostName 10.10.0.0
267 | ProxyCommand ssh -W %h:%p env1
268 |
269 | Host e1
270 | HostName 10.10.0.1
271 | ProxyCommand ssh -W %h:%p env1
272 |
273 |
274 | By the way, you may see, that indentation defines parent is the same
275 | way as Python syntax is organized. So following config is absolutely
276 | equivalent.
277 |
278 | ::
279 |
280 | Compression yes
281 |
282 | Host m
283 | Compression no
284 | HostName 127.0.0.1
285 | User nineseconds
286 |
287 | Host e0
288 | HostName 10.10.0.0
289 | ProxyCommand ssh -W %h:%p env1
290 |
291 | Host e1
292 | HostName 10.10.0.1
293 | ProxyCommand ssh -W %h:%p env1
294 |
295 | CompressionLevel 9
296 |
297 | You can also work the other way around with a star.
298 | In this example, I remove the first Host line from being generated and add that
299 | domain information to other host.
300 | Also, ProxyJump is available
301 |
302 | ::
303 |
304 | Compression yes
305 |
306 | -Host *.my.domain
307 | Compression no
308 | User tr4sk
309 | ProxyJump gateway
310 |
311 | Host server1
312 | User root
313 | Host server2
314 |
315 |
316 | This is a basic. But if you install ``concierge`` with support of Mako or
317 | Jinja2 templates, you may use them in your ``~/.conciergerc``.
318 |
319 | ::
320 |
321 | Compression yes
322 | CompressionLevel 9
323 |
324 | Host m
325 | Compression no
326 | HostName 127.0.0.1
327 | User nineseconds
328 |
329 | % for i in range(2):
330 | Host e${i}
331 | HostName 10.10.0.${i}
332 | ProxyCommand ssh -W %h:%p env1
333 | % endfor
334 |
335 | This is a Mako template I use. Please refer `Mako
336 | `__ and `Jinja2
337 | `__ documentation for details.
338 |
339 | By the way, if you want to hide some host you are using for grouping only,
340 | please prefix it with ``-`` (``-Host``).
341 |
342 |
343 | Examples
344 | --------
345 |
346 | Here are some examples. Please do not hesitate to check `Demo`_, pause it,
347 | look around.
348 |
349 | +----------------------------------------+--------------------------------------------+
350 | | Source, converted from (~/.concierge) | Destination, converted to (~/.ssh/config) |
351 | +========================================+============================================+
352 | | :: | :: |
353 | | | |
354 | | Host name | Host name |
355 | | HostName 127.0.0.1 | HostName 127.0.0.1 |
356 | | | |
357 | +----------------------------------------+--------------------------------------------+
358 | | :: | :: |
359 | | | |
360 | | Compression yes | Host name |
361 | | | HostName 127.0.0.1 |
362 | | Host name | |
363 | | HostName 127.0.0.1 | Host * |
364 | | | Compression yes |
365 | | | |
366 | +----------------------------------------+--------------------------------------------+
367 | | :: | :: |
368 | | | |
369 | | Compression yes | Host name |
370 | | | HostName 127.0.0.1 |
371 | | Host name | |
372 | | HostName 127.0.0.1 | Host * |
373 | | | Compression yes |
374 | | Host * | CompressionLevel 9 |
375 | | CompressionLevel 9 | |
376 | | | |
377 | +----------------------------------------+--------------------------------------------+
378 | | :: | :: |
379 | | | |
380 | | Compression yes | Host name |
381 | | | HostName 127.0.0.1 |
382 | | Host name | |
383 | | HostName 127.0.0.1 | Host nameq |
384 | | | HostName node-1 |
385 | | Host q | ProxyCommand ssh -W %h:%p env1 |
386 | | ViaJumpHost env1 | |
387 | | HostName node-1 | Host * |
388 | | | Compression yes |
389 | | | |
390 | +----------------------------------------+--------------------------------------------+
391 | | :: | :: |
392 | | | |
393 | | Compression yes | Host nameq |
394 | | | HostName node-1 |
395 | | -Host name | ProxyCommand ssh -W %h:%p env1 |
396 | | HostName 127.0.0.1 | |
397 | | | Host * |
398 | | Host q | Compression yes |
399 | | ViaJumpHost env1 | |
400 | | HostName node-1 | |
401 | | | |
402 | +----------------------------------------+--------------------------------------------+
403 | | :: | :: |
404 | | | |
405 | | Compression yes | Host blog |
406 | | | User sa |
407 | | Host m | |
408 | | User nineseconds | Host me0 |
409 | | | HostName 10.10.0.0 |
410 | | % for i in range(2): | Protocol 2 |
411 | | Host e${i} | ProxyCommand ssh -W %h:%p gw2 |
412 | | HostName 10.10.0.${i} | User nineseconds |
413 | | ViaJumpHost gw2 | |
414 | | % endfor | Host me1 |
415 | | | HostName 10.10.0.1 |
416 | | Protocol 2 | Protocol 2 |
417 | | | ProxyCommand ssh -W %h:%p gw2 |
418 | | Host blog | User nineseconds |
419 | | User sa | |
420 | | | Host * |
421 | | | Compression yes |
422 | | | |
423 | +----------------------------------------+--------------------------------------------+
424 | | :: | :: |
425 | | | |
426 | | Compression yes | Host blog |
427 | | | User sa |
428 | | -Host \*.my.domain | |
429 | | User nineseconds | Host first.my.domain |
430 | | | Protocol 2 |
431 | | Host first | User nineseconds |
432 | | Host second | Host second.my.domain |
433 | | HostName 10.10.10.1 | User nineseconds |
434 | | | Protocol 2 |
435 | | | HostName 10.10.10.1 |
436 | | Protocol 2 | |
437 | | | Host * |
438 | | Host blog | Compression yes |
439 | | User sa | |
440 | | | |
441 | +----------------------------------------+--------------------------------------------+
442 |
443 |
444 | .. _SASS: http://sass-lang.com
445 | .. _Mako: http://www.makotemplates.org
446 | .. _Jinja2: http://jinja.pocoo.org
447 |
448 | .. |PyPI| image:: https://img.shields.io/pypi/v/concierge.svg
449 | :target: https://pypi.python.org/pypi/concierge
450 |
451 | .. |Build Status| image:: https://travis-ci.org/9seconds/concierge.svg?branch=master
452 | :target: https://travis-ci.org/9seconds/concierge
453 |
454 | .. |Code Coverage| image:: https://codecov.io/github/9seconds/concierge/coverage.svg?branch=master
455 | :target: https://codecov.io/github/9seconds/concierge?branch=master
456 |
--------------------------------------------------------------------------------
/concierge/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import os.path
5 | import warnings
6 |
7 |
8 | HOME_DIR = os.path.expanduser("~")
9 | DEFAULT_RC = os.path.join(HOME_DIR, ".conciergerc")
10 | DEFAULT_SSHCONFIG = os.path.join(HOME_DIR, ".ssh", "config")
11 |
12 |
13 | warnings.simplefilter("always", DeprecationWarning)
14 |
--------------------------------------------------------------------------------
/concierge/core/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | INDENT_LENGTH = 4
5 |
--------------------------------------------------------------------------------
/concierge/core/exceptions.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import concierge.core
5 |
6 |
7 | class ConciergeError(Exception):
8 | pass
9 |
10 |
11 | class ReaderError(ConciergeError):
12 | pass
13 |
14 |
15 | class LexerError(ValueError, ReaderError):
16 | pass
17 |
18 |
19 | class LexerIncorrectOptionValue(LexerError):
20 |
21 | MESSAGE = "Cannot find correct option/value pair on line {0} '{1}'"
22 |
23 | def __init__(self, line, lineno):
24 | super().__init__(self.MESSAGE.format(lineno, line))
25 |
26 |
27 | class LexerIncorrectIndentationLength(LexerError):
28 |
29 | MESSAGE = ("Incorrect indentation on line {0} '{1}'"
30 | "({2} spaces, has to be divisible by {3})")
31 |
32 | def __init__(self, line, lineno, indentation_value):
33 | super().__init__(
34 | self.MESSAGE.format(
35 | lineno, line,
36 | indentation_value,
37 | concierge.core.INDENT_LENGTH))
38 |
39 |
40 | class LexerIncorrectFirstIndentationError(LexerError):
41 |
42 | MESSAGE = "Line {0} '{1}' has to have no indentation at all"
43 |
44 | def __init__(self, line, lineno):
45 | super().__init__(self.MESSAGE.format(lineno, line))
46 |
47 |
48 | class LexerIncorrectIndentationError(LexerError):
49 |
50 | MESSAGE = "Incorrect indentation on line {0} '{1}'"
51 |
52 | def __init__(self, line, lineno):
53 | super().__init__(self.MESSAGE.format(lineno, line))
54 |
55 |
56 | class ParserError(ValueError, ReaderError):
57 | pass
58 |
59 |
60 | class ParserUnknownOption(ParserError):
61 |
62 | MESSAGE = "Unknown option {0}"
63 |
64 | def __init__(self, option):
65 | super().__init__(self.MESSAGE.format(option))
66 |
--------------------------------------------------------------------------------
/concierge/core/lexer.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import collections
5 | import re
6 |
7 | import concierge.core
8 | import concierge.core.exceptions as exceptions
9 | import concierge.utils
10 |
11 |
12 | Token = collections.namedtuple(
13 | "Token",
14 | ["indent", "option", "values", "original", "lineno"])
15 |
16 | RE_QUOTED_SINGLE = r"'(?:[^'\\]|\\.)*'"
17 | RE_QUOTED_DOUBLE = r'"(?:[^"\\]|\\.)*"'
18 | RE_UNQUOTED = r"(?:[^'\"\\ \r\n\t]|\\.)+"
19 |
20 | RE_COMMENT = re.compile(r"#.*$")
21 | RE_QUOTED = re.compile(
22 | r"(?:{0}|{1}|{2})".format(RE_QUOTED_SINGLE, RE_QUOTED_DOUBLE, RE_UNQUOTED))
23 | RE_OPT_VALUE = re.compile(r"(-?\w+-?)\b\s*=?\s*([^= \r\n\t].*?)$")
24 | RE_INDENT = re.compile(r"^\s+")
25 |
26 | LOG = concierge.utils.logger(__name__)
27 |
28 |
29 | def lex(lines):
30 | tokens = []
31 |
32 | LOG.info("Start lexing of %d lines.", len(lines))
33 |
34 | for index, line in enumerate(lines, start=1):
35 | LOG.debug("Process line %d '%s'.", index, line)
36 | processed_line = process_line(line)
37 | if processed_line:
38 | token = make_token(processed_line, line, index)
39 | LOG.debug("Processed line %d to token %s", index, token)
40 | tokens.append(token)
41 | else:
42 | LOG.debug("Processed line %d is empty, skip.", index)
43 |
44 | tokens = verify_tokens(tokens)
45 |
46 | LOG.info("Lexing is finished. Got %d tokens.", len(tokens))
47 |
48 | return tokens
49 |
50 |
51 | def process_line(line):
52 | if not line:
53 | return ""
54 |
55 | line = reindent_line(line)
56 | line = clean_line(line)
57 |
58 | return line
59 |
60 |
61 | def make_token(line, original_line, index):
62 | indentation, content = split_indent(line)
63 |
64 | matcher = RE_OPT_VALUE.match(content)
65 | if not matcher:
66 | raise exceptions.LexerIncorrectOptionValue(original_line, index)
67 |
68 | option, values = matcher.groups()
69 | values = RE_QUOTED.findall(values)
70 |
71 | indentation = len(indentation)
72 | if indentation % concierge.core.INDENT_LENGTH:
73 | raise exceptions.LexerIncorrectIndentationLength(
74 | original_line, index, indentation)
75 |
76 | return Token(indentation // 4, option, values, original_line, index)
77 |
78 |
79 | def verify_tokens(tokens):
80 | LOG.info("Verify %d tokens.", len(tokens))
81 |
82 | if not tokens:
83 | return []
84 |
85 | if tokens[0].indent:
86 | raise exceptions.LexerIncorrectFirstIndentationError(
87 | tokens[0].original, tokens[0].lineno)
88 |
89 | current_level = 0
90 | for token in tokens:
91 | if token.indent - current_level >= 2:
92 | LOG.warning("Token %s has incorrect indentation. "
93 | "Previous level is %d.", token, current_level)
94 | raise exceptions.LexerIncorrectIndentationError(
95 | token.original, token.lineno)
96 | current_level = token.indent
97 |
98 | LOG.info("All %d tokens are fine.", len(tokens))
99 |
100 | return tokens
101 |
102 |
103 | def split_indent(line):
104 | indentation = get_indent(line)
105 | content = line[len(indentation):]
106 |
107 | return indentation, content
108 |
109 |
110 | def get_indent(line):
111 | indentations = RE_INDENT.findall(line)
112 |
113 | if indentations:
114 | return indentations[0]
115 |
116 | return ""
117 |
118 |
119 | def reindent_line(line):
120 | indentation, content = split_indent(line)
121 | if not indentation:
122 | return line
123 |
124 | indentation = indentation.replace("\t", " ")
125 | line = indentation + content
126 |
127 | return line
128 |
129 |
130 | def clean_line(line):
131 | line = RE_COMMENT.sub("", line)
132 | line = line.rstrip()
133 |
134 | return line
135 |
--------------------------------------------------------------------------------
/concierge/core/parser.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import collections
5 | import itertools
6 | import json
7 |
8 | import concierge.core.exceptions as exceptions
9 | import concierge.utils
10 |
11 |
12 | VALID_OPTIONS = set((
13 | "AddressFamily",
14 | "AddKeysToAgent",
15 | "BatchMode",
16 | "BindAddress",
17 | "ChallengeResponseAuthentication",
18 | "CheckHostIP",
19 | "Cipher",
20 | "Ciphers",
21 | "Compression",
22 | "CompressionLevel",
23 | "ConnectionAttempts",
24 | "ConnectTimeout",
25 | "ControlMaster",
26 | "ControlPath",
27 | "DynamicForward",
28 | "EnableSSHKeysign",
29 | "EscapeChar",
30 | "ExitOnForwardFailure",
31 | "ForwardAgent",
32 | "ForwardX11",
33 | "ForwardX11Trusted",
34 | "GatewayPorts",
35 | "GlobalKnownHostsFile",
36 | "GSSAPIAuthentication",
37 | "GSSAPIKeyExchange",
38 | "GSSAPIClientIdentity",
39 | "GSSAPIDelegateCredentials",
40 | "GSSAPIRenewalForcesRekey",
41 | "GSSAPITrustDns",
42 | "HashKnownHosts",
43 | "HostbasedAuthentication",
44 | "HostKeyAlgorithms",
45 | "HostKeyAlias",
46 | "HostName",
47 | "IdentitiesOnly",
48 | "IdentityFile",
49 | "KbdInteractiveAuthentication",
50 | "KbdInteractiveDevices",
51 | "KexAlgorithms",
52 | "LocalCommand",
53 | "LocalForward",
54 | "LogLevel",
55 | "MACs",
56 | "NoHostAuthenticationForLocalhost",
57 | "NumberOfPasswordPrompts",
58 | "PasswordAuthentication",
59 | "PermitLocalCommand",
60 | "Port",
61 | "PreferredAuthentications",
62 | "Protocol",
63 | "ProxyCommand",
64 | "ProxyJump",
65 | "PubkeyAuthentication",
66 | "RekeyLimit",
67 | "RemoteForward",
68 | "RhostsRSAAuthentication",
69 | "RSAAuthentication",
70 | "SendEnv",
71 | "ServerAliveCountMax",
72 | "ServerAliveInterval",
73 | "SmartcardDevice",
74 | "StrictHostKeyChecking",
75 | "TCPKeepAlive",
76 | "Tunnel",
77 | "TunnelDevice",
78 | "UsePrivilegedPort",
79 | "UserKnownHostsFile",
80 | "VerifyHostKeyDNS",
81 | "VisualHostKey",
82 | "XAuthLocation",
83 | "User",
84 | "CertificateFile",
85 | "UseRoaming"
86 | ))
87 |
88 | VIA_JUMP_HOST_OPTION = "ViaJumpHost"
89 | VALID_OPTIONS.add(VIA_JUMP_HOST_OPTION)
90 |
91 | LOG = concierge.utils.logger(__name__)
92 |
93 |
94 | class Host(object):
95 |
96 | def __init__(self, name, parent, trackable=True):
97 | self.values = collections.defaultdict(set)
98 | self.childs = []
99 | self.name = name
100 | self.parent = parent
101 | self.trackable = trackable
102 |
103 | @property
104 | def fullname(self):
105 | if self.name != "" and self.name[0] == "_":
106 | return self.name[1:]
107 | parent_name = self.parent.fullname if self.parent else ""
108 | if parent_name != "" and parent_name[0] == "*":
109 | return self.name + parent_name[1:]
110 | return parent_name + self.name
111 |
112 | @property
113 | def options(self):
114 | if self.parent:
115 | parent_options = self.parent.options
116 | else:
117 | parent_options = collections.defaultdict(set)
118 |
119 | for key, value in self.values.items():
120 | # Yes, =, not 'update'. this is done intentionally to
121 | # fix the situation when you might have some mutually exclusive
122 | # options like User.
123 | parent_options[key] = sorted(value)
124 |
125 | return parent_options
126 |
127 | @property
128 | def hosts(self):
129 | return sorted(self.childs, key=lambda host: host.name)
130 |
131 | @property
132 | def struct(self):
133 | return {
134 | "*name*": self.fullname,
135 | "*options*": self.options,
136 | "*hosts*": [host.struct for host in self.childs]
137 | }
138 |
139 | def add_host(self, name, trackable=True):
140 | LOG.debug("Add host %s to %s.", name, self)
141 |
142 | host = self.__class__(name, self, trackable)
143 | self.childs.append(host)
144 |
145 | return host
146 |
147 | def __setitem__(self, key, value):
148 | self.values[key].add(value)
149 |
150 | def __getitem__(self, key):
151 | return self.options[key]
152 |
153 | def __str__(self):
154 | return "".format(self.fullname)
155 |
156 | def __repr__(self, indent=True):
157 | indent = 4 if indent else None
158 | representation = json.dumps(self.struct, indent=indent)
159 |
160 | return representation
161 |
162 |
163 | def parse(tokens):
164 | LOG.info("Start parsing %d tokens.", len(tokens))
165 |
166 | root_host = Host("", None)
167 | root_host = parse_options(root_host, tokens)
168 | root_host = fix_star_host(root_host)
169 |
170 | LOG.info("Finish parsing of %d tokens.", len(tokens))
171 | LOG.debug("Tree is %s", repr(root_host))
172 |
173 | return root_host
174 |
175 |
176 | def parse_options(root, tokens):
177 | if not tokens:
178 | LOG.debug("No tokens for root %s.", root)
179 | return root
180 |
181 | current_level = tokens[0].indent
182 | LOG.debug("Indent level for root %s is %d.", root, current_level)
183 |
184 | tokens = collections.deque(tokens)
185 | while tokens:
186 | token = tokens.popleft()
187 | LOG.debug("Process token %s for root %s.", token, root)
188 |
189 | if token.option in ("Host", "-Host"):
190 | LOG.debug("Token %s is host token", token)
191 |
192 | host_tokens = get_host_tokens(current_level, tokens)
193 | LOG.debug("Found %d host tokens for token %s: %s.",
194 | len(host_tokens), token, host_tokens)
195 | for name in token.values:
196 | host = root.add_host(name, is_trackable_host(token.option))
197 | parse_options(host, host_tokens)
198 | for _ in range(len(host_tokens)):
199 | tokens.popleft()
200 | elif token.option == VIA_JUMP_HOST_OPTION:
201 | LOG.debug("Special option %s in token %s is detected.",
202 | VIA_JUMP_HOST_OPTION, token)
203 | root["ProxyCommand"] = "ssh -W %h:%p {0}".format(token.values[0])
204 | elif token.option not in VALID_OPTIONS:
205 | LOG.debug("Option %s in token %s is unknown.", token.option, token)
206 | raise exceptions.ParserUnknownOption(token.option)
207 | else:
208 | LOG.debug("Add option %s with values %s to host %s.",
209 | token.option, token.values, root)
210 | root[token.option] = " ".join(token.values)
211 |
212 | return root
213 |
214 |
215 | def fix_star_host(root):
216 | star_host = None
217 |
218 | for host in root.childs:
219 | if host.name == "*":
220 | LOG.debug("Detected known '*' host.")
221 | star_host = host
222 | break
223 | else:
224 | LOG.debug("Add new '*' host.")
225 | star_host = root.add_host("*")
226 |
227 | values = collections.defaultdict(set)
228 | values.update(root.values)
229 | values.update(star_host.values)
230 | star_host.values = values
231 | star_host.trackable = True
232 | root.values.clear()
233 |
234 | return root
235 |
236 |
237 | def get_host_tokens(level, tokens):
238 | host_tokens = itertools.takewhile(lambda tok: tok.indent > level, tokens)
239 | host_tokens = list(host_tokens)
240 |
241 | return host_tokens
242 |
243 |
244 | def is_trackable_host(name):
245 | return name != "-Host"
246 |
--------------------------------------------------------------------------------
/concierge/core/processor.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import concierge.core.lexer
5 | import concierge.core.parser
6 |
7 |
8 | def process(content):
9 | content = content.split("\n")
10 | content = concierge.core.lexer.lex(content)
11 | content = concierge.core.parser.parse(content)
12 | content = generate(content)
13 | content = "\n".join(content)
14 |
15 | return content
16 |
17 |
18 | def generate(tree):
19 | for host in flat(tree):
20 | yield "Host {}".format(host.fullname)
21 |
22 | for option, values in sorted(host.options.items()):
23 | for value in sorted(values):
24 | yield " {} {}".format(option, value)
25 |
26 | yield ""
27 |
28 |
29 | def flat(tree):
30 | for host in sorted(tree.childs, key=lambda h: (h.name == "*", h.name)):
31 | yield from flat_host_data(host)
32 |
33 |
34 | def flat_host_data(tree):
35 | for host in tree.hosts:
36 | yield from flat_host_data(host)
37 |
38 | if tree.trackable:
39 | if not (tree.fullname == "*" and not tree.options):
40 | yield tree
41 |
--------------------------------------------------------------------------------
/concierge/endpoints/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/9seconds/concierge/40b0de3e68354cd06461763b228d8901bc4c2d12/concierge/endpoints/__init__.py
--------------------------------------------------------------------------------
/concierge/endpoints/check.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """`check` command for concierge."""
4 |
5 |
6 | import sys
7 |
8 | import concierge.endpoints.common
9 |
10 |
11 | class CheckApp(concierge.endpoints.common.App):
12 |
13 | def do(self):
14 | return self.output()
15 |
16 |
17 | main = concierge.endpoints.common.main(CheckApp)
18 |
19 | if __name__ == "__main__":
20 | sys.exit(main())
21 |
--------------------------------------------------------------------------------
/concierge/endpoints/cli.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import argparse
5 |
6 | import concierge
7 | import concierge.templater
8 |
9 |
10 | def create_parser():
11 | parser = argparse.ArgumentParser()
12 | parser.add_argument(
13 | "-d", "--debug",
14 | help="Run %(prog)s in debug mode.",
15 | action="store_true",
16 | default=False)
17 | parser.add_argument(
18 | "-v", "--verbose",
19 | help="Run %(prog)s in verbose mode.",
20 | action="store_true",
21 | default=False)
22 | parser.add_argument(
23 | "-s", "--source-path",
24 | help="Path of concierge. Default is {0}".format(concierge.DEFAULT_RC),
25 | default=concierge.DEFAULT_RC)
26 | parser.add_argument(
27 | "-o", "--destination-path",
28 | help=("Path of ssh config. If nothing is set, then prints to stdout. "
29 | "Otherwise, stores into file."),
30 | default=None)
31 | parser.add_argument(
32 | "-b", "--boring-syntax",
33 | help="Use old boring syntax, described in 'man 5 ssh_config'.",
34 | action="store_true",
35 | default=False)
36 | parser.add_argument(
37 | "-a", "--add-header",
38 | help=("Prints header at the top of the file. "
39 | "If nothing is set, then the rule is: if DESTINATION_PATH "
40 | "is file, then this option is true by default. If "
41 | "DESTINATION_PATH is stdout, then this option is set to false."),
42 | action="store_true",
43 | default=None)
44 | parser.add_argument(
45 | "-u", "--use-templater",
46 | help=("Use following templater for config file. If nothing is set, "
47 | "then default template resolve chain will be "
48 | "used (Mako -> Jinja -> Nothing). Dummy templater means that "
49 | "no templater is actually used."),
50 | choices=concierge.templater.all_templaters().keys(),
51 | default=None)
52 | parser.add_argument(
53 | "-t", "--no-templater",
54 | help=("Do not use any templater. Please be noticed that newer "
55 | "version of concierge will change that behavior."),
56 | action="store_true",
57 | default=False)
58 | parser.add_argument(
59 | "-n", "--no-desktop-notifications",
60 | help="Do not show desktop notifications on problems.",
61 | action="store_true",
62 | default=False)
63 |
64 | return parser
65 |
--------------------------------------------------------------------------------
/concierge/endpoints/common.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import abc
5 | import os
6 | import warnings
7 |
8 | import concierge.core.processor
9 | import concierge.endpoints.cli
10 | import concierge.endpoints.templates
11 | import concierge.notifications
12 | import concierge.templater
13 | import concierge.utils
14 |
15 |
16 | LOG = concierge.utils.logger(__name__)
17 |
18 |
19 | class App(metaclass=abc.ABCMeta):
20 |
21 | @classmethod
22 | def specify_parser(cls, parser):
23 | return parser
24 |
25 | def __init__(self, options):
26 | if options.use_templater is None:
27 | warnings.warn(
28 | "--use-templater flag and therefore implicit templater "
29 | "autoresolve are deprecated. Please use explicit "
30 | "templater in both concierge-check and concierge.",
31 | FutureWarning)
32 |
33 | if options.no_templater:
34 | warnings.warn(
35 | "Flag --no-templater is deprecated. "
36 | "Please use 'dummy' templater instead.",
37 | DeprecationWarning)
38 |
39 | self.source_path = options.source_path
40 | self.destination_path = options.destination_path
41 | self.boring_syntax = options.boring_syntax
42 | self.add_header = options.add_header
43 | self.no_templater = getattr(options, "no_templater", False)
44 | self.templater_name = options.use_templater
45 |
46 | if options.no_desktop_notifications:
47 | self.notificator = concierge.notifications.dummy_notifier
48 | else:
49 | self.notificator = concierge.notifications.notifier
50 |
51 | try:
52 | self.templater = concierge.templater.resolve_templater(
53 | self.templater_name)
54 | except KeyError:
55 | raise ValueError(
56 | "Cannot find templater for {0}".format(options.use_templater))
57 |
58 | if self.add_header is None:
59 | self.add_header = options.destination_path is not None
60 |
61 | concierge.utils.configure_logging(
62 | options.debug,
63 | options.verbose,
64 | self.destination_path is None)
65 |
66 | @abc.abstractmethod
67 | def do(self):
68 | pass
69 |
70 | def output(self):
71 | content = self.get_new_config()
72 |
73 | if self.destination_path is None:
74 | print(content)
75 | return
76 |
77 | try:
78 | with concierge.utils.topen(self.destination_path, True) as destfp:
79 | destfp.write(content)
80 | except Exception as exc:
81 | self.log_error("Cannot write to file %s: %s",
82 | self.destination_path, exc)
83 | raise
84 |
85 | def get_new_config(self):
86 | content = self.fetch_content()
87 |
88 | if not self.no_templater:
89 | content = self.apply_template(content)
90 | else:
91 | LOG.info("No templating is used.")
92 |
93 | if not self.boring_syntax:
94 | content = self.process_syntax(content)
95 | else:
96 | LOG.info("Boring syntax was choosen, not processing is applied.")
97 |
98 | if self.add_header:
99 | content = self.attach_header(content)
100 | else:
101 | LOG.info("No need to attach header.")
102 |
103 | return content
104 |
105 | def fetch_content(self):
106 | LOG.info("Fetching content from %s", self.source_path)
107 |
108 | try:
109 | content = concierge.utils.get_content(self.source_path)
110 | except Exception as exc:
111 | self.log_error("Cannot fetch content from %s: %s",
112 | self.source_path, exc)
113 | raise
114 |
115 | LOG.info("Original content of %s:\n%s", self.source_path, content)
116 |
117 | return content
118 |
119 | def apply_template(self, content):
120 | LOG.info("Applying templater to content of %s.", self.source_path)
121 |
122 | try:
123 | content = self.templater.render(content)
124 | except Exception as exc:
125 | self.log_error("Cannot process template (%s) in source file %s.",
126 | self.source_path, self.templater.name, exc)
127 | raise
128 |
129 | LOG.info("Templated content of %s:\n%s", self.source_path, content)
130 |
131 | return content
132 |
133 | def process_syntax(self, content):
134 | try:
135 | return concierge.core.processor.process(content)
136 | except Exception as exc:
137 | self.log_error("Cannot parse content of source file %s: %s",
138 | self.source_path, exc)
139 | raise
140 |
141 | def attach_header(self, content):
142 | header = concierge.endpoints.templates.make_header(
143 | rc_file=self.source_path)
144 | content = header + content
145 |
146 | return content
147 |
148 | def log_error(self, template, *args):
149 | LOG.error(template, *args)
150 | self.notificator(template % args)
151 |
152 |
153 | def main(app_class):
154 | def main_func():
155 | parser = concierge.endpoints.cli.create_parser()
156 | parser = app_class.specify_parser(parser)
157 | options = parser.parse_args()
158 | app = app_class(options)
159 |
160 | LOG.debug("Options: %s", options)
161 |
162 | try:
163 | return app.do()
164 | except KeyboardInterrupt:
165 | pass
166 | except Exception as exc:
167 | LOG.exception("Failed with error %s", exc)
168 | return os.EX_SOFTWARE
169 |
170 | return main_func
171 |
--------------------------------------------------------------------------------
/concierge/endpoints/daemon.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | """`concierge` daemon which converts ~/.conciergerc to ~/.ssh/config."""
4 |
5 |
6 | import os
7 | import os.path
8 | import sys
9 |
10 | import inotify_simple
11 |
12 | import concierge.endpoints.common
13 | import concierge.utils
14 |
15 |
16 | LOG = concierge.utils.logger(__name__)
17 |
18 |
19 | INOTIFY_FLAGS = (
20 | inotify_simple.flags.CREATE |
21 | inotify_simple.flags.MODIFY |
22 | inotify_simple.flags.MOVED_TO |
23 | inotify_simple.flags.EXCL_UNLINK
24 | )
25 |
26 |
27 | class Daemon(concierge.endpoints.common.App):
28 |
29 | @staticmethod
30 | def describe_events(events):
31 | descriptions = []
32 |
33 | for event in events:
34 | flags = inotify_simple.flags.from_mask(event.mask)
35 | flags = (str(flag) for flag in flags)
36 |
37 | descriptions.append(
38 | "Ev<(name={0}, flags={1})>".format(
39 | event.name, ",".join(flags)))
40 |
41 | return descriptions
42 |
43 | @classmethod
44 | def specify_parser(cls, parser):
45 | parser.add_argument(
46 | "--systemd",
47 | help="Printout instructions to set deamon with systemd.",
48 | action="store_true",
49 | default=False)
50 | parser.add_argument(
51 | "--curlsh",
52 | help="I do not care and want curl | sh.",
53 | action="store_true",
54 | default=False)
55 |
56 | return parser
57 |
58 | def __init__(self, options):
59 | super().__init__(options)
60 |
61 | self.systemd = options.systemd
62 | self.curlsh = options.curlsh
63 |
64 | def do(self):
65 | if not self.systemd:
66 | return self.track()
67 |
68 | script = concierge.endpoints.templates.make_systemd_script(
69 | self.templater_name)
70 |
71 | if not self.curlsh:
72 | script = [
73 | "Please execute following lines or compose script:",
74 | ""] + ["$ {0}".format(line) for line in script]
75 |
76 | print("\n".join(script))
77 |
78 | def track(self):
79 | with inotify_simple.INotify() as notify:
80 | self.add_watch(notify)
81 | self.manage_events(notify)
82 |
83 | def add_watch(self, notify):
84 | # there is a sad story on editors: some of them actually modify
85 | # files. But some write temporary files and rename. So it is
86 | # required to track directory where file is placed.
87 | path = os.path.abspath(self.source_path)
88 | path = os.path.dirname(path)
89 | notify.add_watch(path, INOTIFY_FLAGS)
90 |
91 | def manage_events(self, notify):
92 | filename = os.path.basename(self.source_path)
93 |
94 | while True:
95 | try:
96 | events = notify.read()
97 | except KeyboardInterrupt:
98 | return os.EX_OK
99 | else:
100 | LOG.debug("Caught %d events", len(events))
101 |
102 | events = self.filter_events(filename, events)
103 | descriptions = self.describe_events(events)
104 | LOG.debug("Got %d events after filtration: %s",
105 | len(descriptions), descriptions)
106 |
107 | if events:
108 | self.output()
109 |
110 | LOG.info("Config was managed. Going to the next loop.")
111 |
112 | def filter_events(self, name, events):
113 | events = filter(lambda ev: ev.name == name, events)
114 | events = list(events)
115 |
116 | return events
117 |
118 |
119 | main = concierge.endpoints.common.main(Daemon)
120 |
121 |
122 | if __name__ == "__main__":
123 | sys.exit(main())
124 |
--------------------------------------------------------------------------------
/concierge/endpoints/templates.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import datetime
5 | import distutils.spawn
6 | import os.path
7 | import sys
8 |
9 | import concierge
10 |
11 |
12 | HEADER = """
13 | # THIS FILE WAS AUTOGENERATED BY concierge on {date}.
14 | # IT MAKES NO SENSE TO EDIT IT MANUALLY!
15 | #
16 | # CONCIERGERC FILE: {rc_file}
17 | #
18 | # PLEASE VISIT https://github.com/9seconds/concierge FOR DETAILS.
19 | """.strip() + "\n\n"
20 |
21 |
22 | SYSTEMD_CONFIG = """
23 | [Unit]
24 | Description=Daemon for converting ~/.concierge to ~/.ssh/config
25 |
26 | [Service]
27 | ExecStart={command} -u {templater} -o {sshconfig}
28 | Restart=on-failure
29 |
30 | [Install]
31 | WantedBy=default.target
32 | """.strip()
33 |
34 | SYSTEMD_SERVICE_NAME = "concierge.service"
35 |
36 | SYSTEMD_INSTRUCTIONS = """
37 | Please execute following lines or compose script:
38 |
39 | $ mkdir -p "{systemd_user_path}" || true
40 | $ cat > "{systemd_user_service_path}" < "{0}" < 1:
20 | method = unittest.mock.patch.object
21 | else:
22 | method = unittest.mock.patch
23 |
24 | patch = method(*mock_args, **mock_kwargs)
25 | mocked = patch.start()
26 |
27 | request.addfinalizer(patch.stop)
28 |
29 | return mocked
30 |
31 |
32 | @pytest.fixture
33 | def no_sleep(monkeypatch):
34 | monkeypatch.setattr("time.sleep", lambda arg: arg)
35 |
36 |
37 | @pytest.fixture
38 | def mock_get_content(request):
39 | return have_mocked(request, "concierge.utils.get_content")
40 |
41 |
42 | @pytest.fixture(scope="session", autouse=True)
43 | def mock_logger(request):
44 | return have_mocked(request, "concierge.utils.logger")
45 |
46 |
47 | @pytest.fixture(autouse=True)
48 | def mock_log_configuration(request):
49 | have_mocked(request, "socket.socket") # required for SysLogHandler
50 |
51 | marker = request.node.get_marker("no_mock_log_configuration")
52 |
53 | if not marker:
54 | return have_mocked(request, "concierge.utils.configure_logging")
55 |
56 |
57 | @pytest.fixture(autouse=True)
58 | def mock_notificatior(request, monkeypatch):
59 | marker = request.node.get_marker("no_mock_notificatior")
60 |
61 | if not marker:
62 | monkeypatch.setattr(
63 | concierge.notifications,
64 | "notifier",
65 | concierge.notifications.dummy_notifier)
66 |
67 |
68 | @pytest.fixture
69 | def ptmpdir(request, tmpdir):
70 | for key in "TMP", "TEMPDIR", "TEMP":
71 | os.environ[key] = tmpdir.strpath
72 |
73 | request.addfinalizer(lambda: shutil.rmtree(tmpdir.strpath))
74 |
75 | return tmpdir
76 |
77 |
78 | @pytest.fixture
79 | def sysargv(monkeypatch):
80 | argv = ["concierge"]
81 |
82 | monkeypatch.setattr(sys, "argv", argv)
83 |
84 | return argv
85 |
86 |
87 | @pytest.fixture
88 | def inotifier(request):
89 | mock = have_mocked(request, "inotify_simple.INotify")
90 | mock.return_value = mock
91 | mock.__enter__.return_value = mock
92 |
93 | values = [inotify_simple.Event(0, 0, 0,
94 | os.path.basename(concierge.DEFAULT_RC))]
95 | values *= 3
96 |
97 | def side_effect():
98 | if values:
99 | return [values.pop()]
100 | raise KeyboardInterrupt
101 |
102 | mock.read.side_effect = side_effect
103 | mock.v = values
104 |
105 | return mock
106 |
107 |
108 | @pytest.fixture
109 | def template_render(request):
110 | return have_mocked(request, concierge.templater.Templater, "render")
111 |
112 |
113 | @pytest.fixture(params=(None, "-d", "--debug"))
114 | def cliparam_debug(request):
115 | return request.param
116 |
117 |
118 | @pytest.fixture(params=(None, "-v", "--verbose"))
119 | def cliparam_verbose(request):
120 | return request.param
121 |
122 |
123 | @pytest.fixture(params=(None, "-s", "--source-path"))
124 | def cliparam_source_path(request):
125 | return request.param
126 |
127 |
128 | @pytest.fixture(params=(None, "-o", "--destination-path"))
129 | def cliparam_destination_path(request):
130 | return request.param
131 |
132 |
133 | @pytest.fixture(params=(None, "-b", "--boring-syntax"))
134 | def cliparam_boring_syntax(request):
135 | return request.param
136 |
137 |
138 | @pytest.fixture(params=(None, "-a", "--add-header"))
139 | def cliparam_add_header(request):
140 | return request.param
141 |
142 |
143 | @pytest.fixture(params=(None, "-t", "--no-templater"))
144 | def cliparam_no_templater(request):
145 | return request.param
146 |
147 |
148 | @pytest.fixture(params=(None, "--systemd"))
149 | def cliparam_systemd(request):
150 | return request.param
151 |
152 |
153 | @pytest.fixture(params=(None, "--curlsh"))
154 | def cliparam_curlsh(request):
155 | return request.param
156 |
157 |
158 | @pytest.fixture(params=(None, "-n", "--no-desktop-notifications"))
159 | def cliparam_no_desktop_notifications(request):
160 | return request.param
161 |
162 |
163 | @pytest.fixture
164 | def cliargs_default(sysargv):
165 | return sysargv
166 |
167 |
168 | @pytest.fixture
169 | def cliargs_fullset(sysargv, cliparam_debug, cliparam_verbose,
170 | cliparam_source_path, cliparam_destination_path,
171 | cliparam_boring_syntax, cliparam_add_header,
172 | cliparam_no_templater, cliparam_no_desktop_notifications):
173 | options = {
174 | "debug": cliparam_debug,
175 | "verbose": cliparam_verbose,
176 | "source_path": cliparam_source_path,
177 | "destination_path": cliparam_destination_path,
178 | "add_header": cliparam_add_header,
179 | "boring_syntax": cliparam_boring_syntax,
180 | "no_templater": cliparam_no_templater,
181 | "no_desktop_notifications": cliparam_no_desktop_notifications}
182 | bool_params = (
183 | cliparam_debug, cliparam_verbose, cliparam_boring_syntax,
184 | cliparam_add_header, cliparam_no_desktop_notifications)
185 | value_params = (
186 | cliparam_source_path, cliparam_destination_path)
187 |
188 | for param in bool_params:
189 | if param:
190 | sysargv.append(param)
191 |
192 | for param in value_params:
193 | if param:
194 | sysargv.append(param)
195 | sysargv.append("/path/to")
196 |
197 | if cliparam_no_templater:
198 | sysargv.append(cliparam_no_templater)
199 |
200 | return sysargv, options
201 |
202 |
203 | @pytest.fixture
204 | def cliargs_concierge_fullset(cliargs_fullset, cliparam_systemd,
205 | cliparam_curlsh):
206 | sysargv, options = cliargs_fullset
207 |
208 | for param in cliparam_systemd, cliparam_curlsh:
209 | if param:
210 | sysargv.append(param)
211 |
212 | options["systemd"] = cliparam_systemd
213 | options["curlsh"] = cliparam_curlsh
214 |
215 | return sysargv, options
216 |
217 |
218 | @pytest.fixture
219 | def mock_mainfunc(cliargs_default, mock_get_content, inotifier):
220 | mock_get_content.return_value = """\
221 | Compression yes
222 |
223 | Host q
224 | HostName e
225 |
226 | Host b
227 | HostName lalala
228 | """
229 |
230 | return cliargs_default, mock_get_content, inotifier
231 |
--------------------------------------------------------------------------------
/tests/test_core_lexer.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.core.exceptions as exceptions
7 | import concierge.core.lexer as lexer
8 |
9 |
10 | def make_token(indent_lvl=0):
11 | token_name = "a{0}".format(0)
12 |
13 | return lexer.Token(indent_lvl, token_name, [token_name], token_name, 0)
14 |
15 |
16 | @pytest.mark.parametrize(
17 | "input_, output_", (
18 | ("", ""),
19 | (" ", ""),
20 | (" #", ""),
21 | ("# ", ""),
22 | (" # dsfsdfsdf sdfsdfsd", ""),
23 | (" a", " a"),
24 | (" a# sdfsfdf", " a"),
25 | (" a # sdfsfsd x xxxxxxx # sdfsfd", " a")))
26 | def test_clean_line(input_, output_):
27 | assert lexer.clean_line(input_) == output_
28 |
29 |
30 | @pytest.mark.parametrize(
31 | "input_, output_", (
32 | ("", ""),
33 | (" ", " "),
34 | (" ", " "),
35 | (" ", " "),
36 | ("\t ", " "),
37 | ("\t\t\t", 12 * " "),
38 | ("\t \t", " "),
39 | ("\t\t\t ", " "),
40 | (" \t\t\t ", " ")))
41 | def test_reindent_line(input_, output_):
42 | assert lexer.reindent_line(input_) == output_
43 |
44 |
45 | @pytest.mark.parametrize(
46 | "indent_", (
47 | "",
48 | " ",
49 | " ",
50 | "\t",
51 | "\t\t",
52 | "\t \t",
53 | "\t\t ",
54 | " \t\t"))
55 | @pytest.mark.parametrize(
56 | "content_", (
57 | "",
58 | "a"))
59 | def test_get_split_indent(indent_, content_):
60 | text = indent_ + content_
61 |
62 | assert lexer.get_indent(text) == indent_
63 | assert lexer.split_indent(text) == (indent_, content_)
64 |
65 |
66 | @pytest.mark.parametrize(
67 | "text", (
68 | "#",
69 | "# ",
70 | "# sdfsdf #",
71 | "## sdfsfdf",
72 | "# #sdf # #"))
73 | def test_regexp_comment_ok(text):
74 | assert lexer.RE_COMMENT.match(text)
75 |
76 |
77 | @pytest.mark.parametrize(
78 | "text", (
79 | "",
80 | "sdfdsf",
81 | "sdfsdf#",
82 | "dzfsdfsdf#sdfsdf",
83 | "sdf #",
84 | " #"))
85 | def test_regexp_comment_nok(text):
86 | assert not lexer.RE_COMMENT.match(text)
87 |
88 |
89 | @pytest.mark.parametrize(
90 | "text", (
91 | " ",
92 | " ",
93 | " ",
94 | "\t"))
95 | def test_regexp_indent_ok(text):
96 | assert lexer.RE_INDENT.match(text)
97 |
98 |
99 | @pytest.mark.parametrize(
100 | "text", (
101 | "",
102 | "sdf",
103 | "sdfs ",
104 | "sdfsfd dsfx"))
105 | def test_regexp_indent_nok(text):
106 | assert not lexer.RE_INDENT.match(text)
107 |
108 |
109 | @pytest.mark.parametrize(
110 | "text", (
111 | "''",
112 | "'sdf'",
113 | "'sdfsf\'sfdsf'",
114 | "'sdfsd\'\'sdfsf\'sdf\'sdfxx'"
115 | '""',
116 | '"sdf"',
117 | '"sdfsf\"fdsf"',
118 | '"sdfsd\"\"sdfsf\"sdf\"sdfx"',
119 | "'\"'",
120 | "'sdfsdf' \"sdfsdf\"",
121 | "'sdfx\"sdx' 'sdfdf\"' \"sdfx'sdfffffdf\" \"sdfsdf'sdxx'ds\""))
122 | def test_regexp_quoted_ok(text):
123 | assert lexer.RE_QUOTED.match(text)
124 |
125 |
126 | @pytest.mark.parametrize(
127 | "text", (
128 | "'xx\"",
129 | "\"sdfk'"))
130 | def test_regexp_quoted_nok(text):
131 | assert not lexer.RE_QUOTED.match(text)
132 |
133 |
134 | @pytest.mark.parametrize(
135 | "text", (
136 | "hhh x",
137 | "hhh x",
138 | "hhh \tx",
139 | "hhh=x",
140 | "hhh =sdfsf",
141 | "sdf= sdfx",
142 | "sdf = sdf",
143 | "hhh x",
144 | "sdfsf- x"))
145 | def test_regexp_optvalue_ok(text):
146 | assert lexer.RE_OPT_VALUE.match(text)
147 |
148 |
149 | @pytest.mark.parametrize(
150 | "text", (
151 | "",
152 | "hhx",
153 | "sdfsf ",
154 | " sdfsfdf",
155 | "sdfsf =",
156 | "sdfsf= ",
157 | "sdfsdf = ",
158 | " "))
159 | def test_regexp_optvalue_nok(text):
160 | assert not lexer.RE_OPT_VALUE.match(text)
161 |
162 |
163 | @pytest.mark.parametrize(
164 | "input_, output_", (
165 | ("", ""),
166 | ("a", "a"),
167 | (" a", " a"),
168 | (" a", " a"),
169 | ("\ta", " a"),
170 | (" \ta", " a"),
171 | (" \t a", " a"),
172 | (" \t a ", " a"),
173 | (" \t a #sdfds", " a"),
174 | (" \t a #sdfds #", " a"),
175 | ("a\t", "a"),
176 | ("a\t\r", "a"),
177 | ("a\r", "a"),
178 | ("a\n", "a")))
179 | def test_process_line(input_, output_):
180 | assert lexer.process_line(input_) == output_
181 |
182 |
183 | @pytest.mark.parametrize(
184 | "text, indent_len, option, values", (
185 | ("\ta 1", 1, "a", "1"),
186 | ("\ta 1 2", 1, "a", ["1", "2"]),
187 | ("\t\ta 1 2", 2, "a", ["1", "2"]),
188 | ("a 1 2 'cv'", 0, "a", ["1", "2", "'cv'"]),
189 | ("a 1 2 \"cv\"", 0, "a", ["1", "2", '"cv"']),
190 | ("a 1 2 \"cv\" 3", 0, "a", ["1", "2", '"cv"', "3"]),
191 | ("\ta=1", 1, "a", "1"),
192 | ("\ta =1 2", 1, "a", ["1", "2"]),
193 | ("\t\ta= 1 2", 2, "a", ["1", "2"]),
194 | ("a = 1 2 'cv'", 0, "a", ["1", "2", "'cv'"])))
195 | def test_make_token_ok(text, indent_len, option, values):
196 | processed_line = lexer.process_line(text)
197 | token = lexer.make_token(processed_line, text, 1)
198 |
199 | if not isinstance(values, (list, tuple)):
200 | values = [values]
201 |
202 | assert token.indent == indent_len
203 | assert token.option == option
204 | assert token.values == values
205 | assert token.original == text
206 |
207 |
208 | @pytest.mark.parametrize(
209 | "text", (
210 | "",
211 | "a",
212 | "a=",
213 | "a =",
214 | "a ",
215 | "=",
216 | "==",
217 | " =asd"))
218 | def test_make_token_incorrect_value(text):
219 | with pytest.raises(exceptions.LexerIncorrectOptionValue):
220 | lexer.make_token(text, text, 1)
221 |
222 |
223 | @pytest.mark.parametrize(
224 | "offset", (
225 | 1, 2, 3, 5, 6, 7))
226 | def test_make_token_incorrect_indentation(offset):
227 | text = " " * offset + "a = 1"
228 |
229 | with pytest.raises(exceptions.LexerIncorrectIndentationLength):
230 | lexer.make_token(text, text, 1)
231 |
232 |
233 | def test_verify_tokens_empty():
234 | assert lexer.verify_tokens([]) == []
235 |
236 |
237 | def test_verify_tokens_one_token():
238 | token = make_token(indent_lvl=0)
239 |
240 | assert lexer.verify_tokens([token]) == [token]
241 |
242 |
243 | @pytest.mark.parametrize(
244 | "level", list(range(1, 4)))
245 | def test_verify_tokens_one_token_incorrect_level(level):
246 | token = make_token(indent_lvl=level)
247 |
248 | with pytest.raises(exceptions.LexerIncorrectFirstIndentationError):
249 | assert lexer.verify_tokens([token]) == [token]
250 |
251 |
252 | def test_verify_tokens_ladder_level():
253 | tokens = [make_token(indent_lvl=level) for level in range(5)]
254 |
255 | assert lexer.verify_tokens(tokens) == tokens
256 |
257 |
258 | @pytest.mark.parametrize(
259 | "level", list(range(2, 7)))
260 | def test_verify_tokens_big_level_gap(level):
261 | tokens = [make_token(indent_lvl=0), make_token(indent_lvl=level)]
262 |
263 | with pytest.raises(exceptions.LexerIncorrectIndentationError):
264 | assert lexer.verify_tokens(tokens) == tokens
265 |
266 |
267 | @pytest.mark.parametrize("level", list(range(5)))
268 | def test_verify_tokens_dedent(level):
269 | tokens = [make_token(indent_lvl=lvl) for lvl in range(5)]
270 | tokens.append(make_token(indent_lvl=level))
271 |
272 | assert lexer.verify_tokens(tokens) == tokens
273 |
274 |
275 | def test_verify_tokens_lex_ok():
276 | text = """\
277 | aa = 1
278 | b 1
279 |
280 |
281 | q = 2
282 | c = 3 # q
283 | d = 5 'aa' "sdx" xx 3 3
284 |
285 | e = 3
286 | """.strip()
287 |
288 | tokens = lexer.lex(text.split("\n"))
289 |
290 | assert len(tokens) == 6
291 |
292 | assert tokens[0].indent == 0
293 | assert tokens[0].option == "aa"
294 | assert tokens[0].values == ["1"]
295 | assert tokens[0].original == "aa = 1"
296 | assert tokens[0].lineno == 1
297 |
298 | assert tokens[1].indent == 0
299 | assert tokens[1].option == "b"
300 | assert tokens[1].values == ["1"]
301 | assert tokens[1].original == "b 1"
302 | assert tokens[1].lineno == 2
303 |
304 | assert tokens[2].indent == 1
305 | assert tokens[2].option == "q"
306 | assert tokens[2].values == ["2"]
307 | assert tokens[2].original == " q = 2"
308 | assert tokens[2].lineno == 5
309 |
310 | assert tokens[3].indent == 1
311 | assert tokens[3].option == "c"
312 | assert tokens[3].values == ["3"]
313 | assert tokens[3].original == " c = 3 # q"
314 | assert tokens[3].lineno == 6
315 |
316 | assert tokens[4].indent == 2
317 | assert tokens[4].option == "d"
318 | assert tokens[4].values == ["5", "'aa'", '"sdx"', "xx", "3", "3"]
319 | assert tokens[4].original == " d = 5 'aa' \"sdx\" xx 3 3"
320 | assert tokens[4].lineno == 7
321 |
322 | assert tokens[5].indent == 0
323 | assert tokens[5].option == "e"
324 | assert tokens[5].values == ["3"]
325 | assert tokens[5].original == "e = 3"
326 | assert tokens[5].lineno == 9
327 |
328 |
329 | def test_lex_incorrect_first_indentation():
330 | text = """\
331 | a = 1
332 | b = 3
333 | """
334 |
335 | with pytest.raises(exceptions.LexerIncorrectFirstIndentationError):
336 | lexer.lex(text.split("\n"))
337 |
--------------------------------------------------------------------------------
/tests/test_endpoints_app.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import os
5 |
6 | import pytest
7 |
8 | import concierge
9 | import concierge.endpoints.cli as cli
10 | import concierge.endpoints.common as common
11 |
12 |
13 | def get_app():
14 | parser = cli.create_parser()
15 | parser = SimpleApp.specify_parser(parser)
16 | parsed = parser.parse_args()
17 | app = SimpleApp(parsed)
18 |
19 | return app
20 |
21 |
22 | class SimpleApp(common.App):
23 |
24 | def do(self):
25 | return self.output()
26 |
27 |
28 | def test_resolve_templater_unknown(cliargs_default, monkeypatch):
29 | def boom(*args, **kwargs):
30 | raise KeyError
31 |
32 | monkeypatch.setattr("concierge.templater.resolve_templater", boom)
33 |
34 | with pytest.raises(ValueError):
35 | get_app()
36 |
37 |
38 | def test_fetch_content_ok(cliargs_default, mock_get_content):
39 | mock_get_content.return_value = "Content"
40 |
41 | app = get_app()
42 | assert app.fetch_content() == mock_get_content.return_value
43 |
44 |
45 | def test_fetch_content_exception(cliargs_default, mock_get_content):
46 | mock_get_content.side_effect = Exception
47 |
48 | app = get_app()
49 | with pytest.raises(Exception):
50 | app.fetch_content()
51 |
52 |
53 | def test_apply_content_ok(monkeypatch, cliargs_default, template_render):
54 | template_render.side_effect = lambda param: param.upper()
55 |
56 | app = get_app()
57 | assert app.apply_template("hello") == "HELLO"
58 |
59 |
60 | def test_apply_content_exception(monkeypatch, cliargs_default,
61 | template_render):
62 | template_render.side_effect = Exception
63 |
64 | app = get_app()
65 | with pytest.raises(Exception):
66 | app.apply_template("hello")
67 |
68 |
69 | def test_process_syntax_ok(cliargs_default):
70 | content = """\
71 | Host n
72 | ViaJumpHost x
73 | """
74 |
75 | app = get_app()
76 | assert app.process_syntax(content) == (
77 | "Host n\n"
78 | " ProxyCommand ssh -W %h:%p x\n")
79 |
80 |
81 | def test_process_syntax_exception(cliargs_default):
82 | app = get_app()
83 |
84 | with pytest.raises(Exception):
85 | app.process_syntax("WTF")
86 |
87 |
88 | def test_attach_header(cliargs_default):
89 | app = get_app()
90 | assert app.attach_header("Content").startswith("#")
91 |
92 |
93 | @pytest.mark.parametrize(
94 | "no_templater", (
95 | True, False))
96 | @pytest.mark.parametrize(
97 | "boring_syntax", (
98 | True, False))
99 | @pytest.mark.parametrize(
100 | "add_header", (
101 | True, False))
102 | def test_get_new_config(monkeypatch, cliargs_default, template_render,
103 | mock_get_content, no_templater, boring_syntax,
104 | add_header):
105 | template_render.side_effect = lambda param: param.upper()
106 | mock_get_content.return_value = """\
107 | Compression yes
108 |
109 | Host q
110 | HostName e
111 |
112 | Host b
113 | HostName lalala
114 | """
115 |
116 | app = get_app()
117 | app.no_templater = no_templater
118 | app.boring_syntax = boring_syntax
119 | app.add_header = add_header
120 |
121 | if not no_templater and not boring_syntax:
122 | with pytest.raises(Exception):
123 | app.get_new_config()
124 | else:
125 | result = app.get_new_config()
126 |
127 | if not no_templater:
128 | assert "COMPRESSION YES" in result
129 | else:
130 | assert "Compression yes" in result
131 |
132 | if boring_syntax:
133 | assert "Host qb" not in result
134 | else:
135 | assert "Host qb" in result
136 |
137 | if add_header:
138 | assert result.startswith("#")
139 | else:
140 | assert not result.startswith("#")
141 |
142 |
143 | def test_output_stdout(capfd, monkeypatch, cliargs_default, mock_get_content):
144 | mock_get_content.return_value = """\
145 | Compression yes
146 |
147 | Host q
148 | HostName e
149 |
150 | Host b
151 | HostName lalala
152 | """
153 |
154 | app = get_app()
155 | app.destination_path = None
156 |
157 | app.output()
158 |
159 | out, err = capfd.readouterr()
160 | assert out == """\
161 | Host qb
162 | HostName lalala
163 |
164 | Host q
165 | HostName e
166 |
167 | Host *
168 | Compression yes
169 |
170 | """
171 | assert not err
172 |
173 |
174 | def test_output_file(cliargs_default, ptmpdir, mock_get_content):
175 | mock_get_content.return_value = """\
176 | Compression yes
177 |
178 | Host q
179 | HostName e
180 |
181 | Host b
182 | HostName lalala
183 | """
184 |
185 | app = get_app()
186 | app.destination_path = ptmpdir.join("config").strpath
187 |
188 | app.output()
189 |
190 | with open(ptmpdir.join("config").strpath, "r") as filefp:
191 | assert filefp.read()
192 |
193 |
194 | def test_output_file_exception(monkeypatch, cliargs_default, ptmpdir,
195 | mock_get_content):
196 | def write_fail(*args, **kwargs):
197 | raise Exception
198 |
199 | monkeypatch.setattr("concierge.utils.topen", write_fail)
200 | mock_get_content.return_value = """\
201 | Compression yes
202 |
203 | Host q
204 | HostName e
205 |
206 | Host b
207 | HostName lalala
208 | """
209 |
210 | app = get_app()
211 | app.destination_path = ptmpdir.join("config").strpath
212 |
213 | with pytest.raises(Exception):
214 | app.output()
215 |
216 |
217 | @pytest.mark.longrun
218 | def test_create_app(cliargs_fullset, mock_log_configuration):
219 | _, options = cliargs_fullset
220 |
221 | parser = cli.create_parser()
222 | parsed = parser.parse_args()
223 |
224 | app = SimpleApp(parsed)
225 |
226 | assert app.boring_syntax == bool(options["boring_syntax"])
227 |
228 | if options["source_path"]:
229 | assert app.source_path == "/path/to"
230 | else:
231 | assert app.source_path == concierge.DEFAULT_RC
232 |
233 | if options["destination_path"]:
234 | assert app.destination_path == "/path/to"
235 | else:
236 | assert app.destination_path is None
237 |
238 | if options["add_header"] is not None:
239 | assert app.add_header
240 | else:
241 | assert app.add_header == (options["destination_path"] is not None)
242 |
243 | assert mock_log_configuration.called
244 |
245 |
246 | def test_mainfunc_ok(cliargs_default, mock_get_content):
247 | mock_get_content.return_value = """\
248 | Compression yes
249 |
250 | Host q
251 | HostName e
252 |
253 | Host b
254 | HostName lalala
255 | """
256 |
257 | main = concierge.endpoints.common.main(SimpleApp)
258 | result = main()
259 |
260 | assert result is None or result == os.EX_OK
261 |
262 |
263 | def test_mainfunc_exception(cliargs_default, mock_get_content):
264 | mock_get_content.side_effect = Exception
265 |
266 | main = concierge.endpoints.common.main(SimpleApp)
267 |
268 | assert main() != os.EX_OK
269 |
270 |
271 | def test_mainfunc_keyboardinterrupt(cliargs_default, mock_get_content):
272 | mock_get_content.side_effect = KeyboardInterrupt
273 |
274 | main = concierge.endpoints.common.main(SimpleApp)
275 | result = main()
276 |
277 | assert result is None or result == os.EX_OK
278 |
--------------------------------------------------------------------------------
/tests/test_endpoints_check.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import os
5 |
6 | import concierge.endpoints.check
7 |
8 |
9 | def test_mainfunc_ok(mock_mainfunc):
10 | main = concierge.endpoints.common.main(concierge.endpoints.check.CheckApp)
11 | result = main()
12 |
13 | assert result is None or result == os.EX_OK
14 |
15 |
16 | def test_mainfunc_exception(mock_mainfunc):
17 | _, mock_get_content, _ = mock_mainfunc
18 | mock_get_content.side_effect = Exception
19 |
20 | main = concierge.endpoints.common.main(concierge.endpoints.check.CheckApp)
21 |
22 | assert main() != os.EX_OK
23 |
--------------------------------------------------------------------------------
/tests/test_endpoints_cli.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import concierge
5 | import concierge.endpoints.cli as cli
6 |
7 |
8 | def test_parser_default(cliargs_default):
9 | parser = cli.create_parser()
10 | parsed = parser.parse_args()
11 |
12 | assert not parsed.debug
13 | assert not parsed.verbose
14 | assert parsed.source_path == concierge.DEFAULT_RC
15 | assert parsed.destination_path is None
16 | assert not parsed.boring_syntax
17 | assert parsed.add_header is None
18 |
--------------------------------------------------------------------------------
/tests/test_endpoints_daemon.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import errno
5 | import itertools
6 | import os
7 | import os.path
8 |
9 | import inotify_simple
10 | import pytest
11 |
12 | import concierge
13 | import concierge.endpoints.cli as cli
14 | import concierge.endpoints.daemon as daemon
15 | import concierge.utils
16 |
17 |
18 | def get_app(*params):
19 | parser = cli.create_parser()
20 | parser = daemon.Daemon.specify_parser(parser)
21 | parsed = parser.parse_args()
22 |
23 | for param in params:
24 | if param:
25 | setattr(parsed, param.strip("-"), True)
26 |
27 | app = daemon.Daemon(parsed)
28 |
29 | return app
30 |
31 |
32 | def test_create_app(cliargs_default, cliparam_systemd, cliparam_curlsh):
33 | app = get_app(cliparam_systemd, cliparam_curlsh)
34 |
35 | assert app.systemd == bool(cliparam_systemd)
36 | assert app.curlsh == bool(cliparam_curlsh)
37 |
38 |
39 | def test_print_help(capfd, cliargs_default, cliparam_curlsh):
40 | app = get_app("--systemd", cliparam_curlsh)
41 |
42 | app.do()
43 |
44 | out, err = capfd.readouterr()
45 | out = out.split("\n")
46 |
47 | if cliparam_curlsh:
48 | for line in out:
49 | assert not line.startswith("$")
50 | else:
51 | assert line.startswith(("$", "Please")) or not line
52 |
53 | assert not err
54 |
55 |
56 | @pytest.mark.parametrize(
57 | "main_method", (
58 | True, False))
59 | def test_work(mock_mainfunc, ptmpdir, main_method):
60 | _, _, inotifier = mock_mainfunc
61 |
62 | app = get_app()
63 | app.destination_path = ptmpdir.join("filename").strpath
64 |
65 | if main_method:
66 | app.do()
67 | else:
68 | app.track()
69 |
70 | inotifier.add_watch.assert_called_once_with(
71 | os.path.dirname(concierge.DEFAULT_RC), daemon.INOTIFY_FLAGS)
72 | assert not inotifier.v
73 |
74 | with concierge.utils.topen(ptmpdir.join("filename").strpath) as filefp:
75 | assert 1 == sum(int(line.strip() == "Host *") for line in filefp)
76 |
77 |
78 | def test_track_no_our_events(no_sleep, mock_mainfunc, ptmpdir):
79 | _, _, inotifier = mock_mainfunc
80 |
81 | inotifier.v.clear()
82 | inotifier.v.extend([inotify_simple.Event(0, 0, 0, "Fake")] * 3)
83 |
84 | app = get_app()
85 | app.destination_path = ptmpdir.join("filename").strpath
86 | app.track()
87 |
88 | assert not os.path.exists(ptmpdir.join("filename").strpath)
89 |
90 |
91 | def test_track_cannot_read(no_sleep, mock_mainfunc, ptmpdir):
92 | _, _, inotifier = mock_mainfunc
93 |
94 | def add_watch(*args, **kwargs):
95 | exc = IOError("Hello?")
96 | exc.errno = errno.EPERM
97 |
98 | raise exc
99 |
100 | inotifier.add_watch.side_effect = add_watch
101 |
102 | app = get_app()
103 | app.destination_path = ptmpdir.join("filename").strpath
104 |
105 | with pytest.raises(IOError):
106 | app.track()
107 |
108 |
109 | @pytest.mark.parametrize(
110 | "ev1, ev2",
111 | list(itertools.permutations(inotify_simple.flags, 2)))
112 | def test_event_names(ev1, ev2):
113 | events = [
114 | inotify_simple.Event(0, ev1, 0, "ev1"),
115 | inotify_simple.Event(0, ev2, 0, "ev2"),
116 | inotify_simple.Event(0, ev1 | ev2, 0, "ev1ev2")]
117 |
118 | descriptions = daemon.Daemon.describe_events(events)
119 |
120 | assert len(descriptions) == len(events)
121 |
122 | assert "ev1" in descriptions[0]
123 | assert str(ev1) in descriptions[0]
124 |
125 | assert "ev2" in descriptions[1]
126 | assert str(ev2) in descriptions[1]
127 |
128 | assert "ev1" in descriptions[2]
129 | assert "ev2" in descriptions[2]
130 | assert str(ev1) in descriptions[2]
131 | assert str(ev2) in descriptions[2]
132 |
133 |
134 | def test_mainfunc_ok(mock_mainfunc):
135 | result = daemon.main()
136 |
137 | assert result is None or result == os.EX_OK
138 |
139 |
140 | def test_mainfunc_exception(mock_mainfunc):
141 | _, _, inotifier = mock_mainfunc
142 | inotifier.read.side_effect = Exception
143 |
144 | result = daemon.main()
145 |
146 | assert result != os.EX_OK
147 |
--------------------------------------------------------------------------------
/tests/test_endpoints_templates.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.endpoints.templates as templates
7 | import concierge.templater
8 |
9 |
10 | @pytest.mark.parametrize(
11 | "filename", (
12 | None, "filename"))
13 | @pytest.mark.parametrize(
14 | "date", (
15 | None, "2016"))
16 | def test_make_header(filename, date):
17 | kwargs = {}
18 |
19 | if filename is not None:
20 | kwargs["rc_file"] = filename
21 | if date is not None:
22 | kwargs["date"] = date
23 |
24 | header = templates.make_header(**kwargs)
25 |
26 | if filename is None:
27 | assert "???" in header
28 | else:
29 | assert filename in header
30 |
31 | if date is not None:
32 | assert date in header
33 |
34 |
35 | def test_make_systemd_script():
36 | list(templates.make_systemd_script(concierge.templater.Templater))
37 |
--------------------------------------------------------------------------------
/tests/test_parser.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.core.exceptions as exceptions
7 | import concierge.core.lexer as lexer
8 | import concierge.core.parser as parser
9 |
10 |
11 | def is_trackable_host():
12 | assert parser.is_trackable_host("Host")
13 | assert not parser.is_trackable_host("Host-")
14 |
15 |
16 | def get_host_tokens():
17 | text = """\
18 | Host name
19 | Option 1
20 |
21 | Host 2
22 | Host 3
23 | Hello yes
24 |
25 | q 5
26 | """.strip()
27 |
28 | tokens = lexer.lex(text.split("\n"))
29 | tokens = tokens[1:]
30 |
31 | leveled_tokens = parser.get_host_tokens(1, tokens)
32 | assert len(leveled_tokens) == 4
33 | assert leveled_tokens[-1].option == "Hello"
34 |
35 |
36 | def test_parse_options_big_config_with_star_host():
37 | text = """\
38 | # Okay, rather big config but let's try to cover all cases here.
39 | # Basically, I've been trying to split it to different test cases but it
40 | # was really hard to maintain those tests. So there.
41 |
42 | Compression yes
43 | CompressionLevel 5
44 |
45 | Host m
46 | Port 22
47 |
48 | Host e v
49 | User root
50 | HostName env10
51 |
52 | Host WWW
53 | TCPKeepAlive 5
54 |
55 | Host q
56 | Protocol 2
57 |
58 | -Host x
59 | SendEnv 12
60 |
61 | Host qex
62 | Port 35
63 | ViaJumpHost env312
64 |
65 | Host *
66 | CompressionLevel 6
67 |
68 | """.strip()
69 |
70 | tokens = lexer.lex(text.split("\n"))
71 | tree = parser.parse(tokens)
72 |
73 | assert tree.name == ""
74 | assert tree.parent is None
75 | assert len(tree.hosts) == 2
76 |
77 | star_host = tree.hosts[0]
78 | assert star_host.trackable
79 | assert star_host.fullname == "*"
80 | assert star_host.options == {"Compression": ["yes"],
81 | "CompressionLevel": ["6"]}
82 |
83 | m_host = tree.hosts[1]
84 | assert m_host.trackable
85 | assert m_host.fullname == "m"
86 | assert m_host.options == {"Port": ["22"]}
87 | assert len(m_host.hosts) == 4
88 |
89 | me_host = m_host.hosts[0]
90 | assert me_host.trackable
91 | assert me_host.fullname == "me"
92 | assert me_host.options == {"Port": ["22"], "HostName": ["env10"],
93 | "User": ["root"]}
94 | assert len(me_host.hosts) == 1
95 |
96 | mewww_host = me_host.hosts[0]
97 | assert mewww_host.trackable
98 | assert mewww_host.fullname == "meWWW"
99 | assert mewww_host.options == {"Port": ["22"], "TCPKeepAlive": ["5"],
100 | "HostName": ["env10"], "User": ["root"]}
101 | assert mewww_host.hosts == []
102 |
103 | mq_host = m_host.hosts[1]
104 | assert mq_host.trackable
105 | assert mq_host.fullname == "mq"
106 | assert mq_host.options == {"Protocol": ["2"], "Port": ["22"]}
107 | assert mq_host.hosts == []
108 |
109 | mv_host = m_host.hosts[2]
110 | assert mv_host.trackable
111 | assert mv_host.fullname == "mv"
112 | assert mv_host.options == {"Port": ["22"], "HostName": ["env10"],
113 | "User": ["root"]}
114 | assert len(mv_host.hosts) == 1
115 |
116 | mvwww_host = mv_host.hosts[0]
117 | assert mvwww_host.trackable
118 | assert mvwww_host.fullname == "mvWWW"
119 | assert mvwww_host.options == {"Port": ["22"], "TCPKeepAlive": ["5"],
120 | "HostName": ["env10"], "User": ["root"]}
121 | assert mvwww_host.hosts == []
122 |
123 | mx_host = m_host.hosts[3]
124 | assert not mx_host.trackable
125 | assert mx_host.fullname == "mx"
126 | assert mx_host.options == {"SendEnv": ["12"], "Port": ["22"]}
127 | assert len(mx_host.hosts) == 1
128 |
129 | mxqex_host = mx_host.hosts[0]
130 | assert mxqex_host.trackable
131 | assert mxqex_host.fullname == "mxqex"
132 | assert mxqex_host.options == {"SendEnv": ["12"], "Port": ["35"],
133 | "ProxyCommand": ["ssh -W %h:%p env312"]}
134 | assert mxqex_host.hosts == []
135 |
136 |
137 | def test_parse_options_star_host_invariant():
138 | no_star_host = """\
139 | Compression yes
140 | CompressionLevel 6
141 | """.strip()
142 |
143 | star_host = """\
144 | Compression yes
145 |
146 | Host *
147 | CompressionLevel 6
148 | """.strip()
149 |
150 | star_host_only = """\
151 | Host *
152 | Compression yes
153 | CompressionLevel 6
154 | """.strip()
155 |
156 | no_star_host = parser.parse(lexer.lex(no_star_host.split("\n")))
157 | star_host = parser.parse(lexer.lex(star_host.split("\n")))
158 | star_host_only = parser.parse(lexer.lex(star_host_only.split("\n")))
159 |
160 | assert no_star_host.struct == star_host.struct
161 | assert no_star_host.struct == star_host_only.struct
162 |
163 |
164 | def test_parse_multiple_options():
165 | config = """\
166 |
167 | Host q
168 | User root
169 |
170 | Host name
171 | User rooter
172 |
173 | LocalForward 80 brumm:80
174 | LocalForward 443 brumm:443
175 | LocalForward 22 brumm:23
176 | """.strip()
177 |
178 | parsed = parser.parse(lexer.lex(config.split("\n")))
179 | assert sorted(parsed.hosts[1].options["LocalForward"]) == [
180 | "22 brumm:23",
181 | "443 brumm:443",
182 | "80 brumm:80"]
183 |
184 |
185 | @pytest.mark.parametrize(
186 | "empty_lines", list(range(5)))
187 | def test_nothing_to_parse(empty_lines):
188 | root = parser.parse(lexer.lex([""] * empty_lines))
189 |
190 | assert len(root.hosts) == 1
191 | assert root.hosts[0].fullname == "*"
192 | assert root.hosts[0].options == {}
193 | assert root.hosts[0].hosts == []
194 |
195 |
196 | def test_unknown_option():
197 | tokens = lexer.lex(["ASDF 1"])
198 |
199 | with pytest.raises(exceptions.ParserUnknownOption):
200 | parser.parse(tokens)
201 |
--------------------------------------------------------------------------------
/tests/test_parser_host.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.core.parser as parser
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "name", (
11 | "", "name"))
12 | @pytest.mark.parametrize(
13 | "parent", (
14 | None, "", object()))
15 | @pytest.mark.parametrize(
16 | "trackable", (
17 | True, False))
18 | def test_init(name, parent, trackable):
19 | obj = parser.Host(name, parent, trackable)
20 |
21 | assert obj.values == {}
22 | assert obj.childs == []
23 | assert obj.name == name
24 | assert obj.parent == parent
25 | assert obj.trackable == trackable
26 |
27 |
28 | def test_fullname():
29 | host1 = parser.Host("a", None)
30 | host2 = parser.Host("b", host1)
31 | host3 = parser.Host("c", host2)
32 |
33 | assert host1.name == "a"
34 | assert host1.fullname == "a"
35 |
36 | assert host2.name == "b"
37 | assert host2.fullname == "ab"
38 |
39 | assert host3.name == "c"
40 | assert host3.fullname == "abc"
41 |
42 |
43 | def test_fullname_dynamic():
44 | host1 = parser.Host("a", None)
45 | host2 = parser.Host("b", host1)
46 | host3 = parser.Host("c", host2)
47 |
48 | assert host3.fullname == "abc"
49 |
50 | host3.parent = host1
51 |
52 | assert host3.fullname == "ac"
53 |
54 |
55 | def test_options():
56 | host1 = parser.Host("a", None)
57 |
58 | assert host1.values == {}
59 | assert host1.options == {}
60 |
61 | host1["a"] = 1
62 |
63 | assert len(host1.values) == len(host1.options)
64 | for key, value in host1.values.items():
65 | assert sorted(value) == sorted(host1.options[key])
66 | assert host1.options == {"a": [1]}
67 |
68 | assert host1["a"] == [1]
69 |
70 |
71 | def test_options_several():
72 | host = parser.Host("a", None)
73 |
74 | host["a"] = 1
75 | assert host.options == {"a": [1]}
76 |
77 | host["a"] = 3
78 | assert host.options == {"a": [1, 3]}
79 |
80 | host["a"] = 2
81 | assert host.options == {"a": [1, 2, 3]}
82 |
83 |
84 | def test_options_overlap():
85 | host1 = parser.Host("a", None)
86 | host2 = parser.Host("b", host1)
87 |
88 | host1["a"] = 1
89 | host1["b"] = 2
90 | assert host2.options == {"a": [1], "b": [2]}
91 |
92 | host2["c"] = 3
93 | assert host1.options == {"a": [1], "b": [2]}
94 | assert host2.options == {"a": [1], "b": [2], "c": [3]}
95 |
96 | host2["b"] = "q"
97 | assert host1.options == {"a": [1], "b": [2]}
98 | assert host2.options == {"a": [1], "b": ["q"], "c": [3]}
99 |
100 |
101 | def test_add_host():
102 | root = parser.Host("root", None)
103 |
104 | for name in "child1", "child2", "child0":
105 | host = root.add_host(name)
106 |
107 | assert host.fullname == "root" + name
108 |
109 |
110 | def test_hosts_names():
111 | root = parser.Host("root", None)
112 |
113 | for name in "child1", "child2", "child0":
114 | root.add_host(name)
115 |
116 | names = [host.name for host in root.childs]
117 | host_names = [host.name for host in root.hosts]
118 |
119 | assert names != host_names
120 | assert sorted(names) == host_names
121 |
122 |
123 | def test_beat_coverage():
124 | root = parser.Host("root", None)
125 | repr(root)
126 | str(root)
127 |
128 | for name in "child1", "child2", "child0":
129 | root.add_host(name)
130 | repr(root)
131 | str(root)
132 |
--------------------------------------------------------------------------------
/tests/test_processor.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import concierge.core.lexer as lexer
5 | import concierge.core.parser as parser
6 | import concierge.core.processor as process
7 |
8 |
9 | CONTENT = """\
10 | Compression yes
11 |
12 | Host q
13 | Port 22
14 |
15 | -Host e
16 | Protocol 2
17 |
18 | Host h
19 | HostName hew
20 | LocalForward 22 b:22
21 | LocalForward 23 b:23
22 |
23 | Host q
24 | HostName qqq
25 | """.strip()
26 |
27 |
28 | def test_generate():
29 | tokens = lexer.lex(CONTENT.split("\n"))
30 | tree = parser.parse(tokens)
31 | new_config = list(process.generate(tree))
32 |
33 | assert new_config == [
34 | "Host qeh",
35 | " HostName hew",
36 | " LocalForward 22 b:22",
37 | " LocalForward 23 b:23",
38 | " Port 22",
39 | " Protocol 2",
40 | "",
41 | "Host qq",
42 | " HostName qqq",
43 | " Port 22",
44 | "",
45 | "Host q",
46 | " Port 22",
47 | "",
48 | "Host *",
49 | " Compression yes",
50 | ""]
51 |
52 |
53 | def test_process():
54 | assert process.process(CONTENT) == """\
55 | Host qeh
56 | HostName hew
57 | LocalForward 22 b:22
58 | LocalForward 23 b:23
59 | Port 22
60 | Protocol 2
61 |
62 | Host qq
63 | HostName qqq
64 | Port 22
65 |
66 | Host q
67 | Port 22
68 |
69 | Host *
70 | Compression yes
71 | """
72 |
--------------------------------------------------------------------------------
/tests/test_templater.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.templater as templater
7 |
8 |
9 | class Plugin(object):
10 |
11 | def __init__(self, tpl):
12 | self.templater = tpl
13 |
14 | @property
15 | def name(self):
16 | return self.templater.name
17 |
18 | def load(self):
19 | return self.templater
20 |
21 |
22 | def create_templater(name_):
23 | class Fake(templater.Templater):
24 | name = name_
25 |
26 | def render(self, content):
27 | return self.name + " " + content
28 |
29 | return Fake
30 |
31 |
32 | @pytest.fixture
33 | def mock_plugins(request, monkeypatch):
34 | templaters = [
35 | Plugin(create_templater(name))
36 | for name in ("mako", "jinja")]
37 |
38 | monkeypatch.setattr(
39 | "pkg_resources.iter_entry_points",
40 | lambda *args, **kwargs: templaters)
41 |
42 | return templaters
43 |
44 |
45 | def test_all_templaters(mock_plugins):
46 | tpls = templater.all_templaters()
47 |
48 | assert len(tpls) == 3
49 | assert tpls["dummy"] is templater.Templater
50 | assert tpls["mako"]().render("q") == "mako q"
51 | assert tpls["jinja"]().render("q") == "jinja q"
52 |
53 |
54 | def test_resolve_templater_none(mock_plugins):
55 | tpl = templater.resolve_templater("dummy")
56 |
57 | assert isinstance(tpl, templater.Templater)
58 | assert tpl.name == "dummy"
59 |
60 |
61 | def test_resolve_templater_default(mock_plugins):
62 | assert templater.resolve_templater(None).name == "mako"
63 | del mock_plugins[0]
64 |
65 | assert templater.resolve_templater(None).name == "jinja"
66 | del mock_plugins[0]
67 |
68 | assert templater.resolve_templater(None).name == "dummy"
69 |
70 |
71 | @pytest.mark.parametrize("code", ("mako", "jinja", "dummy"))
72 | def test_resolve_templater_known(mock_plugins, code):
73 | assert templater.resolve_templater(code).name == code
74 |
75 |
76 | def test_render_dummy_templater():
77 | tpl = templater.Templater()
78 |
79 | assert tpl.render("lalala") == "lalala"
80 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | import pytest
5 |
6 | import concierge.utils as utils
7 |
8 |
9 | def test_topen_write_read(ptmpdir):
10 | filename = ptmpdir.join("test")
11 | filename.write_text("TEST", "utf-8")
12 |
13 | with utils.topen(filename.strpath) as filefp:
14 | with pytest.raises(IOError):
15 | filefp.write("1")
16 | assert filefp.read() == "TEST"
17 |
18 |
19 | def test_topen_write_ok(ptmpdir):
20 | filename = ptmpdir.join("test")
21 | filename.write_text("TEST", "utf-8")
22 |
23 | with utils.topen(filename.strpath, True) as filefp:
24 | filefp.write("1")
25 |
26 | with utils.topen(filename.strpath) as filefp:
27 | assert filefp.read() == "1"
28 |
29 |
30 | @pytest.mark.parametrize(
31 | "content", (
32 | "1", "", "TEST"))
33 | def test_get_content(ptmpdir, content):
34 | filename = ptmpdir.join("test")
35 | filename.write_text(content, "utf-8")
36 |
37 | assert utils.get_content(filename.strpath) == content
38 |
39 |
40 | @pytest.mark.parametrize(
41 | "name, address", (
42 | ("linux", "/dev/log"),
43 | ("linux2", "/dev/log"),
44 | ("linux3", "/dev/log"),
45 | ("darwin", "/var/run/syslog"),
46 | ("windows", ("localhost", 514))))
47 | def test_get_syslog_address(monkeypatch, name, address):
48 | monkeypatch.setattr("sys.platform", name)
49 |
50 | assert utils.get_syslog_address() == address
51 |
52 |
53 | @pytest.mark.parametrize(
54 | "debug", (
55 | True, False))
56 | @pytest.mark.parametrize(
57 | "verbose", (
58 | True, False))
59 | @pytest.mark.parametrize(
60 | "stderr", (
61 | True, False))
62 | @pytest.mark.no_mock_log_configuration
63 | def test_configure_logging(debug, verbose, stderr):
64 | utils.configure_logging(debug, verbose, stderr)
65 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py{33,34,35}, static, metrics
3 |
4 | [testenv]
5 | usedevelop = True
6 | setenv =
7 | VIRTUAL_ENV={envdir}
8 | LANG=en_US.UTF-8
9 | LANGUAGE=en_US:en
10 | LC_ALL=C
11 | PYTHONHASHSEED=0
12 | passenv = CI TRAVIS
13 | deps =
14 | -r{toxinidir}/test-requirements.txt
15 | commands =
16 | py.test {posargs}
17 |
18 | [testenv:static]
19 | commands =
20 | flake8
21 |
22 | [testenv:metrics]
23 | commands =
24 | radon cc --average --show-closures concierge
25 | radon raw --summary concierge
26 | radon mi --show --multi concierge
27 | xenon -aA -mA -bB concierge
28 |
--------------------------------------------------------------------------------