diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml new file mode 100644 index 0000000..01f081a --- /dev/null +++ b/.github/workflows/workflow.yaml @@ -0,0 +1,41 @@ +name: Ultest Workflow +on: [push, pull_request] +jobs: + tests: + name: tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Linting + run: ./scripts/lint + - name: Testing + run: ./scripts/test + + release: + name: release + runs-on: ubuntu-18.04 + needs: tests + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adedbd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.mypy_cache/ +__pycache__/ +.vimrc +.vim/ +tags +*coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e4e872e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +default_stages: + - commit +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/psf/black + rev: 20.8b0 + hooks: + - id: black + - repo: https://github.com/commitizen-tools/commitizen + rev: master + hooks: + - id: commitizen + stages: [commit-msg] diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..201e729 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,484 @@ +[MASTER] + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + W1203, + C0111, + R0201, + W0221, + E731, + C0330, + W0703, + C0103 + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=3 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=new + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules=survey_lib.app.cli + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format=LF + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=snake_case + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_|^test_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph=yes + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=8 + +# Maximum number of attributes for a class (see R0902). +max-attributes=12 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=3 + +# Maximum number of branch for function / method body. +max-branches=8 + +# Maximum number of locals for function / method body. +max-locals=16 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=30 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception". +overgeneral-exceptions=Exception diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..0985abb --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,7 @@ +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..17bff54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Rónán Carrigan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..653f3a0 --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +mypy = "*" +pylint = "*" +pytest = "*" +pytest-cov = "*" +black = "==20.08b" +pre-commit = "*" +commitizen = "*" + +[packages] +pynvim = "*" + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..91de297 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,556 @@ +{ + "_meta": { + "hash": { + "sha256": "3c07872aa7a55b9eb66a7cbeaf0fcfb0eedcdf55b59416a92ae50ec687a25d7a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "greenlet": { + "hashes": [ + "sha256:1023d7b43ca11264ab7052cb09f5635d4afdb43df55e0854498fc63070a0b206", + "sha256:124a3ae41215f71dc91d1a3d45cbf2f84e46b543e5d60b99ecc20e24b4c8f272", + "sha256:13037e2d7ab2145300676852fa069235512fdeba4ed1e3bb4b0677a04223c525", + "sha256:3af587e9813f9bd8be9212722321a5e7be23b2bc37e6323a90e592ab0c2ef117", + "sha256:41d8835c69a78de718e466dd0e6bfd4b46125f21a67c3ff6d76d8d8059868d6b", + "sha256:4481002118b2f1588fa3d821936ffdc03db80ef21186b62b90c18db4ba5e743b", + "sha256:47825c3a109f0331b1e54c1173d4e57fa000aa6c96756b62852bfa1af91cd652", + "sha256:5494e3baeacc371d988345fbf8aa4bd15555b3077c40afcf1994776bb6d77eaf", + "sha256:75e4c27188f28149b74e7685809f9227410fd15432a4438fc48627f518577fa5", + "sha256:97f2b01ab622a4aa4b3724a3e1fba66f47f054c434fbaa551833fa2b41e3db51", + "sha256:a34023b9eabb3525ee059f3bf33a417d2e437f7f17e341d334987d4091ae6072", + "sha256:ac85db59aa43d78547f95fc7b6fd2913e02b9e9b09e2490dfb7bbdf47b2a4914", + "sha256:be7a79988b8fdc5bbbeaed69e79cfb373da9759242f1565668be4fb7f3f37552", + "sha256:bee111161420f341a346731279dd976be161b465c1286f82cc0779baf7b729e8", + "sha256:ccd62f09f90b2730150d82f2f2ffc34d73c6ce7eac234aed04d15dc8a3023994", + "sha256:d3436110ca66fe3981031cc6aff8cc7a40d8411d173dde73ddaa5b8445385e2d", + "sha256:e495096e3e2e8f7192afb6aaeba19babc4fb2bdf543d7b7fed59e00c1df7f170", + "sha256:e66a824f44892bc4ec66c58601a413419cafa9cec895e63d8da889c8a1a4fa4a" + ], + "version": "==0.4.17" + }, + "msgpack": { + "hashes": [ + "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408", + "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8", + "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84", + "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d", + "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a", + "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322", + "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2", + "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e", + "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97", + "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0", + "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be", + "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf", + "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab", + "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08", + "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e", + "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272", + "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1", + "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140" + ], + "version": "==1.0.0" + }, + "pynvim": { + "hashes": [ + "sha256:6bc6204d465de5888a0c5e3e783fe01988b032e22ae87875912280bef0e40f8f" + ], + "index": "pypi", + "version": "==0.4.2" + } + }, + "develop": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "version": "==1.4.4" + }, + "astroid": { + "hashes": [ + "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", + "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" + ], + "markers": "python_version >= '3.5'", + "version": "==2.4.2" + }, + "attrs": { + "hashes": [ + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.2.0" + }, + "black": { + "hashes": [ + "sha256:0fc6b2f00ccd34cc444d41f24967377c2bc6d0b53f333c56376405ff6cd6f789" + ], + "index": "pypi", + "version": "==20.08b" + }, + "cfgv": { + "hashes": [ + "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", + "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" + ], + "markers": "python_full_version >= '3.6.1'", + "version": "==3.2.0" + }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==7.1.2" + }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.4.4" + }, + "commitizen": { + "hashes": [ + "sha256:469c571b993f451772fdbb5be9ff5520d99981530d2d7f6717524ccaafa17bd2", + "sha256:639b100c03c0d153c0f9357cb9571666b34cb6a046f09bc6a27a7124ffc13d21" + ], + "index": "pypi", + "version": "==2.4.1" + }, + "coverage": { + "hashes": [ + "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", + "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", + "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", + "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", + "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", + "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", + "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", + "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", + "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", + "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", + "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", + "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", + "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", + "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", + "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", + "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", + "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", + "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", + "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", + "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", + "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", + "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", + "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", + "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", + "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", + "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", + "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", + "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", + "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", + "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", + "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", + "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", + "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", + "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==5.3" + }, + "decli": { + "hashes": [ + "sha256:d3207bc02d0169bf6ed74ccca09ce62edca0eb25b0ebf8bf4ae3fb8333e15ca0", + "sha256:f2cde55034a75c819c630c7655a844c612f2598c42c21299160465df6ad463ad" + ], + "markers": "python_version >= '3.6'", + "version": "==0.5.2" + }, + "distlib": { + "hashes": [ + "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", + "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" + ], + "version": "==0.3.1" + }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, + "identify": { + "hashes": [ + "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e", + "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.5.6" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" + }, + "isort": { + "hashes": [ + "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", + "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" + ], + "markers": "python_version >= '3.6' and python_version < '4.0'", + "version": "==5.6.4" + }, + "jinja2": { + "hashes": [ + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.11.2" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.3" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.1.1" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mypy": { + "hashes": [ + "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324", + "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc", + "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802", + "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122", + "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975", + "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7", + "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666", + "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669", + "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178", + "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01", + "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea", + "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de", + "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1", + "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c" + ], + "index": "pypi", + "version": "==0.790" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "nodeenv": { + "hashes": [ + "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", + "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c" + ], + "version": "==1.5.0" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.4" + }, + "pathspec": { + "hashes": [ + "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", + "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + ], + "version": "==0.8.0" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.13.1" + }, + "pre-commit": { + "hashes": [ + "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", + "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" + ], + "index": "pypi", + "version": "==2.7.1" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c", + "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63" + ], + "markers": "python_full_version >= '3.6.1'", + "version": "==3.0.8" + }, + "py": { + "hashes": [ + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.9.0" + }, + "pylint": { + "hashes": [ + "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", + "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" + ], + "index": "pypi", + "version": "==2.6.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.4.7" + }, + "pytest": { + "hashes": [ + "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", + "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" + ], + "index": "pypi", + "version": "==6.1.1" + }, + "pytest-cov": { + "hashes": [ + "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", + "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" + ], + "index": "pypi", + "version": "==2.10.1" + }, + "pyyaml": { + "hashes": [ + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + ], + "version": "==5.3.1" + }, + "questionary": { + "hashes": [ + "sha256:338f326d3d10204dc3a21a07cfa8243f32d8d8f3d9f3de244951066c0769f2ea", + "sha256:bdbc9e9877f2b07cdf69893bb87fc798cb4992e262b6fcf702097994b82aaa3e" + ], + "markers": "python_version < '3.10' and python_version >= '3.6'", + "version": "==1.7.0" + }, + "regex": { + "hashes": [ + "sha256:0cb23ed0e327c18fb7eac61ebbb3180ebafed5b9b86ca2e15438201e5903b5dd", + "sha256:1a065e7a6a1b4aa851a0efa1a2579eabc765246b8b3a5fd74000aaa3134b8b4e", + "sha256:1a511470db3aa97432ac8c1bf014fcc6c9fbfd0f4b1313024d342549cf86bcd6", + "sha256:1c447b0d108cddc69036b1b3910fac159f2b51fdeec7f13872e059b7bc932be1", + "sha256:2278453c6a76280b38855a263198961938108ea2333ee145c5168c36b8e2b376", + "sha256:240509721a663836b611fa13ca1843079fc52d0b91ef3f92d9bba8da12e768a0", + "sha256:4e21340c07090ddc8c16deebfd82eb9c9e1ec5e62f57bb86194a2595fd7b46e0", + "sha256:570e916a44a361d4e85f355aacd90e9113319c78ce3c2d098d2ddf9631b34505", + "sha256:59d5c6302d22c16d59611a9fd53556554010db1d47e9df5df37be05007bebe75", + "sha256:6a46eba253cedcbe8a6469f881f014f0a98819d99d341461630885139850e281", + "sha256:6f567df0601e9c7434958143aebea47a9c4b45434ea0ae0286a4ec19e9877169", + "sha256:781906e45ef1d10a0ed9ec8ab83a09b5e0d742de70e627b20d61ccb1b1d3964d", + "sha256:8469377a437dbc31e480993399fd1fd15fe26f382dc04c51c9cb73e42965cc06", + "sha256:8cd0d587aaac74194ad3e68029124c06245acaeddaae14cb45844e5c9bebeea4", + "sha256:97a023f97cddf00831ba04886d1596ef10f59b93df7f855856f037190936e868", + "sha256:a973d5a7a324e2a5230ad7c43f5e1383cac51ef4903bf274936a5634b724b531", + "sha256:af360e62a9790e0a96bc9ac845d87bfa0e4ee0ee68547ae8b5a9c1030517dbef", + "sha256:b706c70070eea03411b1761fff3a2675da28d042a1ab7d0863b3efe1faa125c9", + "sha256:bfd7a9fddd11d116a58b62ee6c502fd24cfe22a4792261f258f886aa41c2a899", + "sha256:c30d8766a055c22e39dd7e1a4f98f6266169f2de05db737efe509c2fb9c8a3c8", + "sha256:c53dc8ee3bb7b7e28ee9feb996a0c999137be6c1d3b02cb6b3c4cba4f9e5ed09", + "sha256:c95d514093b80e5309bdca5dd99e51bcf82c44043b57c34594d9d7556bd04d05", + "sha256:d43cf21df524283daa80ecad551c306b7f52881c8d0fe4e3e76a96b626b6d8d8", + "sha256:d62205f00f461fe8b24ade07499454a3b7adf3def1225e258b994e2215fd15c5", + "sha256:e289a857dca3b35d3615c3a6a438622e20d1bf0abcb82c57d866c8d0be3f44c4", + "sha256:e5f6aa56dda92472e9d6f7b1e6331f4e2d51a67caafff4d4c5121cadac03941e", + "sha256:f4b1c65ee86bfbf7d0c3dfd90592a9e3d6e9ecd36c367c884094c050d4c35d04" + ], + "version": "==2020.10.23" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.15.0" + }, + "termcolor": { + "hashes": [ + "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b" + ], + "version": "==1.1.0" + }, + "toml": { + "hashes": [ + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + ], + "version": "==0.10.1" + }, + "tomlkit": { + "hashes": [ + "sha256:4e1bd6c9197d984528f9ff0cc9db667c317d8881288db50db20eeeb0f6b0380b", + "sha256:f044eda25647882e5ef22b43a1688fb6ab12af2fc50e8456cdfc751c873101cf" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.5.11" + }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "version": "==1.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "version": "==3.7.4.3" + }, + "virtualenv": { + "hashes": [ + "sha256:0ebc633426d7468664067309842c81edab11ae97fcaf27e8ad7f5748c89b431b", + "sha256:2a72c80fa2ad8f4e2985c06e6fc12c3d60d060e410572f553c90619b0f6efaf3" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.0.35" + }, + "wcwidth": { + "hashes": [ + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" + ], + "version": "==0.2.5" + }, + "wrapt": { + "hashes": [ + "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" + ], + "version": "==1.12.1" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f50ce5 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Ultest + +**Currently in beta** + +This plugin is in very early stages of development so there will likely be bugs! +If you experience any issues please open an issue with as much detail as possible i.e. error messages, file type, test runner and minimal example. +Only NeoVim is supported right as that is what I use myself. +There are no features absent from Vim that are required, so I plan to add Vim support at some stage. + + +1. [Introduction](#introduction) +2. [Features](#features) +3. [Installation](#installation) +4. [Usage](#usage) + 1. [Configuration](#configuration) + 2. [Commands](#commands) + 3. [Plug mappings](#plug-mappings) +5. [Known Issues](#known-issues) + 1. [Virtual Text](#virtual-text) +6. [Feedback](#feedback) + +## Introduction + +_The ultimate testing plugin for NeoVim_ + +Running tests should be as quick and painless as possible. +[vim-test](https://github.com/janko/vim-test) is a very powerful and extensive testing plugin, but it can be cumbersome to configure and lacks some features to make it feel like an integrated piece of your editor. +Rather than replacing vim-test altogether, Ultest makes it even better while maintaining the ability to use your existing configuration. +If you're already using vim-test then switching to Ultest is as easy as installing and... well, that's pretty much it. + +The goal behind Ultest is to make running tests as seamless as possible. + +- Tests are run individually so that any errors can be addressed individually. +- Tests are run in seperate threads (not just asynchronously on the same thread) so your Vim session will never be blocked. +- When tests are complete, results can be viewed immediately or on command. +- Utilise the existing power of vim-test by extending upon it. + +## Features + +- Easy to use interface for running tests +- Extensible and customisable +- Test result markers using signs or virtual text +- Failure outputs in a floating window + +More features are being worked on. +If you have any ideas, feel free to open an issue! + +## Installation + +**Requirements**: + +- Python >= 3.7 +- NeoVim >= 0.5 +- [Pynvim library](https://pynvim.readthedocs.io/en/latest/installation.html) +- [vim-test](https://github.com/janko/vim-test) + +These are simply what I can guarantee work, I have not tested NeoVim < 0.5 or Python < 3.7. + +Ultest can be installed as usual with your favourite plugin manager. + +For example with [dein](https://github.com/Shougo/dein.vim): +```vim +call dein#add('janko/vim-test') +call dein#add('rcarriga/vim-ultest', {"depends": "vim-test"}) +``` + +## Usage + +### Configuration + +Any vim-test configuration should carry over to Ultest. +See the vim-test documentation on further details for changing test runner and options. +If you have compatibility issues please raise an issue. + +### Commands + +- `Ultest`: Run all tests in a file. +- `UltestNearest`: Run the test closest to the cursor. +- `UltestOutput`: Show error output of the nearest test. + +These can be used manually or in an autocommand.\ +For example to run the nearest test every time a file is written: +```vim +augroup UltestRunner + au! + au BufWritePost * UltestNearest +augroup END +``` + +### Plug mappings + +- `(ultest-next-fail)`: Jump to next failed test. +- `(ultest-prev-fail)`: Jump to previous failed test. +- `(ultest-run-file)`: Run all tests in a file. +- `(ultest-run-nearest)`: Run test closest to the cursor. +- `(ultest-output-show) `: Show error output of the nearest test. +- `(ultest-output-jump) `: Show error output of the nearest test. + +Bind these to make running, navigating and analysing test results easier.\ +For example to be able to jump between failures in a test file: +```vim +nmap uj (ultest-next-fail) +nmap uk (ultest-prev-fail) +``` + +Features are able to be enabled/disabled individually. +For toggling any feature check the help: `:h ultest` + +## Known Issues + +### Virtual Text + +Using treesitter with NeoVim breaks virtual text. +This issue has been flagged with the NeoVim team but until it is fixed, signs must be used. + +## Feedback + +Feel free to open an issue for bug reports, feature requests or suggestions. +I will try address them as soon as I can! diff --git a/addon-info.json b/addon-info.json new file mode 100644 index 0000000..acb942a --- /dev/null +++ b/addon-info.json @@ -0,0 +1,3 @@ +{ + "name": "ultest" +} diff --git a/autoload/ultest/handler.vim b/autoload/ultest/handler.vim new file mode 100644 index 0000000..ba706da --- /dev/null +++ b/autoload/ultest/handler.vim @@ -0,0 +1,71 @@ + +if has("nvim") + let s:is_nvim = v:true +else + let s:is_nvim = v:false + let s:yarp = yarp#py3('ultest') +endif + +function! ultest#handler#strategy(cmd) abort + if s:is_nvim + call _ultest_strategy(a:cmd) + else + call s:yarp.call('_ultest_strategy', a:cmd) + endif +endfunction + +function! ultest#handler#run_all(file_name) abort + if s:is_nvim + call _ultest_run_all(a:file_name) + else + call s:yarp.call('_ultest_run_all', a:file_name) + endif +endfunction + +function! ultest#handler#run_nearest(file_name) abort + if s:is_nvim + call _ultest_run_nearest(a:file_name) + else + call s:yarp.call('_ultest_run_nearest', a:file_name) + endif +endfunction + +function! ultest#handler#clear_old(file_name) abort + if s:is_nvim + call _ultest_clear_old(a:file_name) + else + call s:yarp.call('_ultest_clear_old', a:file_name) + endif +endfunction + +function! ultest#handler#save_positions(file_name) abort + if s:is_nvim + call _ultest_set_positions(a:file_name) + else + call s:yarp.call('_ultest_positions', a:file_name) + endif +endfunction + +function! ultest#handler#get_positions(file_name) abort + if s:is_nvim + return _ultest_get_positions(a:file_name) + else + return s:yarp.call('_ultest_get_positions', a:file_name) + endif +endfunction + +function! ultest#handler#nearest_output(file_name, strict) abort + if s:is_nvim + return _ultest_nearest_output(a:file_name, a:strict) + else + return s:yarp.call('_ultest_nearest_output', a:file_name, a:strict) + endif +endfunction + +function! ultest#handler#test_output(file_name, test_name) + if s:is_nvim + return _ultest_get_output(a:test_name) + else + call s:yarp.call('_ultest_get_output', a:test_name) + endif +endfunction diff --git a/autoload/ultest/output.vim b/autoload/ultest/output.vim new file mode 100644 index 0000000..08079ad --- /dev/null +++ b/autoload/ultest/output.vim @@ -0,0 +1,92 @@ +augroup UltestOutputClose + autocmd! + autocmd InsertEnter,CursorMoved * call ultest#output#close() + autocmd User UltestOutputOpen call ultest#output#close() +augroup END + +function! ultest#output#open(output) abort + if empty(a:output) | return | endif + doautocmd User UltestOutputOpen + call s:OpenFloat(a:output) +endfunction + +function! ultest#output#close() abort + if !s:OutputIsOpen() + return + endif + if nvim_get_current_win() == g:ultest#output_windows[0] + return + endif + for window in g:ultest#output_windows + call s:CloseFloat(window) + endfor + let g:ultest#output_windows = [] +endfunction +function! s:OutputIsOpen() + return !empty(get(g:, "ultest#output_windows", [])) +endfunction + +function ultest#output#jumpto() abort + if !s:OutputIsOpen() + return + endif + call nvim_set_current_win(g:ultest#output_windows[0]) +endfunction + +function! s:OpenFloat(path) abort + let width = str2nr(split(system("sed 's/\x1b\[[0-9;]*m//g' ".a:path." | wc -L"))[0]) + let height = str2nr(split(system("wc -l ".a:path))[0]) + + let height = min([height, &lines/2]) + let width = min([width, &columns/2]) + + let lineNo = screenrow() + let colNo = screencol() + let vert_anchor = lineNo + height < &lines - 3 ? "N" : "S" + let hor_anchor = colNo + width < &columns + 3 ? "W" : "E" + + + let opts = { + \ 'relative': 'cursor', + \ 'row': vert_anchor == "N" ? 2 : -1, + \ 'col': hor_anchor == "W" ? 3 : -2, + \ 'anchor': vert_anchor.hor_anchor, + \ 'width': width, + \ 'height': height, + \ 'style': 'minimal' + \ } + + let out_buffer = nvim_create_buf(v:false, v:true) + let user_window = nvim_get_current_win() + let output_window = nvim_open_win(out_buffer, v:true, opts) + call termopen('less -R -Ps '.a:path) + call nvim_set_current_win(user_window) + let output_win_id = nvim_win_get_number(output_window) + call setwinvar(output_win_id, "&winhl", "Normal:Normal") + + let opts.row += vert_anchor == "N" ? -1 : 1 + let opts.height += 2 + let opts.col += hor_anchor == "W" ? -2 : 2 + let opts.width += 3 + + let top = "╭" . repeat("─", width+1) . "╮" + let mid = "│" . repeat(" ", width+1) . "│" + let bot = "╰" . repeat("─", width+1) . "╯" + let lines = [top] + repeat([mid], height) + [bot] + let s:buf = nvim_create_buf(v:false, v:true) + call nvim_buf_set_lines(s:buf, 0, -1, v:true, lines) + let border_window = nvim_open_win(s:buf, v:false, opts) + let border_win_id = nvim_win_get_number(border_window) + call setwinvar(border_win_id, "&winhl", "Normal:Normal") + call matchadd("UltestBorder", ".*",100, -1, {"window": border_window}) + let g:ultest#output_windows = [output_window, border_window] +endfunction + +function! s:CloseFloat(window) + try + let output_buffer = nvim_win_get_buf(a:window) + exec "bd! ".output_buffer + call nvim_win_close(a:window, v:true) + catch /Invalid/ + endtry +endfunction diff --git a/autoload/ultest/positions.vim b/autoload/ultest/positions.vim new file mode 100644 index 0000000..1a67b00 --- /dev/null +++ b/autoload/ultest/positions.vim @@ -0,0 +1,87 @@ +augroup TestStatusPositionUpdater + au! + au InsertLeave,BufWrite,BufEnter * call ultest#handler#clear_old(expand("%")) +augroup END + +function! ultest#positions#process(test) abort + let b:test_status_error_positions = get(b:, "test_status_error_positions", []) + if a:test["code"] + call add(b:test_status_error_positions, a:test["name"]) + endif +endfunction + +function! ultest#positions#clear(test) abort + for index in range(len(get(b:, "test_status_error_positions", []))) + if get(b:test_status_error_positions, index, "") == a:test["name"] + call remove(b:test_status_error_positions, index) + break + endif + endfor +endfunction + +function! ultest#positions#next() abort + let tests = ultest#handler#get_positions(expand("%")) + let cur_line = line(".") + let next_test = {"line": 0} + for test_name in get(b:, "test_status_error_positions", []) + let test = get(tests, test_name, {}) + if !empty(test) + let dist = test["line"] - cur_line + let prev_dist = next_test["line"] - cur_line + if dist > 0 && (prev_dist < 0 || dist < prev_dist) + let next_test = test + endif + endif + endfor + if next_test["line"] + exec "normal ".string(next_test["line"])."G" + endif +endfunction + +function! ultest#positions#prev() abort + let tests = ultest#handler#get_positions(expand("%")) + let cur_line = line(".") + let next_test = {"line": line("$") + 1} + for test_name in get(b:, "test_status_error_positions", []) + let test = get(tests, test_name, {}) + if !empty(test) + let dist = cur_line - test["line"] + let prev_dist = cur_line - next_test["line"] + if dist > 0 && (prev_dist < 0 || dist < prev_dist) + let next_test = test + endif + endif + endfor + if next_test["line"] <= line("$") + exec "normal ".string(next_test["line"])."G" + endif +endfunction + +function! ultest#positions#nearest(file, line) abort + let position = {"file": a:file,"line": a:line, "col":1} + let patterns = s:GetTestPatterns() + if empty(patterns) | return {} | endif + let nearest = test#base#nearest_test(position, patterns) + let nearest_test = get(get(nearest, "test"), 0) + if type(nearest_test) == v:t_string + let position["name"] = nearest_test + let position["line"] = get(nearest, "test_line") + return position + endif + return {} +endfunction + +function! s:GetTestPatterns() abort + let patterns = get(g:, "test#".&filetype."#patterns", {}) + if empty(patterns) + " In case file hasn't been loaded yet. + try | call call("test#".&filetype."#bad_function", []) | catch /Unknown/ | endtry + let patterns = get(g:, "test#".&filetype."#patterns", {}) + endif + return patterns +endfunction + +function! s:find_match(line, patterns) abort + let matches = map(copy(a:patterns), 'matchlist(a:line, v:val)') + return get(filter(matches, '!empty(v:val)'), 0, []) +endfunction diff --git a/autoload/ultest/signs.vim b/autoload/ultest/signs.vim new file mode 100644 index 0000000..f3128b0 --- /dev/null +++ b/autoload/ultest/signs.vim @@ -0,0 +1,51 @@ +call sign_define("test_pass", {"text":g:ultest_pass_sign, "texthl": "UltestPass"}) +call sign_define("test_fail", {"text":g:ultest_fail_sign, "texthl": "UltestFail"}) +call sign_define("test_running", {"text":g:ultest_running_sign, "texthl": "UltestRunning"}) + +function! ultest#signs#start(test) abort + call ultest#signs#unplace(a:test) + if s:UseVirtual() + call s:PlaceVirtualText(a:test, g:ultest_running_text, "UltestRunning") + else + call s:PlaceSign(a:test, "test_running") + endif +endfunction + +function! ultest#signs#process(test) abort + call ultest#signs#unplace(a:test) + if s:UseVirtual() + let text_highlight = a:test["code"] ? "UltestFail" : "UltestPass" + let text = a:test["code"] ? g:ultest_fail_text : g:ultest_pass_text + call s:PlaceVirtualText(a:test, text, text_highlight) + else + let test_icon = a:test["code"] ? "test_fail" : "test_pass" + call s:PlaceSign(a:test, test_icon) + endif +endfunction + +function! s:UseVirtual() abort + return get(g:, "ultest_virtual_text", 1) && exists("*nvim_buf_set_virtual_text") +endfunction + +function! s:PlaceSign(test, test_icon) abort + call sign_place(0, a:test.name, a:test_icon, a:test.file, {"lnum": a:test.line, "priority": 1000}) +endfunction + +function! s:PlaceVirtualText(test, text, highlight) abort + let namespace = s:GetNamespace(a:test) + call nvim_buf_set_virtual_text(0, namespace, str2nr(a:test["line"]) - 1, [[a:text, a:highlight]], {}) +endfunction + +function! ultest#signs#unplace(test) + if s:UseVirtual() + let namespace = s:GetNamespace(a:test) + call nvim_buf_clear_namespace(0, namespace, 0, -1) + else + call sign_unplace(a:test.name, {"buffer": a:test.file}) + endif +endfunction + +function! s:GetNamespace(test) + let virtual_namespace = "ultest".substitute(a:test["name"], " ", "_", "g") + return nvim_create_namespace(virtual_namespace) +endfunction diff --git a/autoload/ultest/statusline.vim b/autoload/ultest/statusline.vim new file mode 100644 index 0000000..6bf9101 --- /dev/null +++ b/autoload/ultest/statusline.vim @@ -0,0 +1,17 @@ +function! ultest#statusline#process(test) abort + call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 0) + 1) + if a:test["code"] + call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 0) + 1) + else + call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 0) + 1) + endif +endfunction + +function! ultest#statusline#remove(test) abort + call setbufvar(a:test["file"], "ultest_total", get(b:, "ultest_total", 1) - 1) + if a:test["code"] + call setbufvar(a:test["file"], "ultest_failed", get(b:, "ultest_failed", 1) - 1) + else + call setbufvar(a:test["file"], "ultest_passed", get(b:, "ultest_passed", 1) - 1) + endif +endfunction diff --git a/doc/ultest.txt b/doc/ultest.txt new file mode 100644 index 0000000..c005705 --- /dev/null +++ b/doc/ultest.txt @@ -0,0 +1,123 @@ +*ultest.txt* + *Ultest* *ultest* + +============================================================================== +CONTENTS *ultest-contents* + 1. Introduction........................................|ultest-introduction| + 2. Configuration.............................................|ultest-config| + 3. Commands................................................|ultest-commands| + 4. Highlights............................................|ultest-highlights| + 5. Mappings................................................|ultest-mappings| + +============================================================================== +INTRODUCTION *ultest-introduction* + + +The ultimate testing plugin for NeoVim + +Running tests should be as quick and painless as possible. +[vim-test](https://github.com/janko/vim-test) is a very powerful and extensive +testing plugin, but it can be cumbersome to configure and lacks some features +to make it feel like an integrated piece of your editor. Rather than replacing +vim-test altogether, Ultest makes it even better while maintaining the ability +to use your existing configuration. If you're already using vim-test then +switching to Ultest is as easy as installing and... well, that's pretty much +it. + +The goal behind Ultest is to make running tests as seamless as possible. + + * Tests are run individually so that any errors can be addressed + individually. + * Tests are run in seperate threads (not just asynchronously on the same + thread) so your Vim session will never be blocked. + * When tests are complete, results can be viewed immediately or on command. + * Utilise the existing power of vim-test by extending upon it. + +============================================================================== +CONFIGURATION *ultest-config* + + *g:ultest_positions* +Enable positions processor for tests to allow jumping between tests. + + *g:ultest_output_on_run* +Show failed outputs when completed run (int) + + *g:ultest_output_on_line* +Show failed outputs when cursor is on first line of test (int) + + *g:ultest_icons* +Use unicode icons for results signs/virtual text (int) + + *g:ultest_show_in_file* +Enable sign/virtual text processor for tests + + *g:ultest_pass_sign* +Sign for passing tests (string) + + *g:ultest_fail_sign* +Sign for failing tests (string) + + *g:ultest_running_sign* +Sign for running tests (string) + + *g:ultest_pass_text* +Virtual text for passing tests (string) + + *g:ultest_fail_text* +Virtual text for failing tests (string) + + *g:ultest_running_text* +Virtual text for passing tests (string) + + *g:ultest_custom_processors* +Custom list of receivers for test events. This is experimental and could +change! Receivers are dictionaries with any of the following keys: 'start': A +function which takes a test which has been run. 'exit': A function which takes +a test result once it has completed. 'clear': A function which takes a test +which has been removed for some reason. + +============================================================================== +COMMANDS *ultest-commands* + +:Ultest *:Ultest* + Run all tests in the current file + +:UltestNearest *:UltestNearest* + Run nearest test in the current file + +:UltestOutput *:UltestOutput* + Show the output of the nearest test in the current file + +============================================================================== +HIGHLIGHTS *ultest-highlights* + + +Define the following highlight groups to override their values by copying +these commands and changing their colours. + +hi UltestPass ctermfg=Green guifg=#96F291 + +hi UltestFail ctermfg=Red guifg=#F70067 + +hi UltestRunning ctermfg=Yellow guifg=#FFEC63 + +hi UltestBorder ctermfg=Red guifg=#F70067 + +============================================================================== +MAPPINGS *ultest-mappings* + + +(ultest-next-fail) Jump to next failed test. + +(ultest-prev-fail) Jump to previous failed test. + +(ultest-run-file) Run all tests in a file. + +(ultest-run-nearest) Run test closest to the cursor. + +(ultest-output-show) Show error output of the nearest test. + +(ultest-output-jump) Show error output of the nearest test. + + +vim:tw=78:ts=8:ft=help:norl: diff --git a/plugin/ultest.vim b/plugin/ultest.vim new file mode 100644 index 0000000..d3a9bfe --- /dev/null +++ b/plugin/ultest.vim @@ -0,0 +1,146 @@ +let s:strategy = "status" +let g:test#custom_strategies = get(g:, "test#custom#strategies", {}) +let g:test#custom_strategies[s:strategy] = function('ultest#handler#strategy') + +"" +" @section Introduction +" @order introduction config commands highlights mappings +" @stylized Ultest +" +" The ultimate testing plugin for NeoVim +" +" Running tests should be as quick and painless as possible. +" [vim-test](https://github.com/janko/vim-test) is a very powerful and extensive testing plugin, but it can be cumbersome to configure and lacks some features to make it feel like an integrated piece of your editor. +" Rather than replacing vim-test altogether, Ultest makes it even better while maintaining the ability to use your existing configuration. +" If you're already using vim-test then switching to Ultest is as easy as installing and... well, that's pretty much it. +" +" The goal behind Ultest is to make running tests as seamless as possible. +" +" * Tests are run individually so that any errors can be addressed individually. +" * Tests are run in seperate threads (not just asynchronously on the same thread) so your Vim session will never be blocked. +" * When tests are complete, results can be viewed immediately or on command. +" * Utilise the existing power of vim-test by extending upon it. + +" + +"" +" @section Highlights +" +" Define the following highlight groups to override their values by copying +" these commands and changing their colours. +" +" hi UltestPass ctermfg=Green guifg=#96F291 +" +" hi UltestFail ctermfg=Red guifg=#F70067 +" +" hi UltestRunning ctermfg=Yellow guifg=#FFEC63 +" +" hi UltestBorder ctermfg=Red guifg=#F70067 + +hi default UltestPass ctermfg=Green guifg=#96F291 +hi default UltestFail ctermfg=Red guifg=#F70067 +hi default UltestRunning ctermfg=Yellow guifg=#FFEC63 +hi default UltestBorder ctermfg=Red guifg=#F70067 + +"" +" Enable positions processor for tests to allow jumping between tests. +let g:ultest_positions = get(g:, "ultest_positions", 1) + +"" +" Show failed outputs when completed run (int) +let g:ultest_output_on_run = get(g:, "ultest_output_on_run", 1) +"" +" Show failed outputs when cursor is on first line of test (int) +let g:ultest_output_on_line = get(g:, "ultest_output_on_line", 1) + +"" +" Use unicode icons for results signs/virtual text (int) +let g:ultest_icons = get(g:, "ultest_icons", 0) + +"" +" Enable sign/virtual text processor for tests +let g:ultest_show_in_file = get(g:, "ultest_show_in_file", 1) + +"" +" Sign for passing tests (string) +let g:ultest_pass_sign = get(g:, "ultest_pass_sign", g:ultest_icons ? "●" : "O") +"" +" Sign for failing tests (string) +let g:ultest_fail_sign = get(g:, "ultest_fail_sign", g:ultest_icons ? "●" : "X") +"" +" Sign for running tests (string) +let g:ultest_running_sign = get(g:, "ultest_running_sign", g:ultest_icons ? "●" : "X") + +"" +" Virtual text for passing tests (string) +let g:ultest_pass_text = get(g:, "ultest_pass_text", g:ultest_icons? "●":"Passing") +"" +" Virtual text for failing tests (string) +let g:ultest_fail_text = get(g:, "ultest_fail_text", g:ultest_icons? "●":"Failing") +"" +" Virtual text for passing tests (string) +let g:ultest_running_text = get(g:, "ultest_running_text", g:ultest_icons? "●":"Running") + +"" +" Custom list of receivers for test events. +" This is experimental and could change! +" Receivers are dictionaries with any of the following keys: +" 'start': A function which takes a test which has been run. +" 'exit': A function which takes a test result once it has completed. +" 'clear': A function which takes a test which has been removed for some +" reason. +let g:ultest_custom_processors = get(g:, "ultest_custom_processors", []) +let g:ultest#processors = [ +\ { +\ "condition": g:ultest_show_in_file, +\ "start": "ultest#signs#start", +\ "clear": "ultest#signs#unplace", +\ "exit": "ultest#signs#process" +\ }, +\ { "condition": g:ultest_positions, +\ "clear": "ultest#positions#clear", +\ "exit": "ultest#positions#process", +\ } +\] + get(g:, "ultest_custom_processors", []) + +"" +" Run all tests in the current file +command! -bar Ultest call ultest#handler#run_all(expand("%")) + +"" +" Run nearest test in the current file +command! -bar UltestNearest call ultest#handler#run_nearest(expand("%")) + +"" +" Show the output of the nearest test in the current file +command! -bar UltestOutput call ultest#output#open(ultest#handler#nearest_output(expand("%"), v:false)) + +"" +" @section Mappings +" +" (ultest-next-fail) Jump to next failed test. +" +" (ultest-prev-fail) Jump to previous failed test. +" +" (ultest-run-file) Run all tests in a file. +" +" (ultest-run-nearest) Run test closest to the cursor. +" +" (ultest-output-show) Show error output of the nearest test. +" +" (ultest-output-jump) Show error output of the nearest test. + +nnoremap (ultest-next-fail) :call ultest#positions#next() +nnoremap (ultest-prev-fail) :call ultest#positions#prev() +nnoremap (ultest-run-all) :Ultest +nnoremap (ultest-run-nearest) :UltestNearest +nnoremap (ultest-output-show) :UltestOutput +nnoremap (ultest-output-jump) :call ultest#output#jumpto() + + +if g:ultest_output_on_line + augroup UltestOutputOnLine + au! + au CursorHold * call ultest#output#open(ultest#handler#nearest_output(expand("%"), v:true)) + augroup END +endif diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d6ef45 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +mypy +yapf +pylint +pytest +pytest-cov +pynvim +black==20.08b +pre-commit +commitizen diff --git a/rplugin/python3/ultest/__init__.py b/rplugin/python3/ultest/__init__.py new file mode 100644 index 0000000..1e3ec25 --- /dev/null +++ b/rplugin/python3/ultest/__init__.py @@ -0,0 +1,99 @@ +from importlib.util import find_spec + +import ultest.handler as handler + +try: + import vim # pylint: disable=E0401 + + HANDLER = None + + def _check_started(): + global HANDLER # pylint: disable=W0603 + if not HANDLER: + HANDLER = handler.create(vim) + + def _ultest_strategy(*args): + _check_started() + HANDLER.strategy(*args) + + def _ultest_run_all(*args): + _check_started() + HANDLER.run_all(*args) + + def _ultest_run_nearest(*args): + _check_started() + HANDLER.run_nearest(*args) + + def _ultest_clear_old(*args): + _check_started() + HANDLER.clear_old(*args) + + def _ultest_set_positions(*args): + _check_started() + HANDLER.store_positions(*args) + + def _ultest_get_positions(*args): + _check_started() + return HANDLER.get_positions(*args) + + def _ultest_nearest_output(*args): + _check_started() + return HANDLER.nearest_output(*args) + + def _ultest_get_output(*args): + _check_started() + return HANDLER.get_output(*args) + + +except ImportError: + from pynvim import Nvim, function, plugin + + @plugin + class Ultest: + def __init__(self, nvim: Nvim): + self._vim = nvim + self._handler = None + + def _check_started(self): + if not self._handler: + self._handler = handler.create(self._vim) + + @function("_ultest_strategy") + def _strategy(self, args): + self._check_started() + self._handler.strategy(*args) + + @function("_ultest_run_all") + def _run_all(self, args): + self._check_started() + self._handler.run_all(*args) + + @function("_ultest_run_nearest") + def _run_nearest(self, args): + self._check_started() + self._handler.run_nearest(*args) + + @function("_ultest_clear_old") + def _clear_old(self, args): + self._check_started() + self._handler.clear_old(*args) + + @function("_ultest_set_positions") + def _set_positions(self, args): + self._check_started() + self._handler.store_positions(*args) + + @function("_ultest_get_positions", sync=True) + def _get_positions(self, args): + self._check_started() + return self._handler.get_positions(*args) + + @function("_ultest_nearest_output", sync=True) + def _nearest_output(self, args): + self._check_started() + return self._handler.nearest_output(*args) + + @function("_ultest_get_output", sync=True) + def _get_output(self, args): + self._check_started() + return self._handler.get_output(*args) diff --git a/rplugin/python3/ultest/constants.py b/rplugin/python3/ultest/constants.py new file mode 100644 index 0000000..5cab89e --- /dev/null +++ b/rplugin/python3/ultest/constants.py @@ -0,0 +1,8 @@ +class ModelConstants: + + FILE = "file" + NAME = "name" + LINE = "line" + COL = "col" + TEST_ID = "id" + CODE = "code" diff --git a/rplugin/python3/ultest/handler/__init__.py b/rplugin/python3/ultest/handler/__init__.py new file mode 100644 index 0000000..de619ce --- /dev/null +++ b/rplugin/python3/ultest/handler/__init__.py @@ -0,0 +1,17 @@ +from pynvim import Nvim + +from ultest.handler.base import Handler +from ultest.handler.positions import Positions +from ultest.handler.results import Results +from ultest.handler.runner import Runner +from ultest.processors import Processors +from ultest.vim import VimClient + + +def create(vim: Nvim) -> Handler: + client = VimClient(vim) + processors = Processors(client) + positions = Positions(client) + runner = Runner(client, processors) + results = Results(client, processors) + return Handler(client, runner, positions, results) diff --git a/rplugin/python3/ultest/handler/base.py b/rplugin/python3/ultest/handler/base.py new file mode 100644 index 0000000..8a6408d --- /dev/null +++ b/rplugin/python3/ultest/handler/base.py @@ -0,0 +1,124 @@ +from shlex import split +from typing import List, Dict +import json +import re + +from ultest.handler.positions import Positions +from ultest.handler.results import Results +from ultest.handler.runner import Runner +from ultest.models import Test +from ultest.vim import VimClient + + +class Handler: + def __init__( + self, nvim: VimClient, runner: Runner, positions: Positions, results: Results + ): + self._vim = nvim + self._runner = runner + self._positions = positions + self._results = results + + def strategy(self, cmd: str): + """ + Only meant to be called by vim-test. + Acts as custom strategy. + + :param cmd: Command to run with file name, test name and line no appended. + :type cmd: str + """ + test_args = re.search(r"{.*}$", cmd)[0] # type: ignore + command = split(cmd[: -len(test_args)]) + test = Test(**json.loads(test_args)) + self._vim.launch(self.runner, command, test) + + def runner(self, cmd: List[str], test: Test): + result = self._runner.test(cmd, test) + self._results.handle(result) + self._vim.schedule(self._present_output, result) + + def _present_output(self, result): + if result.code and self._vim.sync_eval("get(g:, 'ultest_output_on_run', 1)"): + nearest = self._positions.nearest_stored(result.file, False) + if nearest and nearest.name == result.name: + self._vim.sync_call("ultest#output#open", result.output) + + def run_all(self, file_name: str): + """ + Run all tests in a file. + + :param file_name: File to run in. + :type file_name: str + """ + self._positions.get_all(file_name, self._runner.positions) + + def run_nearest(self, file_name: str): + """ + Run nearest test to cursor in file. + + :param file_name: File to run in. + :type file_name: str + """ + + def runner(position): + self._runner.positions([position]) + + self._positions.get_nearest(file_name, runner, False) + + def clear_old(self, file_name: str): + """ + Check for removed tests and clear results from processors. + + :param file_name: Name of file to clear results from. + :type file_name: str + """ + + def runner(positions): + self._results.clear_old(file_name, positions) + + self._positions.get_all(file_name, runner) + + def store_positions(self, file_name: str): + """Update and store the test positions for a buffer. + + :param file_name: File to update positions in. + :type file_name: str + """ + self._positions.get_all(file_name) + + def get_positions(self, file_name: str) -> Dict[str, Dict]: + """Get the known positions for a buffer, mapped by name. + + :param file_name: File to get positions from. + :type file_name: str + """ + return { + position.name: position.dict + for position in self._positions.all_stored(file_name) + } + + def nearest_output(self, file_name: str, strict: bool) -> str: + """ + Get the output of the nearest result. + + :param file_name: File to open result from. + :type file_name: str + :param strict: If true then only open when current line == line test is defined + :type strict: bool + :return: Path to output file + :rtype: str + """ + position = self._positions.nearest_stored(file_name, strict) + return self._results.output(file_name, position.name) if position else "" + + def get_output(self, file_name: str, test_name: str) -> str: + """Get the output of a test. + + :param file_name: Name of file to get result from. + :type file_name: str + :param test_name: Name of test to get output for. + :type test_name: str + :return: Path to output file + :rtype: str + """ + return self._results.output(file_name, test_name) diff --git a/rplugin/python3/ultest/handler/positions.py b/rplugin/python3/ultest/handler/positions.py new file mode 100644 index 0000000..ff78378 --- /dev/null +++ b/rplugin/python3/ultest/handler/positions.py @@ -0,0 +1,148 @@ +import re +from typing import Callable, Dict, List, Optional, Iterable +from itertools import tee, zip_longest + +from ultest.models import Position +from ultest.vim import VimClient + + +class Positions: + def __init__(self, vim: VimClient): + self._vim = vim + self._positions: Dict[str, List[Position]] = {} + self._last_runs: Dict[str, List[Positions]] = {} + + def all_stored(self, file_name: str) -> List[Position]: + """ + Get the last known test positions for a file. + Can be run on main thread. + + :param file_name: Name of file to check. + :type file_name: str + :rtype: Dict[str, Position] + """ + return self._positions.get(file_name, []) + + def get_all( + self, file_name: str, receiver: Callable[[Iterable[Position]], None] = None + ): + """ + Calculate the test positions of a file and supply the result to a callback. + This will also update the stored positions. + Must be started on main Vim thread. + The receiver will be started on a seperate thread. + + :param file_name: Name of file to get positions of + :type file_name: str + :param receiver: Function to supply result to. + :type receiver: Callable[[Iterable[Position]], None] + """ + patterns = self._vim.test.patterns(file_name) + if patterns: + lines = self._vim.buffers.contents(file_name) + + def runner(): + positions = self._calculate_positions(file_name, patterns, lines) + + to_send, to_store = tee(positions) + self._vim.launch(self._store_positions, file_name, to_store) + if receiver: + receiver(to_send) + + self._vim.launch(runner) + + def nearest_stored(self, file_name: str, strict: bool) -> Optional[Position]: + """ + Get the nearest position from the last known positions. + The nearest position is the first position found above the cursor. + + :param file_name: File to get position from. + :type file_name: str + :param strict: Only return position if on current line. + :type strict: bool + :return: Position closest to cursor. + :rtype: Optional[Position] + """ + current_line = self._vim.buffers.current_line(file_name) + positions = self.all_stored(file_name) + if not positions: + return None + first_test_line = positions[0].line + offset = current_line - first_test_line + if offset < 0: + return None + nearest = positions[offset] if offset < len(positions) else positions[-1] + return nearest if nearest.line == current_line or not strict else None + + def get_nearest( + self, + file_name: str, + receiver: Callable[[Optional[Position]], None], + strict: bool, + ): + """ + Calculate all the positions in a file and et the nearest. + The nearest position is the first position found above the cursor. + This will also update the stored positions. + + :param file_name: File to get position from. + :type file_name: str + :param strict: Only return position if on current line. + :type strict: bool + :param receiver: Function to receive positions. + :type receiver: Callable[[Optional[Position]], None] + """ + current_line = self._vim.buffers.current_line(file_name) + + def runner(positions): + nearest = self._nearest(positions, current_line, strict) + if nearest: + receiver(nearest) + + self.get_all(file_name, runner) + + def _nearest(self, positions: Iterable[Position], current_line: int, strict: bool): + last = None + for nearest in positions: + if nearest.line == current_line: + return nearest + if last and last.line < current_line < nearest.line: + return None if strict else last + last = nearest + if not strict and last and last.line < current_line: + return last + return None + + def _store_positions(self, file_name: str, positions: Iterable[Position]): + positions = list(positions) + if positions: + future_positions = positions[1:] + pos_list = [] + for current, next_pos in zip_longest(positions, future_positions): + next_line = next_pos.line if next_pos else current.line + 1 + for position in (next_line - current.line) * [current]: + pos_list.append(position) + self._positions[file_name] = pos_list + + def _calculate_positions( + self, + file_name: str, + patterns: Dict, + lines: List[str], + is_reversed: bool = False, + ) -> Iterable[Position]: + last_position = None + num_lines = len(lines) + for line_index, line in enumerate(lines): + test_name = self._find_test_name(line, patterns["test"]) + if test_name and last_position != test_name: + last_position = test_name + line_no = num_lines - line_index if is_reversed else line_index + 1 + yield Position(file=file_name, line=line_no, col=1, name=test_name) + + def _find_test_name(self, line: str, patterns: List[str]) -> Optional[str]: + matches: List[str] = [] + for pattern in patterns: + matched = re.match(pattern, line) + matches = matches + list(matched.groups()) if matched else [] + return matches[0] if matches else None diff --git a/rplugin/python3/ultest/handler/results.py b/rplugin/python3/ultest/handler/results.py new file mode 100644 index 0000000..52b7663 --- /dev/null +++ b/rplugin/python3/ultest/handler/results.py @@ -0,0 +1,62 @@ +from typing import Dict, Iterable + +from ultest.models import Position, Result, Test +from ultest.processors import Processors +from ultest.vim import VimClient + + +class Results: + def __init__(self, vim: VimClient, processors: Processors): + self._vim = vim + self._results: Dict[str, Dict[str, Result]] = {} + self._processors = processors + + def handle(self, result: Result): + """ + Store a result and pass to processors. + + :param result: Result to store. + :type result: Result + """ + self._clear(result) + if not self._results.get(result.file): + self._results[result.file] = {} + self._results[result.file][result.name] = result + self._processors.exit(result, sync=False) + + def output(self, file_name: str, test_name: str, fail_only: bool = True) -> str: + """ + Get the output of a result. + + :param file_name: Name of file to get output from. + :type test_name: str + :param test_name: Name of result to get. + :type test_name: str + :param fail_only: Only return output if result failed, defaults to True + :type fail_only: bool, optional + :return: Path to output file + :rtype: str + """ + result = self._results.get(file_name, {}).get(test_name) + if result and (result.code or not fail_only): + return result.output + return "" + + def clear_old(self, file_name: str, positions: Iterable[Position]): + """ + Clear results from storage that don't match the given positions. + + :param file_name: File to clear results from. + :type file_name: str + :param positions: Current positions for the named file. + :type positions: Iterable[Position] + """ + existing = set(position.name for position in positions) + for name, res in self._results.get(file_name, {}).items(): + if name not in existing and res.file == file_name: + self._clear(res) + + def _clear(self, test: Test): + old_result = self._results.get(test.file, {}).pop(test.name, None) + if old_result: + self._processors.clear(old_result, sync=False) diff --git a/rplugin/python3/ultest/handler/runner.py b/rplugin/python3/ultest/handler/runner.py new file mode 100644 index 0000000..eeaba84 --- /dev/null +++ b/rplugin/python3/ultest/handler/runner.py @@ -0,0 +1,61 @@ +import tempfile +import os +import os.path +import random +import subprocess +from typing import Iterable, List, Dict + +from ultest.models import Position, Result, Test +from ultest.processors import Processors +from ultest.vim import VimClient + + +class Runner: + """Handles scheduling and running tests.""" + + def __init__(self, vim: VimClient, processor: Processors): + self._vim = vim + self._processor = processor + + def test(self, cmd: List[str], test: Test) -> Result: + """ + Runs a test with the given command and returns a result + contstructed from the given test. + + :param cmd: Command arguments to run + :type cmd: List[str] + :param test: Test to build result from. + :type test: Test + :return: Result of test running. + :rtype: Result + """ + completed = subprocess.run( + cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, check=False + ) + (output_handle, output_path) = tempfile.mkstemp() + with os.fdopen(output_handle, "w") as output_file: + output_file.write(completed.stdout.decode()) + result_kwargs: Dict = { + **test.dict, + "code": completed.returncode, + "output": output_path, + } + return Result(**result_kwargs) + + def positions(self, positions: Iterable[Position]): + """ + Run a list of test positions. Each will be done in + a separate thread. + + :param positions: Positions of tests. + :type positions: Iterable[Position] + """ + for position in positions: + self._vim.schedule(self._run_position, position) + + def _run_position(self, position: Position): + test_id = random.randint(1000, 1000000) + test_kwargs: Dict = {**position.dict, "id": test_id} + test = Test(**test_kwargs) + self._processor.start(test) + self._vim.test.run(test) diff --git a/rplugin/python3/ultest/models/__init__.py b/rplugin/python3/ultest/models/__init__.py new file mode 100644 index 0000000..dcea078 --- /dev/null +++ b/rplugin/python3/ultest/models/__init__.py @@ -0,0 +1,3 @@ +from ultest.models.position import Position +from ultest.models.test import Test +from ultest.models.result import Result diff --git a/rplugin/python3/ultest/models/base.py b/rplugin/python3/ultest/models/base.py new file mode 100644 index 0000000..f64b1e2 --- /dev/null +++ b/rplugin/python3/ultest/models/base.py @@ -0,0 +1,15 @@ +import json +from dataclasses import dataclass, asdict + + +@dataclass +class BaseModel: + def __str__(self): + return self.__repr__() + + def __repr__(self): + return json.dumps(asdict(self)) + + @property + def dict(self): + return asdict(self) diff --git a/rplugin/python3/ultest/models/position.py b/rplugin/python3/ultest/models/position.py new file mode 100644 index 0000000..c4d83d5 --- /dev/null +++ b/rplugin/python3/ultest/models/position.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from ultest.models.base import BaseModel + + +@dataclass(repr=False) +class Position(BaseModel): + + name: str + file: str + line: int + col: int diff --git a/rplugin/python3/ultest/models/result.py b/rplugin/python3/ultest/models/result.py new file mode 100644 index 0000000..0de08fa --- /dev/null +++ b/rplugin/python3/ultest/models/result.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from ultest.models.test import Test + + +@dataclass(repr=False) +class Result(Test): + + code: int + output: str diff --git a/rplugin/python3/ultest/models/test.py b/rplugin/python3/ultest/models/test.py new file mode 100644 index 0000000..3ee540b --- /dev/null +++ b/rplugin/python3/ultest/models/test.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from ultest.models.position import Position + + +@dataclass(repr=False) +class Test(Position): + + id: int diff --git a/rplugin/python3/ultest/processors/__init__.py b/rplugin/python3/ultest/processors/__init__.py new file mode 100644 index 0000000..27cb33e --- /dev/null +++ b/rplugin/python3/ultest/processors/__init__.py @@ -0,0 +1,38 @@ +from typing import List, Callable, Any, Type + +from ultest.processors.processor import Processor +from ultest.processors.vimscript import VimscriptProcessor +from ultest.models import Test, Result +from ultest.vim import VimClient + +PROCESSORS_VAR = "ultest#processors" +PYTHON_PROCESSORS: List[Type[Processor]] = [] + + +class Processors: + def __init__(self, vim: VimClient): + self._vim = vim + vim_processors: List[Processor] = [ + VimscriptProcessor(spec, vim) + for spec in self._vim.sync_call("nvim_get_var", PROCESSORS_VAR) or [] + ] + python_processors = [ + constructor(vim) for constructor in PYTHON_PROCESSORS + ] # pylint: disable=E1120 + self._processors: List[Processor] = [ + processor + for processor in (vim_processors + python_processors) + if processor.condition + ] + + def clear(self, test: Test, sync: bool = True): + for processor in self._processors: + processor.clear(test, sync) + + def start(self, test: Test, sync: bool = True): + for processor in self._processors: + processor.start(test, sync) + + def exit(self, result: Result, sync: bool = True): + for processor in self._processors: + processor.exit(result, sync) diff --git a/rplugin/python3/ultest/processors/processor.py b/rplugin/python3/ultest/processors/processor.py new file mode 100644 index 0000000..3374e63 --- /dev/null +++ b/rplugin/python3/ultest/processors/processor.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod, abstractproperty + +from ultest.models import Test, Result +from ultest.vim import VimClient + + +class Processor(ABC): + def __init__(self, vim: VimClient): + self._vim = vim + + @abstractproperty + def condition(self) -> bool: + """ + Check a condition to determine if this processor is being used. + + @rtype: bool + """ + + @abstractmethod + def clear(self, test: Test, sync: bool = True): + """ + Clear a test from a processor. + + :param test: Test to clear + :type test: Test + :param sync: Run synchronously (Must be called from main thread), defaults to True + :type sync: bool, optional + """ + + @abstractmethod + def start(self, test: Test, sync: bool = True): + """ + Pass a test to processor on starting test. + + :param test: Test to clear + :type test: Test + :param sync: Run synchronously (Must be called from main thread), defaults to True + :type sync: bool, optional + """ + + @abstractmethod + def exit(self, result: Result, sync: bool = True): + """ + Pass a test to processor on test finishing. + + :param result: Test to clear + :type result: Result + :param sync: Run synchronously (Must be called from main thread), defaults to True + :type sync: bool, optional + """ diff --git a/rplugin/python3/ultest/processors/vimscript.py b/rplugin/python3/ultest/processors/vimscript.py new file mode 100644 index 0000000..24a6475 --- /dev/null +++ b/rplugin/python3/ultest/processors/vimscript.py @@ -0,0 +1,34 @@ +from typing import Dict + +from ultest.vim import VimClient +from ultest.processors.processor import Processor + +PROCESSORS_VAR = "ultest#processors" +CONDITION = "condition" +START = "start" +CLEAR = "clear" +EXIT = "exit" + + +class VimscriptProcessor(Processor): + def __init__(self, spec: Dict, vim: VimClient): + self._spec = spec + super().__init__(vim) + + @property + def condition(self) -> bool: + return self._spec.get(CONDITION, True) + + def clear(self, test, sync: bool = True): + self._pass_to_processor(CLEAR, test, sync=sync) + + def start(self, test, sync: bool = True): + self._pass_to_processor(START, test, sync=sync) + + def exit(self, test, sync: bool = True): + self._pass_to_processor(EXIT, test, sync=sync) + + def _pass_to_processor(self, func: str, test, sync=True): + caller = self._vim.sync_call if sync else self._vim.call + if self._spec.get(func): + caller(self._spec[func], test) # type: ignore diff --git a/rplugin/python3/ultest/vim/__init__.py b/rplugin/python3/ultest/vim/__init__.py new file mode 100644 index 0000000..fedd2fd --- /dev/null +++ b/rplugin/python3/ultest/vim/__init__.py @@ -0,0 +1 @@ +from .client import VimClient diff --git a/rplugin/python3/ultest/vim/base/__init__.py b/rplugin/python3/ultest/vim/base/__init__.py new file mode 100644 index 0000000..57c790e --- /dev/null +++ b/rplugin/python3/ultest/vim/base/__init__.py @@ -0,0 +1 @@ +from .client import BaseVimClient diff --git a/rplugin/python3/ultest/vim/base/client.py b/rplugin/python3/ultest/vim/base/client.py new file mode 100644 index 0000000..5932ec5 --- /dev/null +++ b/rplugin/python3/ultest/vim/base/client.py @@ -0,0 +1,141 @@ +from typing import Any, Callable, Optional, List + +from pynvim import Nvim +from .threader import Threader + + +class BaseVimClient: + def __init__(self, vim: Nvim): + self._vim = vim + self._threader = Threader() + self._is_nvim = self.sync_call("has", "nvim") + + @property + def is_nvim(self) -> bool: + return self._is_nvim + + def message(self, message, sync=False): + if not isinstance(message, str) or not message.endswith("\n"): + message = str(message) + "\n" + if sync: + self._vim.out_write(message) + else: + self.schedule(self._vim.out_write, message) + + def schedule(self, func: Callable, *args, **kwargs) -> None: + """ + Schedule a function to be called on Vim thread. + + :param func: Function to run. + :type func: Callable + :param *args: Positional args for function. + :param **kwargs: Keywords args for function. + """ + self._vim.async_call(func, *args, **kwargs) + + def launch(self, func: Callable, *args, **kwargs) -> None: + """ + Launch a function to be run on a separate thread. + + :param func: Function to run. + :type func: Callable + :param *args: Positional args for function. + :param **kwargs: Keywords args for function. + """ + runner = lambda: func(*args, **kwargs) + self._threader.run(runner)() + + def command( + self, + command: str, + *args, + callback: Optional[Callable[[List[str]], None]] = None, + **kwargs, + ): + """ + Call a Vim command asynchronously. This can be called from a thread to be + scheduled on the main Vim thread. + Args are supplied first. Kwargs are supplied after in the format "name=value" + + :param command: Command to run + :type command: str + :param callback: Function to supply resulting output to. + :type callback: Optional[Callable] + """ + runner = ( + lambda: callback(self.sync_command(command, *args, **kwargs)) + if callback + else self.sync_command(command, *args, **kwargs) + ) + self.schedule(runner) + + def sync_command(self, command: str, *args, **kwargs) -> List[str]: + """ + Call a Vim command. + Args are supplied first. Kwargs are supplied after in the format "name=value" + + :param command: Command to run + :type command: str + """ + expr = self.construct_command(command, *args, **kwargs) + output = self._vim.command_output(expr) + return output.splitlines() if output else [] + + def construct_command(self, command, *args, **kwargs): + args_str = " ".join(f"{arg}" for arg in args) + kwargs_str = " ".join(f" {name}={val}" for name, val in kwargs.items()) + return f"{command} {args_str} {kwargs_str}" + + def call(self, func: str, *args, callback: Optional[Callable] = None) -> None: + """ + Call a vimscript function asynchronously. This can be called + from a different thread to main Vim thread. + + :param func: Name of function to call. + :type func: str + :param args: Arguments for the function. + :param callback: Callback to send result of function to, defaults to None + :type callback: Optional[Callable] + :rtype: None + """ + runner = ( + lambda: callback(self.sync_call(func, *args)) + if callback + else self.sync_call(func, *args) + ) + self.schedule(runner) + + def sync_call(self, func: str, *args) -> Any: + """ + Call a vimscript function from the main Vim thread. + + :param func: Name of function to call. + :type func: str + :param args: Arguments for the function. + :return: Result of function call. + :rtype: Any + """ + expr = self.construct_function(func, *args) + return self._eval(expr) + + def sync_eval(self, expr: str) -> Any: + return self._vim.eval(expr) + + def construct_function(self, func: str, *args): + func_args = ", ".join(self._convert_arg(arg) for arg in args) + return f"{func}({func_args})" + + def _eval(self, expr: str): + return self._vim.eval(expr) + + def _convert_arg(self, arg): + if isinstance(arg, str) and self._needs_quotes(arg): + return f"'{arg}'" + if isinstance(arg, bool): + arg = 1 if arg else 0 + return str(arg) + + def _needs_quotes(self, arg: str) -> bool: + if not any(char in arg for char in "\"'("): + return not (len(arg) == 2 and arg[1] == ":") + return False diff --git a/rplugin/python3/ultest/vim/base/threader.py b/rplugin/python3/ultest/vim/base/threader.py new file mode 100644 index 0000000..bbef8e6 --- /dev/null +++ b/rplugin/python3/ultest/vim/base/threader.py @@ -0,0 +1,36 @@ +from queue import Queue +from threading import Thread +from typing import Callable, List + + +class Threader: + def __init__(self): + self._queue: "Queue[Callable]" = Queue() + self._threads: List[Thread] = [] + self._running = True + self._start_workers(2) + + def set_threads(self, num: int): + self._start_workers(num) + + def run(self, func: Callable): + threaded = lambda: self._queue.put(func) + return threaded if self._threads else func + + def _stop_workers(self): + self._running = False + for thread in self._threads: + thread.join() + + def _start_workers(self, max_threads): + self._stop_workers() + self._running = True + for _ in range(max_threads): + thread = Thread(target=self._worker, daemon=True) + thread.start() + self._threads.append(thread) + + def _worker(self): + while self._running: + func = self._queue.get() + func() diff --git a/rplugin/python3/ultest/vim/buffers/__init__.py b/rplugin/python3/ultest/vim/buffers/__init__.py new file mode 100644 index 0000000..9390a13 --- /dev/null +++ b/rplugin/python3/ultest/vim/buffers/__init__.py @@ -0,0 +1 @@ +from .client import BufferClient diff --git a/rplugin/python3/ultest/vim/buffers/client.py b/rplugin/python3/ultest/vim/buffers/client.py new file mode 100644 index 0000000..b5a90b3 --- /dev/null +++ b/rplugin/python3/ultest/vim/buffers/client.py @@ -0,0 +1,53 @@ +from typing import List, Union +from ..base import BaseVimClient + + +class BufferClient: + def __init__(self, vim: BaseVimClient): + self._vim = vim + + def contents(self, buffer: Union[str, int], end_line: int = None) -> List[str]: + return self._vim.sync_call("getbufline", buffer, 1, end_line or "$") + + def current_number(self): + return self.number("%") + + def number(self, buffer: Union[int, str]): + return self._vim.sync_call("bufnr", buffer) + + def clear_contents(self, buffer: Union[str, int]): + self._vim.sync_call("deletebufline", buffer, 1, "$") + + def set_lines( + self, buffer: Union[str, int], data: List[str], start: Union[int, str] = None + ): + if start is None: + self.clear_contents(buffer) + start = 1 + else: + start = int(start) + for line_no, line in enumerate(data): + self._vim.sync_call("setbufline", buffer, start + line_no, line) + + def get_property(self, buffer: Union[str, int], prop: str) -> str: + return self.get_var(buffer, f"&{prop}") + + def set_property(self, buffer: Union[str, int], prop: str, value): + self.set_var(buffer, f"&{prop}", value) + + def get_var(self, buffer: Union[str, int], prop: str) -> str: + return self._vim.sync_call("getbufvar", buffer, prop) + + def set_var(self, buffer: Union[str, int], prop: str, value): + self._vim.sync_call("setbufvar", buffer, prop, value) + + def create(self, buf_name: str = "") -> int: + return int(self._vim.sync_call("bufadd", buf_name)) + + def current_line(self, buffer: Union[str, int] = None): + if buffer: + buf_info = self._vim.sync_eval(f"getbufinfo('{buffer}')[0]") + line_num = buf_info.get("lnum") + else: + line_num = self._vim.sync_call("line", ".") + return line_num diff --git a/rplugin/python3/ultest/vim/client.py b/rplugin/python3/ultest/vim/client.py new file mode 100644 index 0000000..a23c736 --- /dev/null +++ b/rplugin/python3/ultest/vim/client.py @@ -0,0 +1,32 @@ +from pynvim import Nvim +from .base import BaseVimClient +from .test import TestClient +from .buffers import BufferClient + + +class VimClient: + """ + Client to interface with vim functions dealing with multi threading. + """ + + def __init__(self, vim: Nvim): + self._vim = vim + self._base = BaseVimClient(vim) + self._test = TestClient(self._base) + self._buffers = BufferClient(self._base) + + self.message = self._base.message + self.schedule = self._base.schedule + self.launch = self._base.launch + self.call = self._base.call + self.sync_call = self._base.sync_call + self.command = self._base.construct_command + self.sync_eval = self._base.sync_eval + + @property + def buffers(self) -> BufferClient: + return self._buffers + + @property + def test(self) -> TestClient: + return self._test diff --git a/rplugin/python3/ultest/vim/constants.py b/rplugin/python3/ultest/vim/constants.py new file mode 100644 index 0000000..dde65cf --- /dev/null +++ b/rplugin/python3/ultest/vim/constants.py @@ -0,0 +1,50 @@ +class HightlightConstants: + + HI_CMD = "highlight" + DEFAULT = "default" + CTERM = "cterm" + CTERMFG = CTERM + "fg" + CTERMBG = CTERM + "bg" + GUI = "gui" + GUIFG = GUI + "fg" + GUIBG = GUI + "bg" + NONE = "none" + ATTRS = "attrs" + CLEAR = "clear" + + +class WindowConstants: + + RELATIVE = "relative" + WIDTH = "width" + HEIGHT = "height" + ROW = "row" + COL = "col" + ANCHOR = "anchor" + LINE = "line" + POS = "pos" + MINWIDTH = "minwidth" + MAXWIDTH = "maxwidth" + MINHEIGHT = "minheight" + MAXHEIGHT = "maxheight" + BOTLEFT = "botleft" + BOTRIGHT = "botright" + TOPLEFT = "topleft" + TOPRIGHT = "topright" + NW = "NW" + NE = "NE" + SE = "SE" + SW = "SW" + CURSOR = "cursor" + PEDIT = "pedit" + PCLOSE = "pclose" + WINBUFNR = "winbufnr" + PREVIEWWINDOW = "previewwindow" + + +class AutocommandConstants: + + ONCE = "++once" + NESTED = "++nested" + AUTOCMD = "autocmd" + AUGROUP = "augroup" diff --git a/rplugin/python3/ultest/vim/test.py b/rplugin/python3/ultest/vim/test.py new file mode 100644 index 0000000..a55b57b --- /dev/null +++ b/rplugin/python3/ultest/vim/test.py @@ -0,0 +1,76 @@ +import re +from typing import Dict, List + +from ultest.models import Test +from .base import BaseVimClient + +REGEX_CONVERSIONS = {r"\\v": "", r"%\((.*?)\)": r"(?:\1)"} + + +class TestClient: + """ + Helper functions to interact with vim-test + """ + + def __init__(self, vim: BaseVimClient): + self._vim = vim + self._patterns: Dict[str, Dict[str, List[str]]] = {} + + def runner(self, file_name: str): + return self._vim.sync_call("test#determine_runner", file_name) + + def build_position(self, runner: str, test: Test): + return self._vim.sync_call("test#base#build_position", runner, "nearest", test) + + def prepare_options(self, runner: str, args: List): + return self._vim.sync_call("test#base#options", runner, args, "nearest") + + def run(self, test: Test): + runner = self.runner(test.file) + base_args = self.build_position(runner, test) + args = base_args + [str(test).replace("'", r"\'")] + options = self.prepare_options(runner, args) + self._vim.call("test#execute", runner, options, "status") + + def patterns(self, file_name: str): + runner = self._vim.sync_call("test#determine_runner", file_name) + if not runner: + return {} + file_type = runner.split("#")[0] + known_patterns = self._patterns.get(file_type) + if known_patterns: + return known_patterns + vim_patterns = self._get_vim_patterns(file_type) + patterns = self._convert_patterns(vim_patterns) + self._patterns[file_type] = patterns + return patterns + + def _get_vim_patterns(self, file_type: str) -> Dict[str, List[str]]: + vim_patterns = self._vim.sync_eval(f"get(g:,'test#{file_type}#patterns')") + if not vim_patterns: + try: + self._vim.sync_call(f"test#{file_type}#noop") + except Exception: + vim_patterns = self._vim.sync_eval( + f"get(g:,'test#{file_type}#patterns')" + ) + if not isinstance(vim_patterns, dict): + return {} + return vim_patterns + + def _convert_patterns(self, vim_patterns: Dict[str, List[str]]): + return { + "test": [ + self._convert_regex(pattern) for pattern in vim_patterns.get("test", "") + ], + "namespace": [ + self._convert_regex(pattern) + for pattern in vim_patterns.get("namespace", "") + ], + } + + def _convert_regex(self, vim_regex: str) -> str: + regex = vim_regex.replace("\\" * 2, r"\\") + for pattern, repl in REGEX_CONVERSIONS.items(): + regex = re.sub(pattern, repl, regex) + return regex diff --git a/scripts/check-commits b/scripts/check-commits new file mode 100755 index 0000000..1d2fb6e --- /dev/null +++ b/scripts/check-commits @@ -0,0 +1,3 @@ +#!/bin/bash + +cz check --rev-range master..HEAD diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..1f60b41 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/bin/sh + +PYTHON_DIR="rplugin/python3/ultest" + +pylint ${PYTHON_DIR} +mypy ${PYTHON_DIR} +black --check ${PYTHON_DIR} diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..2f31a29 --- /dev/null +++ b/scripts/test @@ -0,0 +1,10 @@ +#!/bin/sh + +PYTHON_DIR="rplugin/python3/ultest" + +pytest \ + --cov-branch \ + --cov=${PYTHON_DIR} \ + --cov-report xml:coverage/coverage.xml \ + --cov-report term \ + --cov-report html:coverage diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8891fc6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[mypy] +warn_unused_ignores=True +warn_redundant_casts=True +ignore_missing_imports=True diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..d397526 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,2 @@ +def test_init(): + assert True == True