diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..b99b88f --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,10 @@ +version: "2" +plugins: + pep8: + enabled: true + bandit: + enabled: true +checks: + method-complexity: + config: + threshold: 7 diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..31da458 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,43 @@ +name: Github CI + +on: [pull_request] + +jobs: + linter: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install Python dependencies + run: make dependencies + - name: Lint with pylint + run: make lint + + unit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install Python dependencies + run: make dependencies + - name: Prepare CodeClimate + run: | + curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + chmod +x ./cc-test-reporter + ./cc-test-reporter before-build + - name: Unit test with Django test suite + run: make unit + - name: Report coverage + run: | + pipenv run coverage xml + GIT_BRANCH=$GITHUB_HEAD_REF GIT_COMMIT_SHA=$(git rev-parse origin/$GITHUB_HEAD_REF) ./cc-test-reporter after-build --debug -t coverage.py --exit-code $? + env: + CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} diff --git a/.gitignore b/.gitignore index 894a44c..92d32ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Distribution / packaging .Python +env/ build/ develop-eggs/ dist/ @@ -19,11 +20,9 @@ lib64/ parts/ sdist/ var/ -wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -43,9 +42,7 @@ htmlcov/ .cache nosetests.xml coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ +*cover # Translations *.mo @@ -53,15 +50,6 @@ coverage.xml # Django stuff: *.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy # Sphinx documentation docs/_build/ @@ -69,36 +57,4 @@ docs/_build/ # PyBuilder target/ -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ +\.idea/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..aa785d2 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,504 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore= + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# 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 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +#load-plugins= + +# 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 + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +# confidence=HIGH + +# 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=missing-docstring, + no-self-use, + too-many-arguments, + too-few-arguments, + too-few-public-methods, + invalid-name, + too-many-ancestors, + unused-argument, + no-else-return, + protected-access, + arguments-differ, + no-member, + attribute-defined-outside-init, + method-hidden, + bad-super-call, + no-value-for-parameter, + logging-fstring-interpolation, + logging-format-interpolation, + too-many-locals, + too-many-statements, + cyclic-import, + duplicate-code + +# 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= + + +[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=colorized + +# Tells whether to display a full report or only the messages. +reports=yes + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# 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 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + 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= + +# 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= + +# 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= + +# 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 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.* + +# 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= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# 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=500 + +# 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=trailing-comma, + 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=no + + +[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 + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# 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, + m, + n, + dt, + pk, + qs, + e, + ex, + _ + +# 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=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# 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=^_ + +# 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 + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[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= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[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=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# 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=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..f280719 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8.1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5302350 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +dependencies: + @pip install -U pip + @pip install pipenv --upgrade + @pipenv install --dev --skip-lock + +update: + @pipenv clean + @pipenv lock --clear + @pipenv sync + +test: + @make check + @make lint + @make unit + +check: + @pipenv check + +lint: + @echo "Checking code style ..." + @pipenv run pylint tsuru tests + +unit: + @echo "Running unit tests ..." + ENV=test pipenv run pytest --cov=tsuru --cov-report xml + +clean: + @printf "Deleting dist files" + @rm -rf dist .coverage build/ tsuru.egg-info/ + +publish: + @make clean + @printf "\nPublishing lib" + @pipenv run python setup.py build sdist + @pipenv run twine upload dist/* + @make clean + +setup: + @pipenv run python setup.py develop + +.PHONY: lint publish clean unit test dependencies setup diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..464e98d --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = ">=2.20.0, <3" + +[dev-packages] +pylint = '*' +httpretty = '<1' +nose = '>=1,<2' +twine = "*" + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..51d79cf --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,249 @@ +{ + "_meta": { + "hash": { + "sha256": "eb728712b9f1e00f1914c0bfbad61a45a240b41f85abfd8e979812b1be1c5e36" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "urllib3": { + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "version": "==1.25.9" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", + "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" + ], + "version": "==2.3.3" + }, + "bleach": { + "hashes": [ + "sha256:cc8da25076a1fe56c3ac63671e2194458e0c4d9c7becfd52ca251650d517903c", + "sha256:e78e426105ac07026ba098f04de8abe9b6e3e98b5befbf89b51a5ef0a4292b03" + ], + "version": "==3.1.4" + }, + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "docutils": { + "hashes": [ + "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", + "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" + ], + "version": "==0.16" + }, + "httpretty": { + "hashes": [ + "sha256:66216f26b9d2c52e81808f3e674a6fb65d4bf719721394a1a9be926177e55fbe" + ], + "index": "pypi", + "version": "==0.9.7" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "version": "==4.3.21" + }, + "keyring": { + "hashes": [ + "sha256:197fd5903901030ef7b82fe247f43cfed2c157a28e7747d1cfcf4bc5e699dd03", + "sha256:8179b1cdcdcbc221456b5b74e6b7cfa06f8dd9f239eb81892166d9223d82c5ba" + ], + "version": "==21.2.0" + }, + "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" + ], + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "index": "pypi", + "version": "==1.3.7" + }, + "pkginfo": { + "hashes": [ + "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", + "sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32" + ], + "version": "==1.5.0.1" + }, + "pygments": { + "hashes": [ + "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", + "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + ], + "version": "==2.6.1" + }, + "pylint": { + "hashes": [ + "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", + "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" + ], + "index": "pypi", + "version": "==2.4.4" + }, + "readme-renderer": { + "hashes": [ + "sha256:cbe9db71defedd2428a1589cdc545f9bd98e59297449f69d721ef8f1cfced68d", + "sha256:cc4957a803106e820d05d14f71033092537a22daa4f406dfbdd61177e0936376" + ], + "version": "==26.0" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", + "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" + ], + "version": "==0.9.1" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "tqdm": { + "hashes": [ + "sha256:00339634a22c10a7a22476ee946bbde2dbe48d042ded784e4d88e0236eca5d81", + "sha256:ea9e3fd6bd9a37e8783d75bfc4c1faf3c6813da6bd1c3e776488b41ec683af94" + ], + "version": "==4.45.0" + }, + "twine": { + "hashes": [ + "sha256:c1af8ca391e43b0a06bbc155f7f67db0bf0d19d284bfc88d1675da497a946124", + "sha256:d561a5e511f70275e5a485a6275ff61851c16ffcb3a95a602189161112d9f160" + ], + "index": "pypi", + "version": "==3.1.1" + }, + "urllib3": { + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "version": "==1.25.9" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" + } + } +} diff --git a/README.md b/README.md deleted file mode 100644 index cde3a16..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# tsuru-py -Python client for Tsuru API diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..23a247a --- /dev/null +++ b/README.rst @@ -0,0 +1,55 @@ +.. image:: https://img.shields.io/badge/python-3-blue.svg + :target: https://www.python.org/ + :alt: Python 3 + +.. image:: https://api.codeclimate.com/v1/badges/471b178f14d470337668/maintainability + :target: https://codeclimate.com/github/edukorg/tsuru-py/maintainability + :alt: Maintainability + +.. image:: https://api.codeclimate.com/v1/badges/471b178f14d470337668/test_coverage + :target: https://codeclimate.com/github/edukorg/tsuru-py/test_coverage + :alt: Test Coverage + +.. image:: https://img.shields.io/pypi/v/tsuru.svg?color=blue :alt: PyPI + +.. image:: https://img.shields.io/pypi/dm/tsuru.svg :alt: PyPI - Downloads + + +================ +Tsuru API Client +================ + +Python client for the `Tsuru `_ API. + +************ +Installation +************ + +.. code-block:: bash + + pip install tsuru + + +***** +Setup +***** + +The following environment variables must be set prior to using the lib. + +``TSURU_URL``, with port (if necessary) and without trailing dash. Example: http://my-tsuru.domain.vpc:8080 + +``TSURU_USERNAME`` + +``TSURU_PASSWORD`` + + +***** +Usage +***** + +.. code-block:: python + + from tsuru import App + + for app in App.list() + print(app.name) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b468433 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +[pycodestyle] +ignore = E226,E302,E41 +max-line-length = 120 +statistics = True + +[nosetests] +verbosity=2 +with-coverage=1 +cover-erase=1 +cover-package=tsuru +cover-inclusive=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4ceff06 --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2019> eduK +# +# 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. +import io +import os +from setuptools import setup, find_packages + + +def read_version(): + from tsuru import version + return version + + +def local_file(*f): + return io.open(os.path.join(os.path.dirname(__file__), *f), encoding='utf-8').read() + + +install_requires = ['six'] +tests_requires = ['nose', 'coverage', 'httpretty'] + + +setup( + name='tsuru', + version=read_version(), + description='Tsuru API client for Python', + long_description=local_file('README.rst'), + author='eduK', + author_email='pd@eduk.com.br', + zip_safe=False, + packages=find_packages(exclude=['*tests*']), + tests_require=tests_requires, + install_requires=install_requires, + license='MIT', + test_suite='nose.collector', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Testing' + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/tests_client.py b/tests/unit/tests_client.py new file mode 100644 index 0000000..431b768 --- /dev/null +++ b/tests/unit/tests_client.py @@ -0,0 +1,147 @@ +import json +import unittest + +import httpretty +from unittest.mock import patch + +from tsuru import client + + +class HTTPPrettyTestMixin: + API_URL = 'https://my-tsuru:8080' + + def setUp(self): + self.patcher = patch('tsuru.client.TsuruClient._URL', self.API_URL) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def patch_token(self, token='42'): + return patch('tsuru.client.TsuruClient._TOKEN', token) + + @property + def request_count(self): + return len(httpretty.httpretty.latest_requests) + + @property + def latest_request_header(self): + last_request = httpretty.httpretty.last_request + return last_request.headers + + +class TestTsuruClient(HTTPPrettyTestMixin, unittest.TestCase): + @httpretty.activate + def test_request_error(self): + httpretty.register_uri( + httpretty.GET, + f'{self.API_URL}/potato', + body="Find the best daily deals", + status=404, + ) + + with self.patch_token(): + with self.assertRaises(Exception): + client.TsuruClient.get( + resource='potato', + ) + + self.assertEqual(1, self.request_count) + self.assertEqual('42', self.latest_request_header['Authentication']) + + @httpretty.activate + def test_list_resource(self): + httpretty.register_uri( + httpretty.GET, + f'{self.API_URL}/potato', + body=json.dumps([{'answer': 314}]), + ) + + with self.patch_token(): + data = client.TsuruClient.get( + resource='potato', + ) + + self.assertEqual(1, self.request_count) + self.assertEqual('42', self.latest_request_header['Authentication']) + self.assertEqual([{'answer': 314}], data) + + @httpretty.activate + def test_detail_resource(self): + httpretty.register_uri( + httpretty.GET, + f'{self.API_URL}/potato/314', + body=json.dumps({'answer': 314}), + ) + + with self.patch_token(): + data = client.TsuruClient.get( + resource='potato', + pk=314, + ) + + self.assertEqual(1, self.request_count) + self.assertEqual('42', self.latest_request_header['Authentication']) + self.assertEqual({'answer': 314}, data) + + @httpretty.activate + def test_bound_list_from_resource(self): + httpretty.register_uri( + httpretty.GET, + f'{self.API_URL}/potato/314/answers', + body=json.dumps([{'answer': 666}]), + ) + + with self.patch_token(): + data = client.TsuruClient.get( + from_resource='potato', + from_pk=314, + resource='answers', + ) + + self.assertEqual(1, self.request_count) + self.assertEqual('42', self.latest_request_header['Authentication']) + self.assertEqual([{'answer': 666}], data) + + @httpretty.activate + def test_bound_list_from_resource_without_id(self): + with self.patch_token(): + with self.assertRaises(UnboundLocalError): + client.TsuruClient.get( + from_resource='potato', + resource='answers', + ) + + self.assertEqual(0, self.request_count) + + @httpretty.activate + def test_bound_detail_from_resource(self): + httpretty.register_uri( + httpretty.GET, + f'{self.API_URL}/potato/314/answers/666', + body=json.dumps([{'answer': 666}]), + ) + + with self.patch_token(): + data = client.TsuruClient.get( + from_resource='potato', + from_pk=314, + resource='answers', + pk=666, + ) + + self.assertEqual(1, self.request_count) + self.assertEqual('42', self.latest_request_header['Authentication']) + self.assertEqual([{'answer': 666}], data) + + @httpretty.activate + def test_bound_detail_from_resource_without_id(self): + with self.patch_token(): + with self.assertRaises(UnboundLocalError): + client.TsuruClient.get( + from_resource='potato', + resource='answers', + pk=666, + ) + + self.assertEqual(0, self.request_count) diff --git a/tests/unit/tests_models/__init__.py b/tests/unit/tests_models/__init__.py new file mode 100644 index 0000000..c663a37 --- /dev/null +++ b/tests/unit/tests_models/__init__.py @@ -0,0 +1,48 @@ +import json +import os + +from unittest.mock import patch +from requests import HTTPError, Response + +from tests.unit.tests_client import HTTPPrettyTestMixin + + +class ModelTestMixin(HTTPPrettyTestMixin): + MODEL_KLASS = None + + def sample_list(self): + return self._read_sample(action='list') + + def sample_detail(self): + return self._read_sample(action='detail') + + def _read_sample(self, action): + path = os.path.dirname(__file__) + filename = f'{self.MODEL_KLASS._RESOURCE_NAME}.{action}.json' + with open(os.path.join(path, 'samples', filename), 'r') as f: + return json.loads(f.read()) + + def patch_get(self, data): + return patch('tsuru.client.TsuruClient.get', return_value=data) + + def patch_get_error(self, content=None, status_code=404): + response = Response() + response.status_code = status_code + response._content = content + error = HTTPError(response=response) + return patch('tsuru.client.TsuruClient.get', side_effect=error) + + def test_fields(self): + raise NotImplementedError() + + def test_invalid_field(self): + raise NotImplementedError() + + def test_list(self): + raise NotImplementedError() + + def test_detail(self): + raise NotImplementedError() + + def test_not_found(self): + raise NotImplementedError() diff --git a/tests/unit/tests_models/samples/apps.detail.json b/tests/unit/tests_models/samples/apps.detail.json new file mode 100644 index 0000000..a6e85aa --- /dev/null +++ b/tests/unit/tests_models/samples/apps.detail.json @@ -0,0 +1,57 @@ +{ + "cname": [ + "hufflepuff-stg.hogwarts.wiz" + ], + "deploys": 44, + "description": "https://github.com/hogwarts/hufflepuff-api", + "ip": "hufflepuff-api-prd.tsuru-131.vpc", + "lock": { + "Locked": false, + "Reason": "", + "Owner": "", + "AcquireDate": "0001-01-01T00:00:00Z" + }, + "name": "hufflepuff-api-prd", + "owner": "harry.potter@hogwarts.wiz", + "plan": { + "cpushare": 2, + "memory": 805306368, + "name": "entry", + "router": "hipache", + "swap": 0 + }, + "platform": "python", + "pool": "stg", + "repository": "git@tsuru-gandalf.hogwarts.vpc:hufflepuff-api-prd.git", + "router": "hipache", + "tags": [], + "teamowner": "albus", + "teams": [ + "albus" + ], + "units": [ + { + "ID": "some-very-large-id-here-for-griffindor", + "Name": "griffindor-stg-7bfecd66649a54a4fc09", + "AppName": "griffindor-stg", + "ProcessName": "web", + "Type": "python", + "Ip": "ip-10-0-0-7.ec2.internal", + "Status": "started", + "Address": { + "Scheme": "http", + "Opaque": "", + "User": null, + "Host": "ip-10-0-0-7.ec2.internal:32772", + "Path": "", + "RawPath": "", + "ForceQuery": false, + "RawQuery": "", + "Fragment": "" + }, + "HostAddr": "p-10-0-0-7.ec2.internal", + "HostPort": "32772", + "IP": "p-10-0-0-7.ec2.internal" + } + ] +} \ No newline at end of file diff --git a/tests/unit/tests_models/samples/apps.list.json b/tests/unit/tests_models/samples/apps.list.json new file mode 100644 index 0000000..acd75f3 --- /dev/null +++ b/tests/unit/tests_models/samples/apps.list.json @@ -0,0 +1,96 @@ +[ + { + "name": "hufflepuff-stg", + "pool": "stg", + "teamowner": "hogwarts", + "plan": { + "name": "entry", + "memory": 805306368, + "swap": 0, + "cpushare": 2 + }, + "units": [ + { + "ID": "some-very-large-id-here-for-hufflepuff", + "Name": "hufflepuff-stg-7bfecd55249a54a4fc09", + "AppName": "hufflepuff-stg", + "ProcessName": "web", + "Type": "python", + "Ip": "ip-10-0-0-42.ec2.internal", + "Status": "started", + "Address": { + "Scheme": "http", + "Opaque": "", + "User": null, + "Host": "ip-10-0-0-42.ec2.internal:32772", + "Path": "", + "RawPath": "", + "ForceQuery": false, + "RawQuery": "", + "Fragment": "" + }, + "HostAddr": "p-10-0-0-42.ec2.internal", + "HostPort": "32772", + "IP": "p-10-0-0-42.ec2.internal" + } + ], + "cname": [ + "hufflepuff-stg.hogwarts.wiz" + ], + "ip": "hufflepuff-stg.tsuru.vpc", + "lock": { + "Locked": false, + "Reason": "", + "Owner": "", + "AcquireDate": "0001-01-01T00:00:00Z" + }, + "tags": [] + }, + { + "name": "griffindor-stg", + "pool": "stg", + "teamowner": "hogwarts", + "plan": { + "name": "entry", + "memory": 805306368, + "swap": 0, + "cpushare": 2 + }, + "units": [ + { + "ID": "some-very-large-id-here-for-griffindor", + "Name": "griffindor-stg-7bfecd66649a54a4fc09", + "AppName": "griffindor-stg", + "ProcessName": "web", + "Type": "python", + "Ip": "ip-10-0-0-7.ec2.internal", + "Status": "started", + "Address": { + "Scheme": "http", + "Opaque": "", + "User": null, + "Host": "ip-10-0-0-7.ec2.internal:32772", + "Path": "", + "RawPath": "", + "ForceQuery": false, + "RawQuery": "", + "Fragment": "" + }, + "HostAddr": "p-10-0-0-7.ec2.internal", + "HostPort": "32772", + "IP": "p-10-0-0-7.ec2.internal" + } + ], + "cname": [ + "griffindor-stg.hogwarts.wiz" + ], + "ip": "griffindor-stg.tsuru.vpc", + "lock": { + "Locked": false, + "Reason": "", + "Owner": "", + "AcquireDate": "0001-01-01T00:00:00Z" + }, + "tags": [] + } +] \ No newline at end of file diff --git a/tests/unit/tests_models/tests_app.py b/tests/unit/tests_models/tests_app.py new file mode 100644 index 0000000..ddd0aa8 --- /dev/null +++ b/tests/unit/tests_models/tests_app.py @@ -0,0 +1,39 @@ +import unittest + +from tsuru import models, exceptions +from tests.unit.tests_models import ModelTestMixin + + +class TestAppModel(ModelTestMixin, unittest.TestCase): + MODEL_KLASS = models.App + + def test_fields(self): + data = self.sample_detail() + app = models.App(data=data) + + self.assertEqual('hufflepuff-api-prd', app.name) + + def test_invalid_field(self): + with self.assertRaises(exceptions.UnexpectedDataFormat): + models.App(data={}) + + def test_list(self): + data = self.sample_list() + with self.patch_get(data=data) as get: + list(models.App.list()) + + self.assertEqual(1, get.call_count) + + def test_detail(self): + data = self.sample_detail() + with self.patch_get(data=data) as get: + models.App.get(pk=666) + + self.assertEqual(1, get.call_count) + + def test_not_found(self): + with self.patch_get_error(status_code=404) as get: + with self.assertRaises(exceptions.DoesNotExist): + models.App.get(pk=666) + + self.assertEqual(1, get.call_count) diff --git a/tsuru/__init__.py b/tsuru/__init__.py new file mode 100644 index 0000000..832b840 --- /dev/null +++ b/tsuru/__init__.py @@ -0,0 +1,36 @@ +from tsuru.models import ( + App, + Deploy, + Env, + Lock, + Log, + Plan, + Platform, + Team, + Unit, +) +from tsuru.exceptions import ( + DoesNotExist, + UnexpectedDataFormat, + UnsupportedModelException, +) + + +name = "tsuru" +version = '0.1.0' + +__all__ = ( + 'App', + 'Deploy', + 'Env', + 'Lock', + 'Log', + 'Plan', + 'Platform', + 'Team', + 'Unit', + + 'DoesNotExist', + 'UnexpectedDataFormat', + 'UnsupportedModelException', +) diff --git a/tsuru/client.py b/tsuru/client.py new file mode 100644 index 0000000..43301e8 --- /dev/null +++ b/tsuru/client.py @@ -0,0 +1,64 @@ +import os + +import requests + + +class TsuruClient: + _URL = os.environ.get('TSURU_URL') + _USERNAME = os.environ.get('TSURU_USERNAME') + _PASSWORD = os.environ.get('TSURU_PASSWORD') + _TOKEN = None + + @classmethod + def _get_headers(cls): + return { + "Authentication": cls._get_token(), + "Content-Type": "application/json", + } + + @classmethod + def _get_token(cls): + if not cls._TOKEN: + cls._TOKEN = cls.login() + return cls._TOKEN + + @classmethod + def login(cls): + login_data = f"email={cls._USERNAME}&password={cls._PASSWORD}" + data = cls.post(resource='/auth/login', data=login_data) + return data['token'] + + @classmethod + def get(cls, resource, pk=None, from_resource=None, from_pk=None, params=None): + if from_resource: + if not from_pk: + raise UnboundLocalError() + + if pk: + url = f'{cls._URL}/{from_resource}/{from_pk}/{resource}/{pk}' + else: + url = f'{cls._URL}/{from_resource}/{from_pk}/{resource}' + elif pk: + url = f'{cls._URL}/{resource}/{pk}' + else: + url = f'{cls._URL}/{resource}' + + response = requests.get( + url=url, + params=params, + headers=cls._get_headers(), + ) + response.raise_for_status() + return response.json() + + @classmethod + def post(cls, resource, data): + url = f'{cls._URL}/{resource}' + + response = requests.post( + url=url, + data=data, + headers=cls._get_headers(), + ) + response.raise_for_status() + return response.json() diff --git a/tsuru/exceptions.py b/tsuru/exceptions.py new file mode 100644 index 0000000..eb20b50 --- /dev/null +++ b/tsuru/exceptions.py @@ -0,0 +1,10 @@ +class UnsupportedModelException(Exception): + pass + + +class UnexpectedDataFormat(Exception): + pass + + +class DoesNotExist(Exception): + pass diff --git a/tsuru/models/__init__.py b/tsuru/models/__init__.py new file mode 100644 index 0000000..7d107df --- /dev/null +++ b/tsuru/models/__init__.py @@ -0,0 +1,22 @@ +from tsuru.models.app import App +from tsuru.models.deploy import Deploy +from tsuru.models.env import Env +from tsuru.models.lock import Lock +from tsuru.models.log import Log +from tsuru.models.plan import Plan +from tsuru.models.platform import Platform +from tsuru.models.team import Team +from tsuru.models.unit import Unit + + +__all__ = ( + 'App', + 'Deploy', + 'Env', + 'Lock', + 'Log', + 'Plan', + 'Platform', + 'Team', + 'Unit', +) diff --git a/tsuru/models/app.py b/tsuru/models/app.py new file mode 100644 index 0000000..3f544b2 --- /dev/null +++ b/tsuru/models/app.py @@ -0,0 +1,93 @@ +from tsuru.models.base import BaseModel + + +class App(BaseModel): + _RESOURCE_NAME = 'apps' + _PK_FIELD = 'name' + + @property + def name(self): + return self._get('name') + + @property + def pool(self): + return self._get('pool') + + @property + def team_owner(self): + return self._get('team_owner') + + @property + def owner(self): + return self._get('owner') + + @property + def platform(self): + return self._get('platform') + + @property + def repository(self): + return self._get('repository') + + @property + def router(self): + return self._get('router') + + @property + def teams(self): + from tsuru import Team + + yield from [Team(data={'name': name}) for name in self._get('team')] + + @property + def ip(self): + return self._get('ip') + + @property + def cnames(self): + return self._get('cname') + + @property + def deploys_amount(self): + return self._get('deploys') + + @property + def description(self): + return self._get('description') + + @property + def lock(self): + from tsuru import Lock + + lock_data = self._get('lock') + return Lock(data=lock_data) + + @property + def plan(self): + from tsuru import Plan + + plan_data = self._get('plan') + return Plan(data=plan_data) + + @property + def envs(self): + from tsuru import Env + + yield from self._bound_list(resource_class=Env) + + def get_logs(self, lines=10): + from tsuru import Log + + yield from self._bound_list(resource_class=Log, params={'lines': lines}) + + @property + def units(self): + yield from self._get('units') + + def get_unit(self, pk): + from tsuru import Unit + + return self._bound_detail( + resource_class=Unit, + pk=pk, + ) diff --git a/tsuru/models/base.py b/tsuru/models/base.py new file mode 100644 index 0000000..ab20f42 --- /dev/null +++ b/tsuru/models/base.py @@ -0,0 +1,96 @@ +from datetime import datetime, timezone + +from requests import HTTPError + +from tsuru import client, exceptions + + +class BaseModel: + _RESOURCE_NAME = None + _PK_FIELD = 'ID' + + def __init__(self, data): + if not isinstance(data, dict): + raise exceptions.UnexpectedDataFormat() + if self._PK_FIELD not in data: + raise exceptions.UnexpectedDataFormat(f"Missing mandatory {self._PK_FIELD}") + self._data = data + self._detailed = False + + @property + def pk(self): + return self._get(self._PK_FIELD) + + def _get(self, value): + try: + return self._data[value] + except (KeyError, TypeError): + if not self._detailed: + self.refresh() + return self._get(value) + raise exceptions.UnexpectedDataFormat() + + def refresh(self, force=True): + if force or not self._detailed: + self._data = self.get(pk=self.pk)._data + self._detailed = True + + @classmethod + def get(cls, pk): + try: + data = client.TsuruClient.get(resource=cls._RESOURCE_NAME, pk=pk) + except HTTPError as e: + if e.response.status_code == 404: + raise exceptions.DoesNotExist() + raise + + obj = cls(data=data) + obj._detailed = True + return obj + + @classmethod + def list(cls): + data = client.TsuruClient.get(resource=cls._RESOURCE_NAME) + for item in data: + yield cls(data=item) + + def _bound_list(self, resource_class, params=None): + data = client.TsuruClient.get( + resource=resource_class._RESOURCE_NAME, + from_pk=self.pk, + from_resource=self._RESOURCE_NAME, + params=params, + ) + for item in data: + yield resource_class(data=item) + + def _bound_detail(self, resource_class, pk, params=None): + data = client.TsuruClient.get( + resource=resource_class._RESOURCE_NAME, + from_pk=self.pk, + from_resource=self._RESOURCE_NAME, + pk=pk, + params=params, + ) + for item in data: + yield resource_class(data=item) + + @classmethod + def _parse_date(cls, date_str): + if date_str and not date_str.startswith('0001-01-01'): + fmt = '.%f' if '.' in date_str else '' + return datetime.strptime(date_str, f'%Y-%m-%dT%H:%M:%S{fmt}Z').astimezone(timezone.utc) + return None + + +class UnsupportedModelMixin: + @classmethod + def get(cls, pk): + raise exceptions.UnsupportedModelException() + + @classmethod + def list(cls): + raise exceptions.UnsupportedModelException() + + def _detail(self, resource_class): + raise exceptions.UnsupportedModelException() diff --git a/tsuru/models/deploy.py b/tsuru/models/deploy.py new file mode 100644 index 0000000..89a9a48 --- /dev/null +++ b/tsuru/models/deploy.py @@ -0,0 +1,68 @@ +from datetime import timedelta + +from tsuru.models.base import BaseModel + + +class Deploy(BaseModel): + _RESOURCE_NAME = 'deploys' + _PK_FIELD = 'ID' + + @property + def id(self): + return self._get('ID') + + @property + def app(self): + from tsuru import App + + return App.get(pk=self._get('App')) + + @property + def duration(self): + nanoseconds = self._get('Duration') + return timedelta(seconds=nanoseconds / 1000 / 1000 / 1000) + + @property + def commit(self): + return self._get('Commit') + + @property + def error(self): + return self._get('Error') or None + + @property + def image(self): + return self._get('Image') + + @property + def log(self): + # 'Log' field exists but it is empty when listing + # When detailing, it is not empty anymore, so we force the detail + self.refresh() + return self._get('Log') + + @property + def user(self): + return self._get('User') + + @property + def origin(self): + return self._get('Origin') + + @property + def can_rollback(self): + return self._get('CanRollback') + + @property + def removed_at(self): + date_str = self._get('RemoveDate') + return self._parse_date(date_str=date_str) + + @property + def diff(self): + return self._get('Diff') + + @property + def timestamp(self): + date_str = self._get('Timestamp') + return self._parse_date(date_str=date_str) diff --git a/tsuru/models/env.py b/tsuru/models/env.py new file mode 100644 index 0000000..a3fd6a5 --- /dev/null +++ b/tsuru/models/env.py @@ -0,0 +1,17 @@ +from tsuru.models.base import UnsupportedModelMixin, BaseModel + + +class Env(UnsupportedModelMixin, BaseModel): + _RESOURCE_NAME = 'env' + + @property + def name(self): + return self._get('name') + + @property + def value(self): + return self._get('value') + + @property + def public(self): + return self._get('public') diff --git a/tsuru/models/lock.py b/tsuru/models/lock.py new file mode 100644 index 0000000..2ba39a2 --- /dev/null +++ b/tsuru/models/lock.py @@ -0,0 +1,22 @@ +from tsuru.models.base import UnsupportedModelMixin, BaseModel + + +class Lock(UnsupportedModelMixin, BaseModel): + _RESOURCE_NAME = 'locks' + + @property + def locked(self): + return self._get('Locked') + + @property + def reason(self): + return self._get('Reason') + + @property + def owner(self): + return self._get('Owner') + + @property + def acquire_date(self): + date_str = self._get('AcquireDate') + return self._parse_date(date_str=date_str) diff --git a/tsuru/models/log.py b/tsuru/models/log.py new file mode 100644 index 0000000..36b7dd0 --- /dev/null +++ b/tsuru/models/log.py @@ -0,0 +1,26 @@ +from tsuru.models.base import UnsupportedModelMixin, BaseModel + + +class Log(UnsupportedModelMixin, BaseModel): + _RESOURCE_NAME = 'log' + + @property + def date(self): + date_str = self._get('Date') + return self._parse_date(date_str=date_str) + + @property + def message(self): + return self._get('Message') + + @property + def source(self): + return self._get('Source') + + @property + def app_name(self): + return self._get('AppName') + + @property + def unit(self): + return self._get('Unit') diff --git a/tsuru/models/plan.py b/tsuru/models/plan.py new file mode 100644 index 0000000..1942815 --- /dev/null +++ b/tsuru/models/plan.py @@ -0,0 +1,26 @@ +from tsuru.models.base import BaseModel + + +class Plan(BaseModel): + _RESOURCE_NAME = 'plans' + _PK_FIELD = 'name' + + @property + def cpu_share(self): + return self._get('cpushare') + + @property + def memory(self): + return self._get('memory') + + @property + def name(self): + return self._get('name') + + @property + def router(self): + return self._get('router') + + @property + def swap(self): + return self._get('swap') diff --git a/tsuru/models/platform.py b/tsuru/models/platform.py new file mode 100644 index 0000000..106a4a2 --- /dev/null +++ b/tsuru/models/platform.py @@ -0,0 +1,14 @@ +from tsuru.models.base import BaseModel + + +class Platform(BaseModel): + _RESOURCE_NAME = 'platforms' + _PK_FIELD = 'Name' + + @property + def name(self): + return self._get('Name') + + @property + def is_enabled(self): + return not self._get('Disabled') diff --git a/tsuru/models/team.py b/tsuru/models/team.py new file mode 100644 index 0000000..d2b1143 --- /dev/null +++ b/tsuru/models/team.py @@ -0,0 +1,25 @@ +from tsuru import exceptions +from tsuru.models.base import BaseModel + + +class Team(BaseModel): + _RESOURCE_NAME = 'teams' + _PK_FIELD = 'name' + + @classmethod + def get(cls, pk): + # Since /teams endpoint does not support detail, + # we fetch-all-and-match + all_teams = cls.list() + for team in all_teams: + if team.pk == pk: + return team + raise exceptions.DoesNotExist() + + @property + def name(self): + return self._get('name') + + @property + def permissions(self): + return self._get('permissions') diff --git a/tsuru/models/unit.py b/tsuru/models/unit.py new file mode 100644 index 0000000..db9f014 --- /dev/null +++ b/tsuru/models/unit.py @@ -0,0 +1,53 @@ +from tsuru.models.base import BaseModel + + +class Unit(BaseModel): + _RESOURCE_NAME = 'units' + + @property + def name(self): + return self._get('Name') + + @property + def process_name(self): + return self._get('ProcessName') + + @property + def app_name(self): + return self._get('AppName') + + @property + def type(self): + return self._get('Type') + + @property + def ip(self): + return self._get('IP') + + @property + def status(self): + return self._get('Status') + + @property + def address(self): + data = self._get('Address') + scheme = data['Scheme'] + user = data['User'] + host = data['Host'] + path = data['Path'] + raw_query = data['RawQuery'] + + auth = f'{user}@' if user else '' + query = f'?{raw_query}' if raw_query else '' + return f"{scheme}://{auth}{host}{path}{query}" + + @property + def host_(self): + address = self._get('HostAddr') + port = self._get('HostPort') + return f"{address}:{port}" + + def app(self): + from tsuru import App + + return App(data={'name': self.app_name})