├── .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 | --------------------------------------------------------------------------------