diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..be977a1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Changes in .github repository shall always be approved by an owner +.github @dl1998 +setup.py @dl1998 +.pylintrc @dl1998 \ No newline at end of file diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml new file mode 100644 index 0000000..81769ce --- /dev/null +++ b/.github/workflows/default.yml @@ -0,0 +1,111 @@ +name: Perform Checks +on: + pull_request: + branches: + - main + workflow_dispatch: +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY_NAME: 'py-git' + THRESHOLD: 0.9 +permissions: write-all +jobs: + standard-checks: + name: Perform Standard Checks + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Install Dependencies + run: python3 -m pip install -r requirements.txt + - name: Run Pylint + uses: dciborow/action-pylint@0.1.0 + with: + github_token: ${{ secrets.github_token }} + # Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review]. + reporter: github-pr-review + # Change reporter level if you need. + # GitHub Status Check won't become failure with warning. + level: warning + glob_pattern: "sources/**/*.py" + - name: Behave Tests + run: coverage run --source="sources" -m behave tests/qualification_tests + - name: Generate XML coverage report + run: coverage xml + - name: Get Coverage + uses: orgoro/coverage@v3.1 + with: + coverageFile: coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} + thresholdAll: ${{ env.THRESHOLD }} + - name: Retrieve Total Coverage + id: coverage + if: '!cancelled()' + run: echo "TOTAL_COVERAGE=$(coverage report | grep TOTAL | awk '{ print $4 }' | sed 's/%//g')" >> "$GITHUB_ENV" + - name: Retrieve Coverage Color + if: '!cancelled()' + uses: jannekem/run-python-script-action@v1 + id: coverage-color + with: + fail-on-error: false + script: | + if (int("${{ env.TOTAL_COVERAGE }}") / 100.0) < float("${{ env.THRESHOLD }}"): + print("red", end='') + else: + print("green", end='') + - name: Retrieve Release Version + id: version + if: '!cancelled()' + run: echo "VERSION=$(python3 setup.py --version)" >> "$GITHUB_ENV" + - name: Add Badges + if: '!cancelled()' + # You may pin to the exact commit or the version. + # uses: wow-actions/add-badges@43f2c1eaecfb2596b89a8136a3fbda4f18d1d188 + uses: wow-actions/add-badges@v1.1.0 + env: + repo_url: ${{ github.event.repository.html_url }} + repo_name: ${{ github.event.repository.name }} + repo_owner: ${{ github.event.repository.owner.login }} + with: + # Your GitHub token for authentication. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The badges to add with JSON format + badges: | + [ + { + "badge": "https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge", + "alt": "MIT License", + "link": "${{ env.repo_url }}/blob/main/LICENSE" + }, + { + "badge": "https://img.shields.io/badge/Language-Python-blue?style=for-the-badge&logo=python", + "alt": "Language", + "link": "https://www.python.org/" + }, + { + "badge": "https://img.shields.io/badge/PRs-Welcome-brightgreen.svg?style=for-the-badge", + "alt": "PRs Welcome", + "link": "${{ env.repo_url }}/pulls" + }, + { + "badge": "https://img.shields.io/badge/TestPyPi-${{ env.VERSION }}-brightgreen.svg?style=for-the-badge", + "alt": "TestPyPi ${{ env.VERSION }}", + "link": "https://test.pypi.org/project/${{ env.REPOSITORY_NAME }}/" + }, + { + "badge": "https://img.shields.io/badge/PyPi-${{ env.VERSION }}-brightgreen.svg?style=for-the-badge", + "alt": "PyPi ${{ env.VERSION }}", + "link": "https://pypi.org/project/${{ env.REPOSITORY_NAME }}/" + }, + { + "badge": "https://img.shields.io/badge/Coverage-${{ env.TOTAL_COVERAGE }}%25-${{ steps.coverage-color.outputs.stdout }}.svg?style=for-the-badge", + "alt": "Coverage ${{ env.TOTAL_COVERAGE }}%" + } + ] + # Path and file name to add badges + path: README.md + # Should center align the badges + center: false diff --git a/.github/workflows/publish-documentation.yml b/.github/workflows/publish-documentation.yml new file mode 100644 index 0000000..b129223 --- /dev/null +++ b/.github/workflows/publish-documentation.yml @@ -0,0 +1,33 @@ +name: Publish Documentation +on: + push: + branches: ['main', 'release'] +permissions: + contents: write +jobs: + sphinx-docs: + name: Sphinx Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.7" + - name: Update pip + run: python3 -m pip install --upgrade pip + - name: Install dependencies + run: python3 -m pip install -r requirements.txt + - name: Generate documentation for modules + run: python3 setup.py sphinx_update_modules + - name: Build Sphinx documentation + run: python3 setup.py sphinx_build + - name: Publish Documentation + uses: peaceiris/actions-gh-pages@v3 + with: + publish_branch: gh-pages + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/build/ + force_orphan: true + keep_files: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e812c8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release New Version +on: + push: + branches: + - main +env: + REPOSITORY_NAME: py-git +jobs: + create-release: + name: Create a new release + permissions: write-all + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.7" + - name: Retrieve Release Version + id: version + run: echo "VERSION=$(python3 setup.py --version)" >> "$GITHUB_ENV" + - name: "✏️ Generate release changelog" + uses: heinrichreimer/github-changelog-generator-action@v2.3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + futureRelease: "v${{ env.VERSION }}" + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: "v${{ env.VERSION }}" + name: "Release v${{ env.VERSION }}" + bodyFile: CHANGELOG.md + - name: Install pypa/build + run: python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build --sdist --wheel --outdir dist/ + - name: Publish Artifacts + uses: actions/upload-artifact@v3 + with: + name: linux-dist + path: dist/ + publish-library-dev: + name: Publish new release on dev + needs: ['create-release'] + permissions: write-all + runs-on: ubuntu-latest + environment: + name: dev + url: "https://test.pypi.org/project/${{ env.REPOSITORY_NAME }}/" + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Download Artifacts + uses: actions/download-artifact@v3 + with: + name: linux-dist + path: dist/ + - name: Publish package distributions to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: "https://test.pypi.org/legacy/" + publish-library-prod: + name: Publish new release on prod + needs: ['create-release', 'publish-library-dev'] + permissions: write-all + runs-on: ubuntu-latest + environment: + name: prod + url: "https://pypi.org/project/${{ env.REPOSITORY_NAME }}/" + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Download Artifacts + uses: actions/download-artifact@v3 + with: + name: linux-dist + path: dist/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 68bc17f..9c0fd04 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/build/ # PyBuilder .pybuilder/ @@ -157,4 +158,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ + +# MacOS DB file +.DS_Store \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..50350fb --- /dev/null +++ b/.pylintrc @@ -0,0 +1,628 @@ +[MAIN] + +# 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 + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# 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-allow-list= + +# 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. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# 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= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +init-hook='import sys; sys.path.append("sources")' + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# 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 module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# 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 + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# 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. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set 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. If left empty, constant names will be checked with the set 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. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=exception, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# 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. If left empty, inline iteration names will be checked +# with the set 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. If left empty, method names will be checked with the set naming style. +method-rgx=([^\\W\\dA-Z][^\\WA-Z]{1,}|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$ + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set 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 + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# 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=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods=Enum + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# 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 (see R0916). +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 caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[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=1000 + +# 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 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to 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 + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# 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 re-enable 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=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead + +# 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 + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[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,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 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= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[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 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 symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# 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,argparse.Namespace + +# 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 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[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 names allowed to shadow builtins +allowed-redefined-builtins= + +# 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. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..224807d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] - 2023-09-01 + +### Added +- Basic functionality + - General + - Read Git configuration + - Update Git configuration + - Execute raw Git command + - Repository + - Init repository + - Clone repository + - Use existing repository + - Change management + - Add files/folders + - Remove files/folders + - Move files/folders + - Create a new commit + - Create a new tag + - Create a new branch + - Switch between branches + - Pull changes + - Push changes + - Access information about repository + - List of branches + - List of tags + - List of commits + - Active branch +- README.md +- LICENSE.md +- CHANGELOG.md +- requirements.txt (Python Dependencies) +- .pylintrc (PyLinter Configuration) +- Sphinx Documentation Configuration +- Basic Tests + +[unreleased]: https://github.com/dl1998/PyGit diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/README.md b/README.md index 98c994a..18c5b2b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # PyGit -Python Git CLI Wrapper + +Python Git CLI wrapper, provides various classes that simplifies interaction with the Git using Python. + +It provides basic Git functionality that could be accessed using pre-defined models. diff --git a/_docs/conf.py_t b/_docs/conf.py_t new file mode 100644 index 0000000..93bc311 --- /dev/null +++ b/_docs/conf.py_t @@ -0,0 +1,108 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# + +import os +import sys +sys.path.insert(0, os.path.abspath('./../..')) +sys.path.insert(0, os.path.abspath('.')) + +# -- Project information ----------------------------------------------------- + +project = {{ project | repr }} +copyright = {{ copyright | repr }} +author = {{ author | repr }} + +{%- if version %} + +# The short X.Y version +version = {{ version | repr }} +{%- endif %} +{%- if release %} + +# The full version, including alpha/beta/rc tags +release = {{ release | repr }} +{%- endif %} + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +{%- for ext in extensions %} + '{{ ext }}', +{%- endfor %} +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['{{ dot }}templates'] + +{% if suffix != '.rst' -%} +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = {{ suffix | repr }} + +{% endif -%} +{% if master != 'index' -%} +# The master toctree document. +master_doc = {{ master | repr }} + +{% endif -%} +{% if language -%} +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = {{ language | repr }} + +{% endif -%} +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [{{ exclude_patterns }}] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['{{ dot }}static'] +{%- if extensions %} + + +# -- Extension configuration ------------------------------------------------- +{%- endif %} +{%- if 'sphinx.ext.intersphinx' in extensions %} + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/3/': None} +{%- endif %} +{%- if 'sphinx.ext.todo' in extensions %} + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True +{%- endif %} diff --git a/_docs/root_doc.rst_t b/_docs/root_doc.rst_t new file mode 100644 index 0000000..2ece958 --- /dev/null +++ b/_docs/root_doc.rst_t @@ -0,0 +1,19 @@ +Welcome to {{ project }}'s documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. toctree:: + :maxdepth: 2 + :caption: Source Code Documentation: + + modules + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..6d35112 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,71 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# + +import os +import sys +sys.path.insert(0, os.path.abspath('./../..')) +sys.path.insert(0, os.path.abspath('.')) + +# -- Project information ----------------------------------------------------- + +project = 'py-git' +copyright = '2023, Dmytro Leshchenko' +author = 'Dmytro Leshchenko' + +# The short X.Y version +version = '0.0.1' + +# The full version, including alpha/beta/rc tags +release = '0.0.1' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +# -- Extension configuration ------------------------------------------------- \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..9c33ba9 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,19 @@ +Welcome to py-git's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. toctree:: + :maxdepth: 2 + :caption: Source Code Documentation: + + modules + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f41296 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +setuptools==68.0.0 +wheel==0.41.2 +Sphinx==5.3.0 +sphinx-rtd-theme==1.3.0 +docs-versions-menu==0.5.2 +pytest==7.4.2 +behave==1.2.6 +coverage==7.2.7 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e5b9dfc --- /dev/null +++ b/setup.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 + +import io +import os +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, Union, Set, List + +from setuptools import find_packages, setup, Command + +# In that example author and maintainer is the same person. +NAME = 'py-git' +DESCRIPTION = 'Python Git CLI Wrapper' +URL = 'https://github.com/dl1998/PyGit' +EMAIL = 'dima.leschenko1998@gmail.com' +AUTHOR = 'Dmytro Leshchenko' +REQUIRES_PYTHON = '>=3.7.0' +VERSION = '0.0.1' +RELEASE = VERSION + +here = os.path.abspath(os.path.dirname(__file__)) + +long_description = '' + +try: + with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as file: + long_description = file.read() +except FileNotFoundError: + long_description = DESCRIPTION + +about = {} + +used_python = 'python' +try: + used_python = os.environ['USED_PYTHON'] +except KeyError: + if 'prepare_venv' in sys.argv: + print('USED_PYTHON not found in environment variables, default python version will be used.') + +if not VERSION: + project_slug = NAME.lower().replace("-", "_").replace(" ", "_") + with open(os.path.join(here, 'sources', project_slug, '__version__.py')) as file: + exec(file.read(), about) +else: + about['__version__'] = VERSION + + +def check_status_code(status_code, successful_message, error_message) -> NoReturn: + """ + Check status code and based on status code print one of two messages. + + :raises CommandExecutionException: If status code different from 0. + :param status_code: Checked status code, returned by command. + :type status_code: int + :param successful_message: Message that will be printed, if status code equals to 0. + :type successful_message: str + :param error_message: Message that will be printed, if status code is different from 0. + :type error_message: str + """ + if status_code == 0: + print(successful_message) + else: + raise CommandExecutionException(error_message) + + +class UnknownOSException(Exception): + """ + Unknown operating system exception. + """ + pass + + +class CommandExecutionException(Exception): + """ + Exception that will be used, if error occurred in command execution. + """ + pass + + +class PrepareVirtualEnvironmentCommand(Command): + """ + Support setup.py create venv and install requirements. + """ + + description = 'Create venv and install requirements from requirements.txt file.' + user_options = [] + + @staticmethod + def status(text: str) -> NoReturn: + """ + Method will be used for printing information to console. + + :param text: Parameter that will be printed to console. + :type text: str + """ + print(text) + + def initialize_options(self) -> NoReturn: + """ + If command receive arguments, then method can be used to set default values. + """ + pass + + def finalize_options(self) -> NoReturn: + """ + If command receive arguments, then method can be used to set final values for arguments. + """ + pass + + def __create_venv(self, venv_path: str) -> NoReturn: + """ + Create python virtual environment. + + :raises CommandExecutionException: If error occurred during the command execution. + :param venv_path: Path to place where located virtual environment. + :type venv_path: str + """ + self.status('Create venv') + status_code = os.system(f'{used_python} -m venv {venv_path}') + successful_message = 'Venv successfully created' + error_message = 'Error occurred during venv creation, installation will not be executed' + check_status_code(status_code, successful_message, error_message) + + def __install_requirements(self, venv_path: str) -> NoReturn: + """ + Install requirements from requirements.txt file. + + :raises CommandExecutionException: If error occurred during the command execution. + :path venv_path: Virtual environment for which install requirements. + :type venv_path: str + """ + requirements_file_path = os.path.join(here, 'requirements.txt') + venv_pip = None + if sys.platform == 'linux': + venv_pip = os.path.join(venv_path, 'bin', 'pip3') + elif sys.platform == 'win32': + venv_pip = os.path.join(venv_path, 'Scripts', 'pip3') + else: + raise UnknownOSException(f'Unknown operation system: {sys.platform}.') + self.status('Check requirements.txt') + requirements_exists = os.path.exists(requirements_file_path) + if requirements_exists: + self.status('Install requirements from requirements.txt') + status_code = os.system(f'{venv_pip} install --upgrade --requirement requirements.txt') + successful_message = 'Installation completed successfully' + error_message = 'Error occurred during the installation' + check_status_code(status_code, successful_message, error_message) + else: + self.status('Requirements file not found') + + def run(self) -> NoReturn: + """ + Create virtual environment and install all needed requirements. + """ + venv_path = os.path.join(here, 'venv') + try: + self.__create_venv(venv_path) + self.__install_requirements(venv_path) + except CommandExecutionException as execution_exception: + print(execution_exception) + except UnknownOSException as unknown_os_exception: + print(unknown_os_exception) + + +class BuildDocsCommand(Command): + """ + Custom command to build documentation with Sphinx + """ + doc_dir: Path + source_dir: Path + output_dir: Path + + user_options = [ + ('doc-dir=', None, 'The documentation directory'), + ('source-dir=', None, 'The documentation sources directory'), + ('output-dir=', None, 'The documentation output directory'), + ] + + def initialize_options(self): + self.doc_dir = None + self.source_dir = None + self.output_dir = None + + def finalize_options(self) -> None: + self.doc_dir = Path(self.doc_dir) if self.doc_dir else Path(__file__).parent.joinpath('docs') + if self.source_dir is None: + self.source_dir = self.doc_dir.joinpath('source') + if self.output_dir is None: + self.output_dir = self.doc_dir.joinpath('build') + + def run(self): + build_command = [ + 'sphinx-build', + '-b', 'html', + '-d', str(self.doc_dir.joinpath('build/doctrees')), + '-j', 'auto', + str(self.source_dir), + str(self.output_dir) + ] + + try: + print(' '.join(build_command)) + subprocess.check_call(build_command) + except subprocess.CalledProcessError as e: + print(f"Error: Failed to build documentation with Sphinx: {e}") + raise + + +class SphinxGenerate(Command): + """ + Class responsible for Sphinx project generation. + """ + name: str + author: str + version: str + release: str + language: str + template: str + doc_dir: str + + user_options = [ + ('name', None, 'The project name'), + ('author', None, 'The project author'), + ('version', None, 'The project version'), + ('release', None, 'The project release'), + ('language', None, 'The project language'), + ('template', None, 'The project template'), + ('doc-dir', None, 'The project root'), + ] + + def initialize_options(self) -> NoReturn: + """ + If command receives arguments, then method can be used to set default values. + """ + self.name = None + self.author = None + self.version = None + self.release = None + self.language = None + self.template = None + self.doc_dir = None + + def finalize_options(self) -> NoReturn: + """ + If command receive arguments, then method can be used to set final values for arguments. + """ + if self.name is None: + self.name = NAME + if self.author is None: + self.author = AUTHOR + if self.version is None: + self.version = about['__version__'] + if self.release is None: + self.release = about['__version__'] + if self.language is None: + self.language = 'en' + if self.template is None: + self.template = str(Path('_docs').absolute()) + if self.doc_dir is None: + self.doc_dir = str(Path('docs').absolute()) + + def run(self) -> None: + generate_command = [ + 'sphinx-quickstart', + '--sep', + '-p', self.name, + '-a', self.author, + '-v', self.version, + '-r', self.release, + '-l', self.language, + '--ext-autodoc', + '--ext-githubpages', + '-t', self.template, + str(self.doc_dir) + ] + + try: + print(' '.join(generate_command)) + subprocess.check_call(generate_command) + except subprocess.CalledProcessError as e: + print(f"Error: Failed to generate documentation with Sphinx: {e}") + raise + + +class SphinxAutoDoc(Command): + """ + Class responsible for configuration of the Sphinx project and documentation generation. + """ + + description = 'Generate documentation for modules.' + user_options = [ + ('with-magic-methods', None, 'Include magic packages to documentation.'), + ('with-private-methods', None, 'Include private methods to documentation'), + ('generate-project', None, 'Generate Sphinx project based on configuration.'), + ('separate', None, 'Separate modules from sub-modules.'), + ('automodules=', None, 'Coma-separated list of auto-modules.') + ] + + with_magic_methods: bool + with_private_methods: bool + generate_project: bool + separate: bool + automodules: str + + @staticmethod + def __get_boolean_value(variable: Union[int, bool]) -> bool: + """ + Get boolean value from status code. + + :param variable: Checked variable. + :type variable: Union[int, bool] + :return: True or False for status code and default value for None. + :rtype: bool + """ + if variable == 1: + return True + elif variable == 0: + return False + else: + return variable + + def initialize_options(self) -> NoReturn: + """ + If command receive arguments, then method can be used to set default values. + """ + self.with_magic_methods = False + self.with_private_methods = False + self.generate_project = False + self.separate = False + self.automodules = "" + + def finalize_options(self) -> NoReturn: + """ + If command receive arguments, then method can be used to set final values for arguments. + """ + self.with_magic_methods = self.__get_boolean_value(self.with_magic_methods) + self.with_private_methods = self.__get_boolean_value(self.with_private_methods) + self.generate_project = self.__get_boolean_value(self.generate_project) + self.separate = self.__get_boolean_value(self.separate) + + @staticmethod + def __get_magic_packages(sources_path: str) -> Set[str]: + """ + Get all magic packages from sources. + + :param sources_path: Path to sources. + :type sources_path: str + :return: Set of magic methods. + :rtype: Set[str] + """ + magic_packages = set() + for root, folders, files in os.walk(sources_path): + for file in files: + if file.startswith('__') and file.endswith('__.py'): + magic_packages.add(file) + return magic_packages + + def __get_magic_excludes(self, sources_path: str) -> List[str]: + """ + Get list of fnmatch excludes. + + :param sources_path: Path to sources folder. + :type sources_path: str + :return: Fnmatch excludes list. + :rtype: List[str] + """ + excludes = [] + magic_packages = self.__get_magic_packages(sources_path) + for exclude in magic_packages: + excludes.append(f'*{exclude}') + if '*__init__.py' in excludes: + excludes.remove('*__init__.py') + return excludes + + def __print_configuration(self) -> NoReturn: + """ + Print configuration used to generate documentation. + """ + print('Generate documentation configuration:') + print(f'\tGenerate project - {self.generate_project}') + print(f'\tSeparate modules from sub-modules - {self.separate}') + print(f'\tAdd private methods - {self.with_private_methods}') + print(f'\tAdd magic methods - {self.with_magic_methods}') + print(f'\tSelected auto-modules - {self.automodules}') + + def run(self) -> NoReturn: + """ + Generate Sphinx project and documentation. + """ + docs_path = os.path.join(here, 'docs') + templates = os.path.join(docs_path, '_docs') + sources_path = os.path.join(here, 'sources') + rel_sources_path = os.path.relpath(sources_path, here) + self.__print_configuration() + cmd = ['sphinx-apidoc', '--templatedir', templates, '--force', '-o', os.path.join(docs_path, 'source')] + if self.generate_project: + cmd.extend(['--full', '-a', '-H', NAME, '-A', AUTHOR, '-V', VERSION, '-R', RELEASE]) + if self.with_private_methods: + if self.automodules: + os.environ['SPHINX_APIDOC_OPTIONS'] = self.automodules + cmd.append('--private') + if self.separate: + cmd.append('--separate') + cmd.append(rel_sources_path) + if not self.with_magic_methods: + exclude_packages = ' '.join(self.__get_magic_excludes(sources_path)) + cmd.append(exclude_packages) + print('Generate documentation for modules.') + process = subprocess.Popen(cmd) + process.communicate() + successful_message = 'Documentation for modules was successfully generated.' + error_message = 'Error occurred during the documentation generation.' + check_status_code(process.returncode, successful_message, error_message) + + +cmd_class = { + 'prepare_venv': PrepareVirtualEnvironmentCommand, + 'sphinx_generate_project': SphinxGenerate, + 'sphinx_update_modules': SphinxAutoDoc, + 'sphinx_build': BuildDocsCommand +} + +command_options = { + 'sphinx-generate-project': { + 'name': (NAME, 'Specify the project name'), + 'author': (AUTHOR, 'Specify the project author'), + 'version': (VERSION, 'Specify the project version'), + 'release': (RELEASE, 'Specify the project release'), + 'language': ('en', 'Specify the project language'), + 'template': ('_docs', 'Specify the project template'), + 'doc-dir': ('docs', 'Specify the documentation directory'), + }, +} + + +def parse_requirements(file_path): + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + requirements = f.read().splitlines() + else: + requirements = [] + return requirements + + +def main(): + # Path to the requirements.txt file + requirements_path = 'requirements.txt' + + # Parse the requirements from requirements.txt + requirements = parse_requirements(requirements_path) + + setup( + name=NAME, + version=about['__version__'], + description=DESCRIPTION, + long_description=long_description, + long_description_content_type='text/markdown', + author=AUTHOR, + author_email=EMAIL, + maintainer=AUTHOR, + maintainer_email=EMAIL, + python_requires=REQUIRES_PYTHON, + install_requires=requirements, + url=URL, + platforms=['any'], + packages=find_packages(exclude=['tests', '*.tests', '*.tests.*', 'tests.*']), + include_package_data=True, + license='MIT', + classifiers=[ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + keywords=['git', 'version control', 'sources control', 'Git wrapper', 'Git CLI', 'Git commands', + 'Git automation', 'Git interface', 'Git integration', 'Git management', 'Git utility', + 'Git interaction', 'Git convenience', 'Git operations', 'Git workflow'], + cmdclass=cmd_class, + command_options=command_options, + ) + + +if __name__ == '__main__': + main() diff --git a/sources/__init__.py b/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/command.py b/sources/command.py new file mode 100644 index 0000000..23e30c0 --- /dev/null +++ b/sources/command.py @@ -0,0 +1,292 @@ +""" +Module defines base class for git command execution. +""" +import logging +from pathlib import Path +from subprocess import PIPE, Popen +from typing import Union, List, Optional, Type + +from sources.exceptions import GitCommandException, GitException, GitPushException, GitPullException, GitRmException, \ + GitMvException, GitAddException, GitCloneException, GitInitException, GitShowException, GitConfigException, \ + GitCheckoutException, GitForEachRefException, GitLogException, GitCommitException +from sources.options.add_options import AddCommandDefinitions +from sources.options.checkout_options import CheckoutCommandDefinitions +from sources.options.clone_options import CloneCommandDefinitions +from sources.options.commit_options import CommitCommandDefinitions +from sources.options.config_options import ConfigCommandDefinitions +from sources.options.for_each_ref_options import ForEachRefCommandDefinitions +from sources.options.init_options import InitCommandDefinitions +from sources.options.log_options import LogCommandDefinitions +from sources.options.mv_options import MvCommandDefinitions +from sources.options.options import GitCommand, GitOption +from sources.options.pull_options import PullCommandDefinitions +from sources.options.push_options import PushCommandDefinitions +from sources.options.rm_options import RmCommandDefinitions +from sources.options.show_options import ShowCommandDefinitions + + +class GitCommandRunner: + """ + Base class that wraps git command execution. + """ + ENCODING = 'UTF-8' + ERRORS = 'ignore' + + def __init__(self): + self.__command = 'git' + self.__working_directory = Path() + self.__logging = logging.getLogger('git-command') + + @property + def working_directory(self): + """ + Working directory (git repository) for the git command. + """ + return self.__working_directory + + @working_directory.setter + def working_directory(self, working_directory: Union[str, Path]): + class_name = self.__class__.__name__ + if isinstance(working_directory, str): + working_directory = Path(working_directory) + self.__logging.debug('Switching %s working directory to "%s"', class_name, working_directory.absolute()) + self.__working_directory = working_directory + + def __generate_command(self, command: List[Union[str, int]]) -> List[Union[str, int]]: + """ + A method that generates git command, it adds 'git' to the command options list. + + :param command: List of options for the git command. + :type command: List[Union[str, int]] + :return: Command options list with 'git' as the first argument. + """ + if isinstance(command, list): + command.insert(0, self.__command) + return command + + def execute(self, commands: List[Union[str, int, GitOption, None]], + definitions_class: Optional[Type[GitCommand]] = None, log_output: bool = False) -> str: + """ + Method executes git command with options provided as an input. It validates options based on the provided + definition. If definitions class has not been provided, then no validation happens and standard list of commands + is expected, otherwise it expects GitOption list. + + :param commands: List of options for the git command. + :type commands: List[Union[str, int, GitOption, None]] + :param definitions_class: Definition class that describes options for the git command, shall be derived from + GitCommand class. + :type definitions_class: Optional[Type[GitCommand]] + :param log_output: If this option is True, then stdout of the command will be logged, + :type log_output: bool + :raises GitCommandException: If command execution has been finished with non-zero exit code. + :return: Output from the command. + """ + if None in commands: + commands.remove(None) + if definitions_class is not None: + definitions = definitions_class() + definitions.validate(commands) + commands = definitions.transform_to_command(commands) + commands = self.__generate_command(commands) + self.__logging.debug(commands) + with Popen(commands, shell=False, stderr=PIPE, stdout=PIPE, cwd=self.__working_directory) as process: + while log_output and process.poll() is None: + self.__logging.info(process.stdout.readline().decode(self.ENCODING, errors=self.ERRORS).strip()) + stdout, stderr = process.communicate() + stdout = stdout.decode(self.ENCODING, errors=self.ERRORS) + stderr = stderr.decode(self.ENCODING, errors=self.ERRORS) + if process.returncode != 0: + raise GitCommandException(stderr) + return stdout + + def __execute_git_command(self, options: List[GitOption], definition_class: Type[GitCommand], + exception_class: Type[GitCommandException], log_output: bool = False): + """ + Execute git command that takes GitOption, it shall has command definitions class, and exception class. + Optionally method can log stdout in the runtime, if the 'log_output' was set as True. As result of the + successful execution stdout will be returned. + + :param options: List of git options for the command. + :type options: List[GitOption] + :param definition_class: Class that describes git command and its options. + :type definition_class: Type[GitCommand] + :param exception_class: Exception that will be raised if git command failed (returned non-zero exit code). + :type exception_class: Type[GitCommandException] + :param log_output: Select whether it shall log stdout in the runtime. + :type log_output: bool + :return: Stdout returned by the command. + """ + try: + return self.execute(commands=options, definitions_class=definition_class, log_output=log_output) + except GitException as exception: + raise exception_class(exception.args[0]) from None + + def init(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git init' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git init' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git init' command. + """ + return self.__execute_git_command(list(options), InitCommandDefinitions, GitInitException, log_output) + + def clone(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git clone' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git clone' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git clone' command. + """ + return self.__execute_git_command(list(options), CloneCommandDefinitions, GitCloneException, log_output) + + def add(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git add' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git add' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git add' command. + """ + return self.__execute_git_command(list(options), AddCommandDefinitions, GitAddException, log_output) + + def mv(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git mv' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git mv' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git mv' command. + """ + return self.__execute_git_command(list(options), MvCommandDefinitions, GitMvException, log_output) + + def rm(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git rm' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git rm' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git rm' command. + """ + return self.__execute_git_command(list(options), RmCommandDefinitions, GitRmException, log_output) + + def pull(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git pull' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git pull' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git pull' command. + """ + return self.__execute_git_command(list(options), PullCommandDefinitions, GitPullException, log_output) + + def push(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git push' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git push' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git push' command. + """ + return self.__execute_git_command(list(options), PushCommandDefinitions, GitPushException, log_output) + + def show(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git show' command with provided options and return stdout of the command. Optionally it can log stdout + in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git show' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git show' command. + """ + return self.__execute_git_command(list(options), ShowCommandDefinitions, GitShowException, log_output) + + def config(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git config' command with provided options and return stdout of the command. Optionally it can log + stdout in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git config' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git config' command. + """ + return self.__execute_git_command(list(options), ConfigCommandDefinitions, GitConfigException, log_output) + + def checkout(self, *options: GitOption, log_output: bool = False) -> str: + """ + Execute 'git checkout' command with provided options and return stdout of the command. Optionally it can log + stdout in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git checkout' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git checkout' command. + """ + return self.__execute_git_command(list(options), CheckoutCommandDefinitions, GitCheckoutException, log_output) + + def for_each_ref(self, *options: GitOption, log_output: bool = False): + """ + Execute 'git for-each-ref' command with provided options and return stdout of the command. Optionally it can log + stdout in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git for-each-ref' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git for-each-ref' command. + """ + return self.__execute_git_command(list(options), ForEachRefCommandDefinitions, GitForEachRefException, + log_output) + + def log(self, *options: GitOption, log_output: bool = False): + """ + Execute 'git log' command with provided options and return stdout of the command. Optionally it can log + stdout in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git log' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git log' command. + """ + return self.__execute_git_command(list(options), LogCommandDefinitions, GitLogException, log_output) + + def commit(self, *options: GitOption, log_output: bool = False): + """ + Execute 'git commit' command with provided options and return stdout of the command. Optionally it can log + stdout in the runtime, if 'log_output' option has been set as True. + + :param options: Options for the 'git commit' command. + :type options: Tuple[GitOption] + :param log_output: Set as True, if it shall log stdout of the command in the runtime, otherwise False. + :type log_output: bool + :return: Stdout returned by 'git commit' command. + """ + return self.__execute_git_command(list(options), CommitCommandDefinitions, GitCommitException, log_output) diff --git a/sources/exceptions.py b/sources/exceptions.py new file mode 100644 index 0000000..56b7799 --- /dev/null +++ b/sources/exceptions.py @@ -0,0 +1,129 @@ +""" +Module with Git-specific exceptions classes (derived from Exception base class). +""" + + +class GitException(Exception): + """ + Generic Git Exception class. + """ + + +class GitMissingDefinitionException(GitException): + """ + Exception thrown if no definition has been found for the git option. + """ + + +class GitIncorrectOptionValueException(GitException): + """ + Exception thrown if git option value is not on choices list, where choices list must not be 'None'. + """ + + +class GitIncorrectPositionalOptionDefinitionException(GitException): + """ + Exception thrown if positional option has incorrect definition. + """ + + +class GitMissingRequiredOptionsException(GitException): + """ + Exception thrown if some of the required options are missing. + """ + + +class GitRepositoryNotFoundException(GitException): + """ + Exception thrown if repository doesn't exist. + """ + + +class NotGitRepositoryException(GitException): + """ + Exception thrown if provided path is not a git repository. + """ + + +class GitCommandException(GitException): + """ + Generic Exception class for Git commands. + """ + + +class GitInitException(GitCommandException): + """ + Exception thrown when 'init' operation has failed. + """ + + +class GitCloneException(GitCommandException): + """ + Exception thrown when 'clone' operation has failed. + """ + + +class GitAddException(GitCommandException): + """ + Exception thrown when 'add' operation has failed. + """ + + +class GitMvException(GitCommandException): + """ + Exception thrown when 'mv' operation has failed. + """ + + +class GitRmException(GitCommandException): + """ + Exception thrown when 'rm' operation has failed. + """ + + +class GitPullException(GitCommandException): + """ + Exception thrown when 'pull' operation has failed. + """ + + +class GitPushException(GitCommandException): + """ + Exception thrown when 'push' operation has failed. + """ + + +class GitShowException(GitCommandException): + """ + Exception thrown when 'show' operation has failed. + """ + + +class GitConfigException(GitCommandException): + """ + Exception thrown when 'config' operation has failed. + """ + + +class GitCheckoutException(GitCommandException): + """ + Exception thrown when 'checkout' operation has failed. + """ + + +class GitForEachRefException(GitCommandException): + """ + Exception thrown when 'for-each-ref' operation has failed. + """ + + +class GitLogException(GitCommandException): + """ + Exception thrown when 'log' operation has failed. + """ + + +class GitCommitException(GitCommandException): + """ + Exception thrown when 'commit' operation has failed. + """ diff --git a/sources/git.py b/sources/git.py new file mode 100644 index 0000000..5fb1742 --- /dev/null +++ b/sources/git.py @@ -0,0 +1,859 @@ +""" +Main module that contains entry points classes for the manipulations with the git repository. +""" +import fnmatch +import logging +import os +import re +from configparser import ConfigParser +from dataclasses import field, dataclass +from datetime import datetime +from pathlib import Path +from typing import Union, List, Optional, Dict, NoReturn + +from sources.command import GitCommandRunner +from sources.exceptions import GitException, GitRepositoryNotFoundException, \ + NotGitRepositoryException +from sources.models.base_classes import Reference, Author, Refspec +from sources.models.branches import Branch, Branches +from sources.models.commits import Commits, Commit +from sources.models.remotes import Remotes, Remote +from sources.models.repository_information import GitRepositoryPaths +from sources.models.tags import Tags +from sources.options.add_options import AddCommandDefinitions +from sources.options.checkout_options import CheckoutCommandDefinitions +from sources.options.clone_options import CloneCommandDefinitions +from sources.options.commit_options import CommitCommandDefinitions +from sources.options.config_options import ConfigCommandDefinitions +from sources.options.init_options import InitCommandDefinitions +from sources.options.mv_options import MvCommandDefinitions +from sources.options.options import GitOption +from sources.options.pull_options import PullCommandDefinitions +from sources.options.push_options import PushCommandDefinitions +from sources.options.rm_options import RmCommandDefinitions +from sources.options.show_options import ShowCommandDefinitions +from sources.parsers.branches_parser import BranchesParser +from sources.parsers.commits_parser import CommitsParser +from sources.parsers.tags_parser import TagsParser +from sources.utils.path_util import PathUtil, PathsMapping + + +class GitConfig: + """ + Class for accessing and modifying git configuration file (config). + """ + __configuration_path: Path + + def __init__(self, configuration_path: Union[str, Path]): + self.__configuration_path = PathUtil.convert_to_path(configuration_path) + self.__data = self.__read_configuration(self.__configuration_path) + + @staticmethod + def __read_configuration(path: Path) -> ConfigParser: + """ + Method reads configuration from the file. + + :param path: Path to the git config file. + :type path: Path + :return: Parsed configuration. + """ + parser = ConfigParser() + parser.read(path) + return parser + + def get(self, section: str, name: str) -> str: + """ + Method returns value for the requested parameter from the provided section in the parsed file. + + :param section: Section in which searched parameter is located. + :type section: str + :param name: Name of the parameter. + :type name: str + :return: Value of the parameter from the provided section and name. + """ + return self.__data.get(section, name) + + def set(self, section: str, name: str, value: str) -> NoReturn: + """ + Method overrides value for the parameter in the provided section under provided name in the parsed file. + + :param section: Section in which parameter will be updated. + :type section: str + :param name: Name of the parameter. + :type name: str + :param value: New value for the parameter. + :type value: str + """ + self.__data.set(section, name, value) + + def save(self) -> NoReturn: + """ + Save changes made to configuration back into the file. + """ + with self.__configuration_path.open('w', encoding='UTF-8') as file: + self.__data.write(file) + + @property + def path(self) -> Path: + """ + Path to the configuration file. + """ + return self.__configuration_path + + @property + def remotes(self) -> List[Remote]: + """ + Method reads all remotes defined in the config file and returns list of the Remote objects. + + :return: List of the Remote objects. + """ + regex = re.compile(r'^remote\s*\"(?P[^"]*)\"$') + sections = self.__data.sections() + remotes = [] + for section in sections: + match = regex.match(section) + if match: + remote = Remote(name=match.group('remote'), url=self.__data[section]['url']) + remotes.append(remote) + return remotes + + +class GitIgnore: + """ + Class for manipulations with .gitignore exclude patterns list. + """ + + def __init__(self, path: Union[str, Path]): + self.__path = PathUtil.convert_to_path(path) + if not self.__path.exists(): + raise FileNotFoundError(self.__path) + self.exclude_patterns = self.__read_file(self.__path) + + @classmethod + def create_from_content(cls, path: Union[str, Path], content: str) -> 'GitIgnore': + """ + Create GitIgnore instance with the provided content. + + :param path: Path to the '.gitignore' file. + :type path: Union[str, Path] + :param content: Content of the '.gitignore' file. + :type content: str + :return: New instance of the GitIgnore class. + """ + instance = cls(path) + instance.exclude_patterns = GitIgnore.__read_content(content.split('\n')) + return instance + + @staticmethod + def __read_file(path: Path) -> List[str]: + """ + Read exclude patterns from the provided '.gitignore' file. + + :param path: Path to the '.gitignore' file. + :type path: Path + :return: List with exclude patterns. + """ + with path.open('r') as file: + exclude_patterns = GitIgnore.__read_content(file.readlines()) + return exclude_patterns + + @staticmethod + def __read_content(content: List[str]) -> List[str]: + """ + Parse content lines of the '.gitignore' file and return exclude patterns. + + :param content: Content of the file in the list format (each line is represented as the one element of the + list). + :type content: List[str] + :return: List of exclude patterns. + """ + exclude_patterns = [] + for line in content: + line = line.strip() + if line and not line.startswith('#'): + exclude_patterns.append(line) + return exclude_patterns + + def refresh(self) -> NoReturn: + """ + Refresh exclude patterns from the file. + """ + self.exclude_patterns = self.__read_file(self.__path) + + def save(self, path: Union[str, Path, None] = None) -> NoReturn: + """ + Save current list of exclude patterns to the provided file, if no file has been provided, then save to the + current file. + + :param path: Path where content will be saved. + :type path: Union[str, Path, None] + """ + if path is None: + path = self.__path + else: + path = PathUtil.convert_to_path(path) + with path.open('w', encoding='UTF-8') as file: + for exclude_pattern in self.exclude_patterns: + file.write(f'{exclude_pattern}\r\n') + + +class FilesChangesHandler: + """ + Class tracks and classifies changes in the files within the repository. + """ + START = 'start' + END = 'end' + + ADDED_TAG = 'added' + MODIFIED_TAG = 'modified' + REMOVED_TAG = 'removed' + EXCLUDED_TAG = 'excluded' + + def __init__(self, repository: 'GitRepository'): + self.__repository = repository + self.__files_hashes = {self.START: {}, self.END: {}} + self.__files_status = {} + self.__update_files_hash(self.__repository.path.absolute(), self.__files_hashes[self.START]) + + def update_files_status(self) -> NoReturn: + """ + Method checks status of the files by comparing original files state with current files state. All files changes + are classified as added, modified, or removed. + """ + self.__files_hashes[self.END] = {} + self.__update_files_hash(self.__repository.path.absolute(), self.__files_hashes[self.END]) + self.__files_status = { + self.ADDED_TAG: self.__get_added(self.__files_hashes[self.START], self.__files_hashes[self.END]), + self.MODIFIED_TAG: self.__get_modified(self.__files_hashes[self.START], self.__files_hashes[self.END]), + self.REMOVED_TAG: self.__get_removed(self.__files_hashes[self.START], self.__files_hashes[self.END]) + } + if self.__repository.gitignore: + self.__files_status[self.EXCLUDED_TAG] = self.__get_excluded(self.__files_hashes[self.END], + self.__repository.gitignore.exclude_patterns) + + @property + def files_status(self) -> Dict[str, List[str]]: + """ + Dictionary with changes classified as added, modified, and removed, additionally contains excluded files. + """ + return self.__files_status + + @staticmethod + def __update_files_hash(parent: Path, files_dictionary: Dict[str, float]) -> NoReturn: + """ + Method updates files hashes in the provided dictionary. + + :param parent: Parent path, files hashes will be generated for files under this folder. + :type parent: Path + :param files_dictionary: Dictionary that contains file name and its hash. + :type files_dictionary: Dict[str, str] + """ + for root, _, files in os.walk(parent.absolute()): + for file in files: + absolute_path = Path(root, file) + absolute_path_str = str(absolute_path) + files_dictionary[absolute_path_str] = os.stat(absolute_path_str).st_mtime + + @staticmethod + def __get_added(start: Dict, end: Dict) -> List[str]: + """ + Check which files has been added and return list of file names. + + :param start: Dictionary with files and their hashes at the beginning. + :type start: Dict + :param end: Dictionary with files and their hashes at the end. + :type end: Dict + :return: List of added files. + """ + result = [] + for key, _ in end.items(): + if key not in start.keys(): + result.append(key) + return result + + @staticmethod + def __get_modified(start: Dict, end: Dict) -> List[str]: + """ + Check which files has been modified and return list of file names. + + :param start: Dictionary with files and their hashes at the beginning. + :type start: Dict + :param end: Dictionary with files and their hashes at the end. + :type end: Dict + :return: List of modified files. + """ + result = [] + for key, value in start.items(): + if key in end.keys() and value != end[key]: + result.append(key) + return result + + @staticmethod + def __get_removed(start: Dict, end: Dict) -> List[str]: + """ + Check which files has been removed and return list of file names. + + :param start: Dictionary with files and their hashes at the beginning. + :type start: Dict + :param end: Dictionary with files and their hashes at the end. + :type end: Dict + :return: List of removed files. + """ + result = [] + for key, _ in start.items(): + if key not in end.keys(): + result.append(key) + return result + + @staticmethod + def __get_excluded(files: Dict[str, str], excludes: List[str]) -> List[str]: + """ + Check which files are on the exclude list and return all excluded files. + + :param files: Dictionary with files and their hashes. + :type files: Dict[str, str] + :param excludes: List of exclude patterns. + :type excludes: List[str] + :return: List of files that are excluded. + """ + result = [] + for key, _ in files.items(): + for exclude in excludes: + match = fnmatch.fnmatch(key, exclude) + if match: + result.append(key) + break + return result + + @property + def added(self) -> List[str]: + """ + List of added files. + """ + return self.__files_status.get(self.ADDED_TAG, []) + + @property + def modified(self) -> List[str]: + """ + List of modified files. + """ + return self.__files_status.get(self.MODIFIED_TAG, []) + + @property + def removed(self) -> List[str]: + """ + List of removed files. + """ + return self.__files_status.get(self.REMOVED_TAG, []) + + @property + def excluded(self) -> List[str]: + """ + List of excluded files. + """ + return self.__files_status.get(self.EXCLUDED_TAG, []) + + +class CommitHandler: + """ + Class handles commit operation including tracking of the changed files. + """ + __commit_message: str + __git_repository: 'GitRepository' + __files_changes_handler: FilesChangesHandler + + def __init__(self, message: str, git_repository: 'GitRepository'): + self.__commit_message = message + self.__git_repository = git_repository + self.__files_changes_handler = FilesChangesHandler(git_repository) + + def __enter__(self): + return self.__files_changes_handler + + def __exit__(self, exc_type, exc_val, exc_tb): + changes_found = self.__update_changed_files() + if changes_found: + logging.info('Creating a new commit') + logging.info('Message: %s', self.__commit_message) + options = [ + CommitCommandDefinitions.Options.MESSAGE.create_option(self.__commit_message), + ] + self.__git_repository.git_command.commit(*options) + + def __update_changed_files(self) -> bool: + """ + Add changes done in the files to the tracking. + + :return: True, if any type of modifications happened (add a new file, modify a file, remove a file), otherwise + return False. + """ + self.__files_changes_handler.update_files_status() + changes_found = False + if self.__files_changes_handler.added: + self.__git_repository.add(self.__files_changes_handler.added) + changes_found = True + if self.__files_changes_handler.modified: + self.__git_repository.add(self.__files_changes_handler.modified) + changes_found = True + if self.__files_changes_handler.removed: + self.__git_repository.rm(self.__files_changes_handler.removed) + changes_found = True + return changes_found + + +class CheckoutHandler: + """ + Class handles checkout to another branch in the context manager. Allowing to switch on the another branch in the + context and then reset it back to the original branch. + """ + __new_branch: str + + def __init__(self, new_branch: str, repository: 'GitRepository', old_branch: Optional[str] = None, + create_if_not_exist: bool = False): + self.__new_branch = new_branch + self.__old_branch = old_branch + self.__repository = repository + self.checkout(self.__new_branch, create_if_not_exist) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.checkout(self.__old_branch) + + def checkout(self, branch: Optional[str] = None, create_if_not_exist: bool = False) -> NoReturn: + """ + Method checkouts another branch and refresh active branch with commits list. Optionally it can create branch + if it doesn't exist. + + :param branch: A branch on which it will switch. + :type branch: Optional[str] + :param create_if_not_exist: Handles whether it shall create a new branch, if branch doesn't exist. + :type create_if_not_exist: bool + """ + if branch is None: + branch = self.__new_branch + logging.info('Switching to "%s" branch.', branch) + branch_exist = self.__repository.branches[branch] is not None + options = [] + if not branch_exist and create_if_not_exist: + logging.info('Creating a new branch "%s"', branch) + options.append(CheckoutCommandDefinitions.Options.NEW_BRANCH.create_option(branch)) + else: + options.append(CheckoutCommandDefinitions.Options.BRANCH.create_option(branch)) + self.__repository.git_command.checkout(*options) + self.__repository.refresh_repository(refresh_active_branch=True, refresh_commits=True) + + +@dataclass +class GitObjects: + """ + Class stores different type of git objects, like: remotes, branches, commits, tags. + """ + remotes: Remotes = field(default_factory=Remotes, init=False) + commits: Commits = field(default_factory=Commits, init=False) + tags: Tags = field(default_factory=Tags, init=False) + branches: Branches = field(default_factory=Branches, init=False) + active_branch: Optional[Branch] = field(default=None, init=False) + + +class GitRepository: + """ + Main class that allows to manage git repository. + """ + __repository_information: GitRepositoryPaths + __git_command: GitCommandRunner + __git_config: GitConfig + __gitignore: GitIgnore + __objects: GitObjects + + def __init__(self, path: Union[str, Path]): + self.__git_command = GitCommandRunner() + self.__git_command.working_directory = path + self.__repository_information = GitRepositoryPaths(path) + if not self.__repository_information.path.exists(): + raise GitRepositoryNotFoundException(f'Git repository: {self.__repository_information.path} not exists.') + if not self.__repository_information.git_directory.exists(): + raise NotGitRepositoryException('Provided path is not a git repository, .git directory was not found.') + self.__gitignore = self.__read_git_ignore(self.__repository_information.git_ignore_path, self.__git_command) + self.__git_config = GitConfig(self.__repository_information.git_directory.joinpath('config')) + self.__default_author = self.__get_default_author() + self.__initialize_default_values() + self.refresh_repository(refresh_active_branch=True, refresh_branches=True, refresh_commits=True, + refresh_tags=True, refresh_remotes=True) + + @property + def git_command(self) -> GitCommandRunner: + """ + Git command class instance that allows to execute git commands. + """ + return self.__git_command + + @property + def path(self): + """ + Git repository path. + """ + return self.__repository_information.path + + @property + def git_path(self): + """ + Path to the '.git' directory in the repository. + """ + return self.__repository_information.git_directory + + @property + def gitignore(self): + """ + Path to .gitignore file. + """ + return self.__gitignore + + @property + def current_branch(self) -> Optional[Branch]: + """ + Current branch points on the branch to which the repository is currently configured. + """ + return self.__objects.active_branch + + @property + def remotes(self) -> Remotes: + """ + List of remotes for the repository. + """ + return self.__objects.remotes + + @property + def branches(self) -> Branches: + """ + List of all branches in the repository. + """ + return self.__objects.branches + + @property + def commits(self) -> Commits: + """ + List of all commits in the repository. + """ + return self.__objects.commits + + @property + def tags(self) -> Tags: + """ + List of all tags in the repository. + """ + return self.__objects.tags + + def __initialize_default_values(self): + """ + Initialize default empty values for the repository. + """ + self.__objects = GitObjects() + + def refresh_repository(self, refresh_active_branch: bool = False, refresh_branches: bool = False, + refresh_commits: bool = False, refresh_tags: bool = False, + refresh_remotes: bool = False) -> NoReturn: + """ + Refresh data in the models in the repository. + + :param refresh_active_branch: If True, then refresh current active branch. + :type refresh_active_branch: bool + :param refresh_branches: If True, then refresh list of all branches. + :type refresh_branches: bool + :param refresh_commits: If True, then refresh list of all commits. + :type refresh_commits: bool + :param refresh_tags: If True, then refresh list of all tags. + :type refresh_tags: bool + :param refresh_remotes: If True, then refresh list of all remotes. + :type refresh_remotes: bool + """ + branches_parser = BranchesParser(self.__repository_information, self.__objects.commits) + branches_parser.refresh_active_branch() + if refresh_commits: + try: + commits_parser = CommitsParser(self.__git_command) + self.__objects.commits = commits_parser.commits + except GitException: + self.__objects.commits = Commits() + if refresh_remotes: + self.__objects.remotes = self.__read_remotes() + if refresh_active_branch: + self.__objects.active_branch = Branch(name=branches_parser.active_branch_name, + commit=self.__objects.commits[ + branches_parser.active_branch_commit_hash]) + if refresh_branches: + self.__objects.branches = branches_parser.branches + if refresh_tags: + tags_parser = TagsParser(self.__git_command, self.__objects.commits) + self.__objects.tags = tags_parser.tags + + def __get_default_author(self) -> Author: + """ + Get default author for the repository, based on your configuration. + + :return: Default author for the repository. + """ + user_name_options = [ + ConfigCommandDefinitions.Options.NAME.create_option('user.name') + ] + name = self.__git_command.config(*user_name_options).strip() + user_email_options = [ + ConfigCommandDefinitions.Options.NAME.create_option('user.email') + ] + email = self.__git_command.config(*user_email_options).strip() + return Author(name=name, email=email) + + @staticmethod + def __read_git_ignore(path: Path, git_command: GitCommandRunner) -> Optional[GitIgnore]: + """ + Read .gitignore file. + + :param path: Path to the '.gitignore' file. + :type path: Path + :param git_command: Git command class that wraps git commands. + :type git_command: GitCommandRunner + :return: Git ignore file object. + """ + gitignore = None + if path.exists(): + options = [ShowCommandDefinitions.Options.OBJECTS.create_option([f'HEAD:{path.name}'])] + commit_content = git_command.show(*options) + with path.open('r') as file: + content = file.read() + if content != commit_content: + gitignore = GitIgnore.create_from_content(path, commit_content) + if not gitignore: + gitignore = GitIgnore(path) + return gitignore + + def __read_remotes(self) -> Remotes: + """ + Read remotes to the Remotes object. + + :return: Remotes object with list of all remotes in the repository. + """ + return Remotes(self.__git_config.remotes) + + def commit(self, message: str) -> CommitHandler: + """ + Commit changes to the files. + + :param message: A new commit message. + :type message: str + :return: Commit handler object. + """ + return CommitHandler(message, self) + + def checkout(self, branch: Union[str, Branch], create_if_not_exist: bool = False): + """ + Checkout command that allows to checkout another branch with or without the context manager. + + :param branch: Branch to which it shall switch. + :type branch: Union[str, Branch] + :param create_if_not_exist: If True, then create a new branch if it doesn't exist, otherwise it will fail when + branch doesn't exist. + :type create_if_not_exist: bool + """ + if isinstance(branch, Branch): + branch = branch.name + return CheckoutHandler(branch, self, self.__objects.active_branch.name, create_if_not_exist) + + def create_commit(self, message: str, author: Optional[Author] = None, date: Optional[datetime] = None, + commit_hash: str = '', parent: Union[str, Commit, None] = None) -> Commit: + """ + Method creates a new commit object, without creating the real commit itself. + + :param message: Commit message. + :type message: str + :param author: Author of the commit. + :type author: Optional[Author] + :param date: Date of the commit creation. + :type date: Optional[datetime] + :param commit_hash: Commit object hash. + :type commit_hash: str + :param parent: Parent commit of the current commit. + :type parent: Union[str, Commit, None] + :return: A new commit instance. + """ + if author is None: + author = self.__default_author + if date is None: + date = datetime.now() + if parent and isinstance(parent, str): + parent = self.__objects.commits[parent] + return Commit(message=message, author=author, date=date, commit_hash=commit_hash, parent=parent) + + @classmethod + def init(cls, path: Union[str, Path], *options: GitOption) -> 'GitRepository': + """ + Initialize new git repository and return a new GitRepository instance for that repository. + + :param path: Path to the new git repository. + :type path: Union[str, Path] + :param options: Additional options for the 'git init' command. + :type options: Tuple[GitOption] + :return: GitRepository instance for the new git repository. + """ + if isinstance(path, str): + path = Path(path) + command_options = list(options) + command_options.append(InitCommandDefinitions.Options.DIRECTORY.create_option(str(path.absolute()))) + git_command = GitCommandRunner() + git_command.init(*command_options) + return cls(path) + + @classmethod + def clone(cls, repository: Union[str, Remote], *options: GitOption, + path: Union[str, Path] = None) -> 'GitRepository': + """ + Clone new git repository from the remote and return a new GitRepository instance for that repository. + + :param repository: Remote url or Remote object that points to the remote repository that will be cloned. + :type repository: Union[str, Remote] + :param options: Additional options for the 'git clone' command. + :type options: Tuple[GitOption] + :param path: Path to the new git repository. + :type path: Union[str, Path] + :return: GitRepository instance for the new git repository that has been cloned locally. + """ + if isinstance(path, str): + path = Path(path) + if isinstance(repository, Remote): + repository = repository.url + command_options = list(options) + command_options.append(CloneCommandDefinitions.Options.REPOSITORY.create_option(repository)) + if path is not None: + command_options.append(CloneCommandDefinitions.Options.DIRECTORY.create_option(str(path.absolute()))) + git_command = GitCommandRunner() + git_command.clone(*command_options) + return cls(path) + + def add(self, files: Union[str, Path, List[Union[str, Path]]], *options: GitOption) -> str: + """ + Add a new file or list of files to the tracking. + + :param files: A file or list of files that will be added. + :type files: Union[str, Path, List[Union[str, Path]] + :param options: List of additional options for the 'git add' command. + :type options: Tuple[GitOption] + :return: Output from the 'git add' command, if multiple paths were provided, then joined output will be + returned. + """ + outputs = [] + if isinstance(files, (str, Path)): + files = [files] + for file_path in files: + if isinstance(file_path, Path): + file_path = str(file_path.absolute()) + file_path = [file_path] + command_options = list(options) + command_options.append(AddCommandDefinitions.Options.PATHSPEC.create_option(file_path)) + output = self.__git_command.add(*command_options) + outputs.append(output.strip()) + return '\n'.join(outputs) + + def mv(self, mappings: Union[PathsMapping, List[PathsMapping]], *options: GitOption) -> str: + """ + Move a file or list of files from one place to another and track this change. + + :param mappings: A file mapping or the list of files mappings that contains information about source and + destination. + :type mappings: Union[PathsMapping, List[PathsMapping]] + :param options: List of additional options for the 'git mv' command. + :type options: Tuple[GitOption] + :return: Output from the 'git mv' command, if multiple paths were provided, then joined output will be returned. + """ + outputs = [] + if isinstance(mappings, PathsMapping): + mappings = [mappings] + for mapping in mappings: + mapping.root_path = self.__repository_information.path + command_options = list(options) + command_options.append(MvCommandDefinitions.Options.SOURCE.create_option(str(mapping.source.absolute()))) + command_options.append( + MvCommandDefinitions.Options.DESTINATION.create_option(str(mapping.destination.absolute()))) + output = self.__git_command.mv(*command_options) + outputs.append(output.strip()) + return '\n'.join(outputs) + + def rm(self, files: Union[str, Path, List[Union[str, Path]]], *options: GitOption) -> str: + """ + Remove a file or list of files and track this change. + + :param files: A file or list of files that will be removed. + :type files: Union[str, Path, List[Union[str, Path]]] + :param options: List of additional options for the 'git rm' command. + :type options: Tuple[GitOption] + :return: Output from the 'git rm' command, if multiple paths were provided, then joined output will be returned. + """ + outputs = [] + if isinstance(files, (str, Path)): + files = [files] + raw_repository_path = str(self.__repository_information.path.absolute()) + for file_path in files: + if isinstance(file_path, str) and file_path.startswith(raw_repository_path): + file_path = Path(file_path) + else: + file_path = self.__repository_information.path.joinpath(file_path) + file_path = [str(file_path.absolute())] + command_options = list(options) + command_options.append(RmCommandDefinitions.Options.PATHSPEC.create_option(file_path)) + output = self.__git_command.rm(*command_options) + outputs.append(output.strip()) + return '\n'.join(outputs) + + @staticmethod + def __get_refspec(reference: Optional[Union[Reference, Refspec]]) -> Optional[str]: + """ + Extract refspec string from the Reference or Refspec object. + + :param reference: Reference that will be converted to string, if not possible, then None returned. + :type reference: Optional[str] + :return: Reference converted to the string. + """ + if isinstance(reference, Reference): + refspec = reference.path + elif isinstance(reference, Refspec): + refspec = reference.raw + else: + refspec = None + return refspec + + def pull(self, remote: Remote, *options: GitOption, reference: Optional[Union[Reference, Refspec]] = None) -> str: + """ + Pull changes from the remote repository into local repository. + + :param remote: Remote repository from which changes will be pulled. + :type remote: Remote + :param options: List of additional options for the 'git pull' command. + :type options: Tuple[GitOption] + :param reference: Specific reference that will be pulled. + :type reference: Optional[Union[Reference, Refspec]] + :return: Output from the 'git pull' command. + """ + refspec = self.__get_refspec(reference) + options = list(options) + options.append(PullCommandDefinitions.Options.REPOSITORY.create_option(remote.name)) + if refspec: + options.append(PullCommandDefinitions.Options.REFSPEC.create_option(refspec)) + return self.__git_command.pull(*options) + + def push(self, remote: Remote, *options: GitOption, reference: Optional[Union[Reference, Refspec]] = None) -> str: + """ + Push changes to the remote repository from local repository. + + :param remote: Remote repository to which changes will be pushed. + :type remote: Remote + :param options: List of additional options for the 'git push' command. + :type options: Tuple[GitOption] + :param reference: Specific reference that will be pushed. + :type reference: Optional[Union[Reference, Refspec]] + :return: Output from the 'git push' command. + """ + refspec = self.__get_refspec(reference) + options = list(options) + options.append(PushCommandDefinitions.Options.REPOSITORY.create_option(remote.name)) + if refspec: + options.append(PushCommandDefinitions.Options.REFSPEC.create_option(refspec)) + return self.__git_command.push(*options) diff --git a/sources/models/__init__.py b/sources/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/models/base_classes.py b/sources/models/base_classes.py new file mode 100644 index 0000000..a90d073 --- /dev/null +++ b/sources/models/base_classes.py @@ -0,0 +1,84 @@ +""" +Base Git objects classes, that are used by multiple objects. +""" +from dataclasses import dataclass, field +from typing import Union + + +@dataclass +class Author: + """ + Class represents an author in the git. + """ + __slots__ = ('name', 'email') + name: str + email: str + + +@dataclass +class Reference: + """ + Class represents git reference. + """ + path: str = field(init=False) + + +class Refspec: + """ + Class represents git refspec. + """ + DELIMITER: str = ':' + __source: Reference + __destination: Reference + + @classmethod + def create_from_string(cls, refspec: str) -> 'Refspec': + """ + Create an instance of the Refspec class from the string of format "source:destination". + + :param refspec: Refspec string. + :type refspec: str + :return: Refspec class instance. + """ + source, destination = refspec.split(cls.DELIMITER) + instance = cls() + instance.source = source + instance.destination = destination + return instance + + @property + def source(self) -> Reference: + """ + Refspec source. + """ + return self.__source + + @source.setter + def source(self, source: Union[str, Reference]): + if isinstance(source, str): + refspec = Refspec() + refspec.path = source + source = refspec + self.__source = source + + @property + def destination(self) -> Reference: + """ + Refspec destination. + """ + return self.__destination + + @destination.setter + def destination(self, destination: Union[str, Reference]): + if isinstance(destination, str): + refspec = Refspec() + refspec.path = destination + destination = refspec + self.__destination = destination + + @property + def raw(self) -> str: + """ + Raw refspec string in the format "source:destination". + """ + return self.DELIMITER.join([self.__source.path, self.__destination.path]) diff --git a/sources/models/branches.py b/sources/models/branches.py new file mode 100644 index 0000000..b3d8de8 --- /dev/null +++ b/sources/models/branches.py @@ -0,0 +1,61 @@ +""" +Module contains models for git branches. +""" +from dataclasses import dataclass +from typing import List, Union, Optional + +from sources.models.base_classes import Reference +from sources.models.commits import Commit + + +@dataclass +class Branch(Reference): + """ + Class represents git branch. + """ + name: str + commit: Optional[Commit] = None + + def __post_init__(self): + self.path = '/'.join(['refs', 'heads', self.name]) + + +@dataclass +class RemoteBranch(Branch): + """ + Class represents remote git branch. + """ + + +class Branches: + """ + Class contains list of branches. + """ + __branches: List[Branch] + __current_index: int + + def __init__(self, branches: Optional[List[Branch]] = None): + if branches is None: + self.__branches = [] + else: + self.__branches = branches + + def __getitem__(self, item: Union[int, str]) -> Optional[Branch]: + if isinstance(item, int): + return self.__branches[item] + if isinstance(item, str): + for branch in self.__branches: + if branch.name == item: + return branch + return None + + def __iter__(self): + self.__current_index = 0 + return self + + def __next__(self): + if self.__current_index < len(self.__branches): + element = self.__branches[self.__current_index] + self.__current_index += 1 + return element + raise StopIteration diff --git a/sources/models/commits.py b/sources/models/commits.py new file mode 100644 index 0000000..023e65b --- /dev/null +++ b/sources/models/commits.py @@ -0,0 +1,62 @@ +""" +Module contains models for git commits. +""" +from dataclasses import field, dataclass +from datetime import datetime +from typing import List, Optional, Dict, NoReturn + + +@dataclass +class Commit: + """ + Class represents git commit. + """ + message: str + author: 'Author' + date: datetime + parent: Optional['Commit'] = field(repr=False) + commit_hash: str = field(default_factory=str) + tags: List['Tag'] = field(default_factory=list) + + def add_tag(self, tag: 'Tag') -> NoReturn: + """ + Method adds a new tag to the current commit. + + :param tag: A new tag that will be added. + :type tag: Tag + """ + self.tags.append(tag) + + +class Commits: + """ + Class contains list of commits. + """ + __commits: Dict[str, Commit] + __current_index: int + __keys: List[str] + + def __init__(self, commits: Optional[Dict[str, Commit]] = None): + if commits is None: + self.__commits = {} + else: + self.__commits = commits + + def __add__(self, other: Commit): + self.__commits[other.commit_hash] = other + return self + + def __getitem__(self, item: str): + return self.__commits.get(item, None) + + def __iter__(self): + self.__current_index = 0 + self.__keys = list(self.__commits.keys()) + return self + + def __next__(self): + if self.__current_index < len(self.__keys): + element = self.__commits[self.__keys[self.__current_index]] + self.__current_index += 1 + return element + raise StopIteration diff --git a/sources/models/remotes.py b/sources/models/remotes.py new file mode 100644 index 0000000..fc57c9d --- /dev/null +++ b/sources/models/remotes.py @@ -0,0 +1,48 @@ +""" +Module contains models for git remotes. +""" +from dataclasses import dataclass +from typing import List, Union, Optional + + +@dataclass +class Remote: + """ + Class represents git remote. + """ + name: str + url: str + + +class Remotes: + """ + Class contains list of remotes. + """ + __remotes: List[Remote] + __current_index: int + + def __init__(self, remotes: Optional[List[Remote]] = None): + if remotes is None: + self.__remotes = [] + else: + self.__remotes = remotes + + def __getitem__(self, item: Union[int, str]): + if isinstance(item, int): + return self.__remotes[item] + if isinstance(item, str): + for remote in self.__remotes: + if remote.name == item: + return remote + return None + + def __iter__(self): + self.__current_index = 0 + return self + + def __next__(self): + if self.__current_index < len(self.__remotes): + element = self.__remotes[self.__current_index] + self.__current_index += 1 + return element + raise StopIteration diff --git a/sources/models/repository_information.py b/sources/models/repository_information.py new file mode 100644 index 0000000..49233fe --- /dev/null +++ b/sources/models/repository_information.py @@ -0,0 +1,38 @@ +""" +Module contains class with git repository paths. +""" +from pathlib import Path +from typing import Union + +from sources.utils.path_util import PathUtil + + +class GitRepositoryPaths: + """ + Class that stores git repository paths. + """ + def __init__(self, path: Union[str, Path]): + self.__repository_path = PathUtil.convert_to_path(path) + self.__git_directory = self.__repository_path.joinpath('.git') + self.__git_ignore_path = self.__repository_path.joinpath('.gitignore') + + @property + def path(self): + """ + Path to the git repository. + """ + return self.__repository_path + + @property + def git_directory(self): + """ + Path to the git directory within the repository. + """ + return self.__git_directory + + @property + def git_ignore_path(self): + """ + Path to the git ignore file within repository. + """ + return self.__git_ignore_path diff --git a/sources/models/tags.py b/sources/models/tags.py new file mode 100644 index 0000000..30e0ca8 --- /dev/null +++ b/sources/models/tags.py @@ -0,0 +1,72 @@ +""" +Module contains models for git tags. +""" +from dataclasses import dataclass +from typing import List, Union, Optional + +from sources.models.base_classes import Author, Reference +from sources.models.commits import Commit + + +@dataclass +class Tag(Reference): + """ + Base class that represents git tag. + """ + name: str + commit: Commit + + def __post_init__(self): + self.path = '/'.join(['refs', 'tags', self.name]) + if self.commit and isinstance(self.commit, Commit): + self.commit.add_tag(self) + + +@dataclass +class LightweightTag(Tag): + """ + Class that represents lightweight git tag. + """ + + +@dataclass +class AnnotatedTag(Tag): + """ + Class that represents annotated git tag. + """ + tagger: Author + message: str + + +class Tags: + """ + Class contains list of tags. + """ + __tags: List[Tag] + __current_index: int + + def __init__(self, tags: Optional[List[Tag]] = None): + if tags is None: + self.__tags = [] + else: + self.__tags = tags + + def __getitem__(self, item: Union[int, str]) -> Optional[Tag]: + if isinstance(item, int): + return self.__tags[item] + if isinstance(item, str): + for tag in self.__tags: + if tag.name == item: + return tag + return None + + def __iter__(self): + self.__current_index = 0 + return self + + def __next__(self): + if self.__current_index < len(self.__tags): + element = self.__tags[self.__current_index] + self.__current_index += 1 + return element + raise StopIteration diff --git a/sources/options/__init__.py b/sources/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/options/add_options.py b/sources/options/add_options.py new file mode 100644 index 0000000..5ba7fc9 --- /dev/null +++ b/sources/options/add_options.py @@ -0,0 +1,42 @@ +""" +Module contains classes that defines options that could be configured for 'git add' command. + +Reference: https://git-scm.com/docs/git-add +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class AddCommandDefinitions(GitCommand): + """ + Options definitions class for 'git add' command, it contains definitions of the options for 'git add'. + """ + + class Options(CommandOptions): + """ + Options class for 'git add' command, it contains options that can be configured. + """ + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + FORCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='force', short_option=False), + GitOptionNameAlias(name='f', short_option=True), + ]) + UPDATE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='update', short_option=False), + GitOptionNameAlias(name='u', short_option=True), + ]) + PATHSPEC = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='pathspec', short_option=False), + ]) + + def __init__(self): + super().__init__('add') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORCE, type=bool), + GitOptionDefinition(name_aliases=self.Options.UPDATE, type=bool), + GitOptionDefinition(name_aliases=self.Options.PATHSPEC, type=list, positional=True, position=0), + ] diff --git a/sources/options/checkout_options.py b/sources/options/checkout_options.py new file mode 100644 index 0000000..b334fba --- /dev/null +++ b/sources/options/checkout_options.py @@ -0,0 +1,45 @@ +""" +Module contains classes that defines options that could be configured for 'git checkout' command. + +Reference: https://git-scm.com/docs/git-checkout +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class CheckoutCommandDefinitions(GitCommand): + """ + Options definitions class for 'git checkout' command, it contains definitions of the options for 'git checkout'. + """ + + class Options(CommandOptions): + """ + Options class for 'git checkout' command, it contains options that can be configured. + """ + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + FORCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='force', short_option=False), + GitOptionNameAlias(name='f', short_option=True), + ]) + BRANCH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='branch', short_option=False), + ]) + NEW_BRANCH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='b', short_option=True), + ]) + START_POINT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='start-point', short_option=False), + ]) + + def __init__(self): + super().__init__('checkout') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORCE, type=bool), + GitOptionDefinition(name_aliases=self.Options.BRANCH, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.NEW_BRANCH, type=str), + GitOptionDefinition(name_aliases=self.Options.START_POINT, type=str, positional=True, position=0), + ] diff --git a/sources/options/clone_options.py b/sources/options/clone_options.py new file mode 100644 index 0000000..07c56ad --- /dev/null +++ b/sources/options/clone_options.py @@ -0,0 +1,78 @@ +""" +Module contains classes that defines options that could be configured for 'git clone' command. + +Reference: https://git-scm.com/docs/git-clone +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAlias, \ + GitOptionNameAliases + + +class CloneCommandDefinitions(GitCommand): + """ + Options definitions class for 'git clone' command, it contains definitions of the options for 'git clone'. + """ + + class Options(CommandOptions): + """ + Options class for 'git clone' command, it contains options that can be configured. + """ + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + LOCAL = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='local', short_option=False), + GitOptionNameAlias(name='l', short_option=True), + ]) + NO_HARDLINKS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='no-hardlinks', short_option=False), + ]) + SHARED = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='shared', short_option=False), + GitOptionNameAlias(name='s', short_option=True), + ]) + BARE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='bare', short_option=False), + ]) + ORIGIN = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='origin', short_option=False), + GitOptionNameAlias(name='o', short_option=True), + ]) + BRANCH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='branch', short_option=False), + GitOptionNameAlias(name='b', short_option=True), + ]) + NO_TAGS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='no-tags', short_option=False), + ]) + RECURSE_SUBMODULES = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='recurse-submodules', short_option=False), + ]) + REPOSITORY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='repository', short_option=False), + ]) + DIRECTORY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='directory', short_option=False), + ]) + + def __init__(self): + super().__init__('clone') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.LOCAL, type=bool), + GitOptionDefinition(name_aliases=self.Options.NO_HARDLINKS, type=bool), + GitOptionDefinition(name_aliases=self.Options.SHARED, type=bool), + GitOptionDefinition(name_aliases=self.Options.BARE, type=bool), + GitOptionDefinition(name_aliases=self.Options.ORIGIN, type=bool), + GitOptionDefinition(name_aliases=self.Options.BRANCH, type=bool), + GitOptionDefinition(name_aliases=self.Options.NO_TAGS, type=bool), + GitOptionDefinition(name_aliases=self.Options.RECURSE_SUBMODULES, type=(bool, str)), + GitOptionDefinition(name_aliases=self.Options.REPOSITORY, type=str, positional=True, position=0, + required=True), + GitOptionDefinition(name_aliases=self.Options.DIRECTORY, type=str, positional=True, position=1), + ] diff --git a/sources/options/commit_options.py b/sources/options/commit_options.py new file mode 100644 index 0000000..cd684ab --- /dev/null +++ b/sources/options/commit_options.py @@ -0,0 +1,59 @@ +""" +Module contains classes that defines options that could be configured for 'git commit' command. + +Reference: https://git-scm.com/docs/git-commit +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class CommitCommandDefinitions(GitCommand): + """ + Options definitions class for 'git commit' command, it contains definitions of the options for 'git commit'. + """ + class Options(CommandOptions): + """ + Options class for 'git commit' command, it contains options that can be configured. + """ + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + ALL = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='all', short_option=False), + GitOptionNameAlias(name='a', short_option=True), + ]) + PATCH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='patch', short_option=False), + GitOptionNameAlias(name='p', short_option=True), + ]) + MESSAGE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='message', short_option=False), + GitOptionNameAlias(name='m', short_option=True), + ]) + AMEND = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='amend', short_option=False), + ]) + NO_EDIT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='no-edit', short_option=False), + ]) + PATHSPEC = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='pathspec', short_option=False), + ]) + + def __init__(self): + super().__init__('commit') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.ALL, type=bool), + GitOptionDefinition(name_aliases=self.Options.PATCH, type=bool), + GitOptionDefinition(name_aliases=self.Options.MESSAGE, type=str), + GitOptionDefinition(name_aliases=self.Options.AMEND, type=bool), + GitOptionDefinition(name_aliases=self.Options.NO_EDIT, type=bool), + GitOptionDefinition(name_aliases=self.Options.PATHSPEC, type=str, positional=True, position=0), + ] diff --git a/sources/options/config_options.py b/sources/options/config_options.py new file mode 100644 index 0000000..749e125 --- /dev/null +++ b/sources/options/config_options.py @@ -0,0 +1,34 @@ +""" +Module contains classes that defines options that could be configured for 'git config' command. + +Reference: https://git-scm.com/docs/git-config +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class ConfigCommandDefinitions(GitCommand): + """ + Options definitions class for 'git config' command, it contains definitions of the options for 'git config'. + """ + class Options(CommandOptions): + """ + Options class for 'git config' command, it contains options that can be configured. + """ + NAME = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='name', short_option=False), + ]) + VALUE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='value', short_option=False), + ]) + VALUE_PATTERN = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='value-pattern', short_option=False), + ]) + + def __init__(self): + super().__init__('config') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.NAME, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.VALUE, type=str, positional=True, position=1), + GitOptionDefinition(name_aliases=self.Options.VALUE_PATTERN, type=str, positional=True, position=2), + ] diff --git a/sources/options/for_each_ref_options.py b/sources/options/for_each_ref_options.py new file mode 100644 index 0000000..8379512 --- /dev/null +++ b/sources/options/for_each_ref_options.py @@ -0,0 +1,71 @@ +""" +Module contains classes that defines options that could be configured for 'git for-each-ref' command. + +Reference: https://git-scm.com/docs/git-for-each-ref +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class ForEachRefCommandDefinitions(GitCommand): + """ + Options definitions class for 'git for-each-ref' command, it contains definitions of the options for + 'git for-each-ref'. + """ + class Options(CommandOptions): + """ + Options class for 'git for-each-ref' command, it contains options that can be configured. + """ + COUNT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='count', short_option=False), + ]) + SORT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='sort', short_option=False), + ]) + FORMAT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='format', short_option=False), + ]) + POINTS_AT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='points-at', short_option=False), + ]) + MERGED = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='merged', short_option=False), + ]) + NO_MERGED = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='no-merged', short_option=False), + ]) + CONTAINS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='contains', short_option=False), + ]) + NO_CONTAINS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='no-contains', short_option=False), + ]) + IGNORE_CASE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='ignore-case', short_option=False), + ]) + OMIT_EMPTY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='omit-empty', short_option=False), + ]) + EXCLUDE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='exclude', short_option=False), + ]) + PATTERN = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='pattern', short_option=False), + ]) + + def __init__(self): + super().__init__('for-each-ref') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.COUNT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.SORT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.FORMAT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.POINTS_AT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.MERGED, type=(bool, str), separator='='), + GitOptionDefinition(name_aliases=self.Options.NO_MERGED, type=(bool, str), separator='='), + GitOptionDefinition(name_aliases=self.Options.CONTAINS, type=(bool, str), separator='='), + GitOptionDefinition(name_aliases=self.Options.NO_CONTAINS, type=(bool, str), separator='='), + GitOptionDefinition(name_aliases=self.Options.IGNORE_CASE, type=bool), + GitOptionDefinition(name_aliases=self.Options.OMIT_EMPTY, type=bool), + GitOptionDefinition(name_aliases=self.Options.EXCLUDE, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.PATTERN, type=str, positional=True, position=0), + ] diff --git a/sources/options/init_options.py b/sources/options/init_options.py new file mode 100644 index 0000000..7329987 --- /dev/null +++ b/sources/options/init_options.py @@ -0,0 +1,40 @@ +""" +Module contains classes that defines options that could be configured for 'git init' command. + +Reference: https://git-scm.com/docs/git-init +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class InitCommandDefinitions(GitCommand): + """ + Options definitions class for 'git init' command, it contains definitions of the options for 'git init'. + """ + class Options(CommandOptions): + """ + Options class for 'git init' command, it contains options that can be configured. + """ + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + BARE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='bare', short_option=False), + ]) + INITIAL_BRANCH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='initial-branch', short_option=False), + GitOptionNameAlias(name='b', short_option=True), + ]) + DIRECTORY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='directory', short_option=False), + ]) + + def __init__(self): + super().__init__('init') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.BARE, type=bool), + GitOptionDefinition(name_aliases=self.Options.INITIAL_BRANCH, type=bool), + GitOptionDefinition(name_aliases=self.Options.DIRECTORY, type=str, positional=True, position=0), + ] diff --git a/sources/options/log_options.py b/sources/options/log_options.py new file mode 100644 index 0000000..2166bd8 --- /dev/null +++ b/sources/options/log_options.py @@ -0,0 +1,59 @@ +""" +Module contains classes that defines options that could be configured for 'git log' command. + +Reference: https://git-scm.com/docs/git-log +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class LogCommandDefinitions(GitCommand): + """ + Options definitions class for 'git log' command, it contains definitions of the options for 'git log'. + """ + class Options(CommandOptions): + """ + Options class for 'git log' command, it contains options that can be configured. + """ + MAX_COUNT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='max-count', short_option=False), + GitOptionNameAlias(name='n', short_option=True), + ]) + SKIP = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='skip', short_option=False), + ]) + BRANCHES = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='branches', short_option=False), + ]) + ALL = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='all', short_option=False), + ]) + FORMAT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='format', short_option=False), + ]) + PRETTY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='pretty', short_option=False), + ]) + DATE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='date', short_option=False), + ]) + REVISION_RANGE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='revision-range', short_option=False), + ]) + PATH = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='path', short_option=False), + ]) + + def __init__(self): + super().__init__('log') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.MAX_COUNT, type=int), + GitOptionDefinition(name_aliases=self.Options.SKIP, type=int), + GitOptionDefinition(name_aliases=self.Options.BRANCHES, type=(bool, str)), + GitOptionDefinition(name_aliases=self.Options.ALL, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORMAT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.PRETTY, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.DATE, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.REVISION_RANGE, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.PATH, type=str, positional=True, position=1), + ] diff --git a/sources/options/mv_options.py b/sources/options/mv_options.py new file mode 100644 index 0000000..7fcdaee --- /dev/null +++ b/sources/options/mv_options.py @@ -0,0 +1,40 @@ +""" +Module contains classes that defines options that could be configured for 'git mv' command. + +Reference: https://git-scm.com/docs/git-mv +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class MvCommandDefinitions(GitCommand): + """ + Options definitions class for 'git mv' command, it contains definitions of the options for 'git mv'. + """ + class Options(CommandOptions): + """ + Options class for 'git mv' command, it contains options that can be configured. + """ + FORCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='force', short_option=False), + GitOptionNameAlias(name='f', short_option=True), + ]) + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + SOURCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='source', short_option=False), + ]) + DESTINATION = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='destination', short_option=False), + ]) + + def __init__(self): + super().__init__('mv') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.FORCE, type=bool), + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.SOURCE, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.DESTINATION, type=str, positional=True, position=1), + ] diff --git a/sources/options/options.py b/sources/options/options.py new file mode 100644 index 0000000..cab6086 --- /dev/null +++ b/sources/options/options.py @@ -0,0 +1,329 @@ +""" +Module contains base classes for git command options. +""" +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Any, Union, Tuple, Optional, Type, NoReturn + +from sources.exceptions import GitMissingDefinitionException, GitIncorrectPositionalOptionDefinitionException, \ + GitMissingRequiredOptionsException, GitIncorrectOptionValueException + + +@dataclass +class GitOption: + """ + Class represents one git command option, it consists from the option name and the value. + """ + name: str + value: Any + + +@dataclass +class GitOptionNameAlias: + """ + Class stores fields necessary to define git option alias. + """ + name: str + short_option: bool = field(default=False) + + +@dataclass +class GitOptionNameAliases: + """ + Class stores all aliases for the git option. + """ + aliases: List[GitOptionNameAlias] = field(default_factory=list) + + def get_aliases(self, short_option: bool) -> List[GitOptionNameAlias]: + """ + Return list of the aliases limited to short options or long options. + + :param short_option: If True, then returns all short option aliases, otherwise returns all long aliases. + :type short_option: bool + :return: Found aliases that satisfies criteria. + """ + found_aliases = [] + for alias in self.aliases: + if alias.short_option == short_option: + found_aliases.append(alias) + return found_aliases + + def has_short_aliases(self) -> bool: + """ + Returns whether aliases list has short options. + + :return: True, if there is at least one short option alias on the list of aliases, otherwise False. + """ + return len(self.get_aliases(short_option=True)) > 0 + + def has_long_aliases(self) -> bool: + """ + Returns whether aliases list has long options. + + :return: True, if there is at least one long option alias on the list of aliases, otherwise False. + """ + return len(self.get_aliases(short_option=False)) > 0 + + def exists(self, name: str) -> bool: + """ + Check is alias with provided name exists on the list of the aliases. + + :return: True, if alias exists, otherwise False. + """ + for alias in self.aliases: + if alias.name == name: + return True + return False + + def get_names(self) -> List[str]: + """ + Collect and return all aliases names. + + :return: List with aliases names. + """ + names = [] + for alias in self.aliases: + names.append(alias.name) + return names + + +@dataclass +class GitOptionDefinition: + """ + Class defines git option for the git command. It describes option behaviour and some attributes applicable for this + option. + """ + name_aliases: Union[GitOptionNameAliases, 'CommandOptions'] + type: Union[Type, Tuple] + required: bool = field(default=False) + positional: bool = field(default=False) + position: Optional[int] = field(default=None) + choices: Union[list, Type[Enum]] = field(default=None) + separator: str = field(default=' ') + + def __post_init__(self): + if isinstance(self.name_aliases, CommandOptions): + self.name_aliases = self.name_aliases.value + + def compare_with_option(self, other: GitOption) -> bool: + """ + Method compares option definition with option itself. It validates the value of the option and checks that it is + the same as the one that is defined in the option definition. All options are matched by their name. + + :param other: Git option that should be compared with current definition. + :type other: GitOption + :return: True, if option is matching the definition, otherwise False. + """ + is_the_same = True + if isinstance(self.type, tuple): + types = self.type + else: + types = tuple([self.type]) + if not self.name_aliases.exists(other.name): + is_the_same = False + if type(other.value) not in types: + is_the_same = False + return is_the_same + + +class GitCommand: + """ + Class defines a git command and stores all options definitions for this command. + """ + command_name: str + definitions: List[GitOptionDefinition] + + def __init__(self, subcommand_name: str): + self.command_name = subcommand_name + self.definitions = [] + + def get_definition(self, option: GitOption) -> Optional[GitOptionDefinition]: + """ + Method receives git option and searches through all command definitions for the definition that is matching this + option. + + :param option: Git option for which it shall return its definition. + :type option: GitOption + :return: Git option definition, if it was found, otherwise None. + """ + found_definition = None + for definition in self.definitions: + if definition.compare_with_option(option): + found_definition = definition + break + return found_definition + + def validate(self, options: Union[GitOption, List[GitOption]]) -> NoReturn: + """ + Validate git options with their definitions. + + :param options: List of options for this command that will be validated. + :type options: Union[GitOption, List[GitOption]] + :raises GitMissingDefinitionException: If option doesn't have definition. + :raises GitIncorrectOptionValueException: If option has choices, then check that the value is present on the + choices list. + :raises GitIncorrectPositionalOptionDefinitionException: If there is a positional option of the list type that + is not defined on the last position. + :raises GitMissingRequiredOptionsException: If not all required options are present. + """ + if isinstance(options, GitOption): + options = [options] + for option in options: + definition = self.get_definition(option) + if not definition: + raise GitMissingDefinitionException( + f'definition for "{option.name}" of type "{type(option.value).__name__}" was not found') + if not self.validate_choices(option, definition): + raise GitIncorrectOptionValueException( + f'value "{option.value}" is not on choices list {definition.choices}') + all_positional_list_options_correct, incorrect_option_name = self.validate_positional_list() + if not all_positional_list_options_correct: + raise GitIncorrectPositionalOptionDefinitionException( + f'positional option "{incorrect_option_name}" of type "list" only can be defined as the last option') + all_required_present, missing_required = self.validate_required(options) + if not all_required_present: + raise GitMissingRequiredOptionsException(f'some required options are missing {missing_required}') + + def validate_positional_list(self) -> Tuple[bool, Optional[str]]: + """ + Method checks that there are no positional options of the list type that are defined not on the last position. + + :return: Tuple with boolean and optional string, boolean contains True if everything is correct and False if + there is an incorrect option. Optional string contains all aliases for the definition which failed the + check, where if all positional options passed the check, then None will be returned. + """ + positions = [definition.position for definition in self.definitions if definition.positional] + positions = sorted(positions) + last_position = positions[-1] + for definition in self.definitions: + if definition.type == list and definition.position != last_position: + names = ', '.join(definition.name_aliases.get_names()) + return False, f'[{names}]' + return True, None + + def validate_required(self, options: Union[GitOption, List[GitOption]]) -> Tuple[bool, List[str]]: + """ + Method checks that all required options are present. + + :param options: List of provided options for the command. + :type options: Union[GitOption, List[GitOption]] + :return: Tuple with boolean and list of strings. Boolean is set as True, if all required options are present, + otherwise it is set as False. List of string contains name of the options that are required, but they are + missing. + """ + required_definitions = [definition.name_aliases for definition in self.definitions if definition.required] + for option in options: + for required_definition in required_definitions: + if required_definition.exists(option.name): + required_definitions.remove(required_definition) + break + missing_definitions = [] + for required_definition in required_definitions: + aliases = '|'.join(required_definition.get_names()) + missing_definitions.append(f'{aliases}') + return len(required_definitions) == 0, missing_definitions + + def validate_choices(self, option: GitOption, definition: Optional[GitOptionDefinition] = None) -> bool: + """ + Method checks that option value is one of the values on the choices list, where if choices list is None, then + it can be any value. + + :param option: An option that will be validated. + :type option: GitOption + :param definition: Definition for the provided option. + :type definition: Optional[GitOptionDefinition] + :return: True, if definition choices are None or option value is on the choices list, otherwise return False. + """ + if definition is None: + definition = self.get_definition(option) + if definition.choices is None: + return True + return option.value in [choice.value if isinstance(choice, Enum) else choice for choice in definition.choices] + + def __transform_positional_options_to_command(self, positional_options: List[GitOption]) -> List[Union[str, int]]: + """ + Method transforms positional options into commands list. + + :param positional_options: List of positional options. + :type positional_options: List[GitOption] + :return: Transformed list with positional options. + """ + command = [] + positional_order = [definition for definition in self.definitions if definition.positional] + positional_order = sorted(positional_order, key=lambda positional: positional.position) + positional_order = [positional.name_aliases for positional in positional_order] + for positional_name in positional_order: + for positional_option in positional_options: + if not positional_name.exists(positional_option.name): + continue + if isinstance(positional_option.value, list): + values = positional_option.value + else: + values = [positional_option.value] + for value in values: + command.append(value) + positional_options.remove(positional_option) + break + return command + + def transform_to_command(self, options: Union[GitOption, List[GitOption]]) -> List[Union[str, int]]: + """ + Transform git option objects into list of string and integer options. + + :param options: List of git option objects. + :type options: Union[GitOption, List[GitOption]] + :return: Transformed git command, that consists from command and its options. + """ + positional_options = [] + command = [self.command_name] + for option in options: + definition = self.get_definition(option) + if definition.positional: + positional_options.append(option) + continue + if not isinstance(option.value, bool) or option.value is not False: + if definition.name_aliases.has_short_aliases(): + short_alias = definition.name_aliases.get_aliases(short_option=True)[0].name + command.append(f'-{short_alias}') + else: + long_alias = definition.name_aliases.get_aliases(short_option=False)[0].name + command.append(f'--{long_alias}') + if not isinstance(option.value, bool) and definition.separator == ' ': + command.append(option.value) + elif not isinstance(option.value, bool): + command[-1] = f'{command[-1]}{definition.separator}{option.value}' + command.extend(self.__transform_positional_options_to_command(positional_options)) + return command + + +class CommandOptions(Enum): + """ + Base class for git options enum. + """ + @classmethod + def create_from_value(cls, value: str) -> Optional['CommandOptions']: + """ + Create class instance based on the value. + + :param value: Enum value from the class. + :type value: str + :return: CommandOptions instance. + """ + for element in cls: + if element.value == value: + return element + return None + + def create_option(self, value: Any) -> GitOption: + """ + Create GitOption with provided value from the option class instance. + + :param value: A value for GitOption. + :type value: Any + :return: A new GitOption object for this option with the provided value. + """ + if self.value.has_long_aliases(): + name_alias = self.value.get_aliases(short_option=False)[0] + else: + name_alias = self.value.get_aliases(short_option=True)[0] + return GitOption(name=name_alias.name, value=value) diff --git a/sources/options/pull_options.py b/sources/options/pull_options.py new file mode 100644 index 0000000..87a35d4 --- /dev/null +++ b/sources/options/pull_options.py @@ -0,0 +1,75 @@ +""" +Module contains classes that defines options that could be configured for 'git pull' command. + +Reference: https://git-scm.com/docs/git-pull +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class PullCommandDefinitions(GitCommand): + """ + Options definitions class for 'git pull' command, it contains definitions of the options for 'git pull'. + """ + + class Options(CommandOptions): + """ + Options class for 'git pull' command, it contains options that can be configured. + """ + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + RECURSE_SUBMODULES = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='recurse-submodules', short_option=False), + ]) + COMMIT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='commit', short_option=False), + ]) + FAST_FORWARD_ONLY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='ff-only', short_option=False), + ]) + FAST_FORWARD = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='ff', short_option=False), + ]) + ALL = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='all', short_option=False), + ]) + FORCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='force', short_option=False), + GitOptionNameAlias(name='f', short_option=True), + ]) + REPOSITORY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='repository', short_option=False), + ]) + REFSPEC = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='refspec', short_option=False), + ]) + + class RecurseSubmodulesChoices(CommandOptions): + """ + Class represents choices enum for recurse-submodule option. + """ + YES = 'yes' + ON_DEMAND = 'on-demand' + NO = 'no' + + def __init__(self): + super().__init__('pull') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.RECURSE_SUBMODULES, type=str, + choices=self.RecurseSubmodulesChoices, separator='='), + GitOptionDefinition(name_aliases=self.Options.COMMIT, type=bool), + GitOptionDefinition(name_aliases=self.Options.FAST_FORWARD_ONLY, type=bool), + GitOptionDefinition(name_aliases=self.Options.FAST_FORWARD, type=bool), + GitOptionDefinition(name_aliases=self.Options.ALL, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORCE, type=bool), + GitOptionDefinition(name_aliases=self.Options.REPOSITORY, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.REFSPEC, type=str, positional=True, position=1), + ] diff --git a/sources/options/push_options.py b/sources/options/push_options.py new file mode 100644 index 0000000..a850931 --- /dev/null +++ b/sources/options/push_options.py @@ -0,0 +1,70 @@ +""" +Module contains classes that defines options that could be configured for 'git push' command. + +Reference: https://git-scm.com/docs/git-push +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class PushCommandDefinitions(GitCommand): + """ + Options definitions class for 'git push' command, it contains definitions of the options for 'git push'. + """ + + class Options(CommandOptions): + """ + Options class for 'git push' command, it contains options that can be configured. + """ + VERBOSE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='verbose', short_option=False), + GitOptionNameAlias(name='v', short_option=True), + ]) + RECURSE_SUBMODULES = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='recurse-submodules', short_option=False), + ]) + ALL = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='all', short_option=False), + ]) + BRANCHES = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='branches', short_option=False), + ]) + PRUNE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='prune', short_option=False), + ]) + DELETE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='delete', short_option=False), + ]) + TAGS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='tags', short_option=False), + ]) + REPOSITORY = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='repository', short_option=False), + ]) + REFSPEC = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='refspec', short_option=False), + ]) + + class RecurseSubmodulesChoices(CommandOptions): + """ + Class represents choices enum for recurse-submodule option. + """ + CHECK = 'check' + ON_DEMAND = 'on-demand' + ONLY = 'only' + NO = 'no' + + def __init__(self): + super().__init__('push') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.VERBOSE, type=bool), + GitOptionDefinition(name_aliases=self.Options.RECURSE_SUBMODULES, type=str, + choices=self.RecurseSubmodulesChoices, separator='='), + GitOptionDefinition(name_aliases=self.Options.ALL, type=bool), + GitOptionDefinition(name_aliases=self.Options.BRANCHES, type=bool), + GitOptionDefinition(name_aliases=self.Options.PRUNE, type=bool), + GitOptionDefinition(name_aliases=self.Options.DELETE, type=bool), + GitOptionDefinition(name_aliases=self.Options.TAGS, type=bool), + GitOptionDefinition(name_aliases=self.Options.REPOSITORY, type=str, positional=True, position=0), + GitOptionDefinition(name_aliases=self.Options.REFSPEC, type=str, positional=True, position=1), + ] diff --git a/sources/options/rm_options.py b/sources/options/rm_options.py new file mode 100644 index 0000000..83f5760 --- /dev/null +++ b/sources/options/rm_options.py @@ -0,0 +1,40 @@ +""" +Module contains classes that defines options that could be configured for 'git rm' command. + +Reference: https://git-scm.com/docs/git-rm +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class RmCommandDefinitions(GitCommand): + """ + Options definitions class for 'git rm' command, it contains definitions of the options for 'git rm'. + """ + class Options(CommandOptions): + """ + Options class for 'git rm' command, it contains options that can be configured. + """ + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + FORCE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='force', short_option=False), + GitOptionNameAlias(name='f', short_option=True), + ]) + RECURSIVE = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='r', short_option=True), + ]) + PATHSPEC = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='pathspec', short_option=False), + ]) + + def __init__(self): + super().__init__('rm') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORCE, type=bool), + GitOptionDefinition(name_aliases=self.Options.RECURSIVE, type=bool), + GitOptionDefinition(name_aliases=self.Options.PATHSPEC, type=list, positional=True, position=0), + ] diff --git a/sources/options/show_options.py b/sources/options/show_options.py new file mode 100644 index 0000000..71db93c --- /dev/null +++ b/sources/options/show_options.py @@ -0,0 +1,35 @@ +""" +Module contains classes that defines options that could be configured for 'git show' command. + +Reference: https://git-scm.com/docs/git-show +""" +from sources.options.options import GitCommand, GitOptionDefinition, CommandOptions, GitOptionNameAliases, \ + GitOptionNameAlias + + +class ShowCommandDefinitions(GitCommand): + """ + Options definitions class for 'git show' command, it contains definitions of the options for 'git show'. + """ + class Options(CommandOptions): + """ + Options class for 'git show' command, it contains options that can be configured. + """ + QUIET = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='quiet', short_option=False), + GitOptionNameAlias(name='q', short_option=True), + ]) + FORMAT = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='format', short_option=False), + ]) + OBJECTS = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='objects', short_option=False), + ]) + + def __init__(self): + super().__init__('show') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.QUIET, type=bool), + GitOptionDefinition(name_aliases=self.Options.FORMAT, type=str, separator='='), + GitOptionDefinition(name_aliases=self.Options.OBJECTS, type=list, positional=True, position=0), + ] diff --git a/sources/parsers/__init__.py b/sources/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/parsers/branches_parser.py b/sources/parsers/branches_parser.py new file mode 100644 index 0000000..de41d7c --- /dev/null +++ b/sources/parsers/branches_parser.py @@ -0,0 +1,105 @@ +""" +Module contains parser for the git branches. +""" +import re +from typing import Optional, NoReturn + +from sources.models.branches import Branches, Branch +from sources.models.commits import Commits +from sources.models.repository_information import GitRepositoryPaths + + +class BranchesParser: + """ + Class parses git branches. + """ + ACTIVE_BRANCH_PATTERN: str = r'ref:\s*(?Prefs/heads/(?P.*))' + PACKED_BRANCH_PATTERN: str = (r'^\s*(?P[0-9a-fA-F]+)\s+' + r'refs/(?Pheads|remotes/[A-Za-z0-9._-]+)/(?P.+)$') + __active_branch_name: Optional[str] + __active_branch_commit_hash: Optional[str] + + def __init__(self, repository_information: GitRepositoryPaths, commits: Commits): + self.__repository_information = repository_information + self.__active_branch_name = None + self.__active_branch_commit_hash = None + self.__active_branch_regex = re.compile(self.ACTIVE_BRANCH_PATTERN, flags=re.MULTILINE) + self.__packed_branch_regex = re.compile(self.PACKED_BRANCH_PATTERN) + self.__commits = commits + + @property + def active_branch_name(self) -> Optional[str]: + """ + Active branch name, it is the branch to which the repository is currently configured. + """ + return self.__active_branch_name + + @property + def active_branch_commit_hash(self) -> Optional[str]: + """ + Active branch commit hash, it is the commit hash of the last commit on the branch to which the repository is + currently configured. + """ + return self.__active_branch_commit_hash + + def refresh_active_branch(self) -> NoReturn: + """ + Method refreshes values of the active branch name and active branch commit hash. + """ + head_file = self.__repository_information.git_directory.joinpath('HEAD') + with head_file.open('r') as file: + content = file.read().strip() + match = self.__active_branch_regex.match(content) + if match: + active_branch = match.group('active_branch') + active_branch_path = match.group('active_branch_path') + active_branch_path = self.__repository_information.git_directory.joinpath(active_branch_path) + if not active_branch_path.exists(): + self.__active_branch_name = active_branch + self.__active_branch_commit_hash = None + else: + with active_branch_path.open('r') as branch_file: + self.__active_branch_name = active_branch + self.__active_branch_commit_hash = branch_file.read().strip() + + @property + def branches(self): + """ + List of all branches of the local repository, includes: local branches and packed branches. + """ + branches = [] + branches.extend(self.local_branches) + for packed_branch in self.packed_branches: + if packed_branch not in branches: + branches.append(packed_branch) + return Branches(branches) + + @property + def local_branches(self): + """ + List of all local branches, except of packed branches. + """ + branches_path = self.__repository_information.git_directory.joinpath('refs', 'heads') + branches = [] + for file_path in branches_path.iterdir(): + if file_path.name == '.DS_Store': + continue + with file_path.open('r') as file: + commit = file.read().strip() + branches.append(Branch(name=file_path.name, commit=self.__commits[commit])) + return branches + + @property + def packed_branches(self): + """ + List of all packed branches in the local repository. + """ + packed_refs_path = self.__repository_information.git_directory.joinpath('packed-refs') + branches = [] + if packed_refs_path.exists(): + with packed_refs_path.open('r') as file: + for line in file.readlines(): + match = self.__packed_branch_regex.match(line) + if match: + branches.append(Branch(name=match.group('name'), commit=self.__commits[match.group('commit')])) + return branches diff --git a/sources/parsers/commits_parser.py b/sources/parsers/commits_parser.py new file mode 100644 index 0000000..af5f0a8 --- /dev/null +++ b/sources/parsers/commits_parser.py @@ -0,0 +1,99 @@ +""" +Module contains parser for the git commits. +""" +from datetime import datetime +from enum import Enum +from typing import List, Optional + +from sources.command import GitCommandRunner +from sources.models.base_classes import Author, Reference +from sources.models.commits import Commits, Commit +from sources.options.log_options import LogCommandDefinitions + + +class CommitsParser: + """ + Class parses git commits. + """ + class FormatOptions(Enum): + """ + Class contains options that could be used for commits formatting. + """ + AUTHOR_NAME = '%aN' + AUTHOR_EMAIL = '%aE' + AUTHOR_DATE = '%ad' + COMMIT_HASH = '%H' + PARENT_HASH = '%P' + SUMMARY = '%s' + + DELIMITER: str = '%n' + FORMAT: List[str] = [ + FormatOptions.AUTHOR_NAME.value, + FormatOptions.AUTHOR_EMAIL.value, + FormatOptions.AUTHOR_DATE.value, + FormatOptions.PARENT_HASH.value, + FormatOptions.COMMIT_HASH.value, + FormatOptions.SUMMARY.value + ] + FORMAT_RAW: str = DELIMITER.join(FORMAT) + + DATE_FORMAT: str = '%Y-%m-%d %H:%M:%S' + + TAGGER_LINE_INDEX = 1 + + def __init__(self, git_command: GitCommandRunner): + self.__git_command = git_command + + @property + def commits(self) -> Commits: + """ + All commits in the repository. + """ + return self.get_commits() + + def parse_commits(self, raw_commits: List[str]) -> Commits: + """ + Parse list of the raw stings with commits returned by 'git log' to Commits object. + + :param raw_commits: List of raw strings with commits information. + :type raw_commits: List[str] + :return: Commits object that contains list of commits. + """ + commits = Commits() + for raw_commit in raw_commits: + commit_hash = raw_commit[self.FORMAT.index(self.FormatOptions.COMMIT_HASH.value)] + parent_hash = raw_commit[self.FORMAT.index(self.FormatOptions.PARENT_HASH.value)] + author_name = raw_commit[self.FORMAT.index(self.FormatOptions.AUTHOR_NAME.value)] + author_email = raw_commit[self.FORMAT.index(self.FormatOptions.AUTHOR_EMAIL.value)] + commit_date = datetime.strptime(raw_commit[self.FORMAT.index(self.FormatOptions.AUTHOR_DATE.value)], + self.DATE_FORMAT) + commit_message = raw_commit[self.FORMAT.index(self.FormatOptions.SUMMARY.value)] + author = Author(name=author_name, email=author_email) + commit = Commit(commit_hash=commit_hash, message=commit_message, author=author, date=commit_date, + parent=commits[parent_hash]) + commits += commit + return commits + + def get_commits(self, reference: Optional[Reference] = None) -> Commits: + """ + Extract all commits for the provided reference from the repository and parse them to Commits object. + + :param reference: Reference for which the commits will be collected. + :type reference: Optional[Reference] + :return: Commits object with list of the commits for the reference. + """ + options = [] + if reference is None: + options.append(LogCommandDefinitions.Options.ALL.create_option(True)) + else: + options.append(LogCommandDefinitions.Options.PATH.create_option(reference.path)) + options.append(LogCommandDefinitions.Options.PRETTY.create_option(f'format:{self.FORMAT_RAW}')) + options.append(LogCommandDefinitions.Options.DATE.create_option(f'format:{self.DATE_FORMAT}')) + commit_attributes = len(self.FORMAT) + output = self.__git_command.log(*options).strip() + if not output: + return Commits() + lines = output.split('\n') + raw_commits = [lines[row_index:row_index + commit_attributes] for row_index in + range(len(lines) - commit_attributes, -1, commit_attributes * -1)] + return self.parse_commits(raw_commits) diff --git a/sources/parsers/tags_parser.py b/sources/parsers/tags_parser.py new file mode 100644 index 0000000..9606368 --- /dev/null +++ b/sources/parsers/tags_parser.py @@ -0,0 +1,100 @@ +""" +Module contains parser for the git tags. +""" +from enum import Enum +from typing import List, Union + +from sources.command import GitCommandRunner +from sources.models.base_classes import Author +from sources.models.commits import Commits +from sources.models.tags import Tags, LightweightTag, AnnotatedTag +from sources.options.for_each_ref_options import ForEachRefCommandDefinitions + + +class TagsParser: + """ + Class parses git tags. + """ + class FormatOptions(Enum): + """ + Class contains options that could be used for tags formatting. + """ + OBJECT_TYPE = '%(objecttype)' + OBJECT_HASH = '%(if)%(object)%(then)%(object)%(else)%(objectname)%(end)' + TAG_NAME = '%(refname:short)' + AUTHOR_NAME = '%(taggername)' + AUTHOR_EMAIL = '%(taggeremail)' + SUBJECT = '%(subject)' + + DELIMITER: str = '%0a' + FORMAT: List[str] = [ + FormatOptions.OBJECT_TYPE.value, + FormatOptions.OBJECT_HASH.value, + FormatOptions.TAG_NAME.value, + FormatOptions.AUTHOR_NAME.value, + FormatOptions.AUTHOR_EMAIL.value, + FormatOptions.SUBJECT.value + ] + + RAW_FORMAT: str = DELIMITER.join(FORMAT) + + PATTERN = 'refs/tags' + + TAGGER_LINE_INDEX = 1 + + def __init__(self, git_command: GitCommandRunner, commits: Commits): + self.__git_command = git_command + self.__commits = commits + + @property + def tags(self) -> Tags: + """ + All tags in the repository. + """ + tags = [] + tags.extend(self.get_tags()) + return Tags(tags) + + def parse_tags(self, raw_tags: List[List[str]]) -> List[Union[LightweightTag, AnnotatedTag]]: + """ + Parse list of the raw strings with tags returned by 'git for-each-ref' to list of tags objects. + + :param raw_tags: List of raw strings with tags information. + :type raw_tags: List[List[str]] + :return: List of tags objects. + """ + tags = [] + for tag in raw_tags: + tag_type = tag[self.FORMAT.index(self.FormatOptions.OBJECT_TYPE.value)] + object_hash = tag[self.FORMAT.index(self.FormatOptions.OBJECT_HASH.value)] + name = tag[self.FORMAT.index(self.FormatOptions.TAG_NAME.value)] + if tag_type == 'commit': + tags.append(LightweightTag(name=name, commit=self.__commits[object_hash])) + else: + subject = tag[self.FORMAT.index(self.FormatOptions.SUBJECT.value)] + author = tag[self.FORMAT.index(self.FormatOptions.AUTHOR_NAME.value)] + email = tag[self.FORMAT.index(self.FormatOptions.AUTHOR_EMAIL.value)].removeprefix('<').removesuffix( + '>') + tagger = Author(name=author, email=email) + tags.append( + AnnotatedTag(tagger=tagger, message=subject, name=name, commit=self.__commits[object_hash])) + return tags + + def get_tags(self) -> List[Union[LightweightTag, AnnotatedTag]]: + """ + Extract all tags from the repository and parse them to tags objects. + + :return: List of parsed tags objects. + """ + options = [ + ForEachRefCommandDefinitions.Options.FORMAT.create_option(self.RAW_FORMAT), + ForEachRefCommandDefinitions.Options.PATTERN.create_option(self.PATTERN), + ] + output = self.__git_command.for_each_ref(*options).strip() + if not output: + return [] + lines = output.split('\n') + arguments = len(self.FORMAT) + raw_tags = [lines[row_index:row_index + arguments] for row_index in + range(0, len(lines), arguments)] + return self.parse_tags(raw_tags) diff --git a/sources/utils/__init__.py b/sources/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/utils/path_util.py b/sources/utils/path_util.py new file mode 100644 index 0000000..93e1d91 --- /dev/null +++ b/sources/utils/path_util.py @@ -0,0 +1,115 @@ +""" +Module contains class for manipulations with a path. +""" +from pathlib import Path +from typing import Union, NoReturn + + +class PathUtil: + """ + Class that allows to manipulate with a path. + """ + @staticmethod + def convert_to_path(path: Union[str, Path]) -> Path: + """ + Method converts string to the Path, or returns the object, if it is of the type Path. + + :param path: Path that will be converted. + :type path: Union[str, Path] + :return: Converted to Path object. + """ + if isinstance(path, str): + path = Path(path) + return path + + @staticmethod + def convert_to_string(path: Union[str, Path]) -> str: + """ + Method converts Path to the string, or returns the object, if it is of the type string. + + :param path: String with a path that will be converted. + :type path: Union[str, Path] + :return: Converted to a string path object. + """ + if isinstance(path, Path): + path = str(path.absolute()) + return path + + +class PathsMapping: + """ + Class for paths mapping, maps source path with destination path. + """ + DELIMITER: str = ':' + + __source: Path + __destination: Path + __root_path: Path + + def __init__(self, source: Union[str, Path], destination: Union[str, Path], root_path: Union[str, Path] = None): + if root_path is None: + root_path = Path() + self.root_path = root_path + self.__source = source + self.__destination = destination + + @classmethod + def create_from_text(cls, mapping: str, root_path: Union[str, Path] = None) -> 'PathsMapping': + """ + Create a new class instance from the string mapping in the format "source:destination" that is relative to the + repository root. Optionally repository root can be provided, otherwise current directory considered as a root. + + :param mapping: Source to destination mapping, format: "source:destination". + :type mapping: str + :param root_path: Path to the root in which files are located. + :type root_path: Union[str, Path] + :return: A new class instance. + """ + if root_path is None: + root_path = Path() + source, destination = mapping.strip().split(cls.DELIMITER) + source = source.strip() + destination = destination.strip() + instance = cls(source, destination, root_path) + return instance + + def __normalize_path(self, path: Union[str, Path]) -> Path: + """ + Normalize the path, returns absolute path converted from string to Path, that always starts from the root path. + + :param path: Path that will be normalized. + :type path: Union[str, Path] + :return: Normalized path. + """ + if isinstance(path, str) and str(path).startswith(str(self.__root_path)): + normalized_path = Path(path) + elif isinstance(path, str): + normalized_path = self.__root_path.joinpath(path) + else: + normalized_path = path + return normalized_path + + @property + def source(self) -> Path: + """ + Source path of the mapping. + """ + return self.__normalize_path(self.__source) + + @property + def destination(self) -> Path: + """ + Destination path of the mapping. + """ + return self.__normalize_path(self.__destination) + + @property + def root_path(self) -> Path: + """ + Root path of the repository, where the files are located. + """ + return self.__root_path + + @root_path.setter + def root_path(self, root_path: Union[str, Path]) -> NoReturn: + self.__root_path = root_path diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/acceptance_tests/__init__.py b/tests/acceptance_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/acceptance_tests/features/__init__.py b/tests/acceptance_tests/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/acceptance_tests/features/basic_operations.feature b/tests/acceptance_tests/features/basic_operations.feature new file mode 100644 index 0000000..89fc507 --- /dev/null +++ b/tests/acceptance_tests/features/basic_operations.feature @@ -0,0 +1,32 @@ +Feature: Git Basic Operations + Scenario: Initialize a new repository + Given new local repository path + When executing 'git init' + Then new empty repository will be created + + Scenario: Clone repository + Given remote repository + When executing 'git clone' + Then new repository will be cloned + + Scenario: Add a new file + Given new git repository + And new file has been created + When executing 'git add' on file + Then new file will be added to the git tracking + + Scenario: Remove a file + Given new git repository + And new file has been created + And new file has been added to tracking + And commit changes + When executing 'git rm' on file + Then the file will be removed from the git tracking + + Scenario: Move (rename) a file + Given new git repository + And new file has been created + And new file has been added to tracking + And commit changes + When executing 'git mv' on file + Then the file will be renamed \ No newline at end of file diff --git a/tests/acceptance_tests/features/environment.py b/tests/acceptance_tests/features/environment.py new file mode 100644 index 0000000..ebaa69a --- /dev/null +++ b/tests/acceptance_tests/features/environment.py @@ -0,0 +1,11 @@ +import shutil + +from behave.model import Scenario +from behave.runner import Context + +from sources.git import GitRepository + + +def after_scenario(context: Context, scenario: Scenario): + repository: GitRepository = context.repository + shutil.rmtree(repository.path, ignore_errors=True) diff --git a/tests/acceptance_tests/features/steps/__init__.py b/tests/acceptance_tests/features/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/acceptance_tests/features/steps/basic_operations.py b/tests/acceptance_tests/features/steps/basic_operations.py new file mode 100644 index 0000000..003cd43 --- /dev/null +++ b/tests/acceptance_tests/features/steps/basic_operations.py @@ -0,0 +1,145 @@ +import logging +import platform +import uuid +from pathlib import Path + +from behave import given, when, then +from behave.runner import Context + +from sources.exceptions import GitException +from sources.git import GitRepository, PathsMapping +from sources.options.commit_options import CommitCommandDefinitions + + +@given("new local repository path") +def step_impl(context: Context): + hash_name = str(uuid.uuid4()) + if platform.system().lower() == 'windows': + path = Path(r'') + else: + path = Path(fr'/tmp/git-repository-{hash_name}') + logging.info(f'Git Repository: {path}') + context.repository_path = path + + +@when("executing 'git init'") +def step_impl(context: Context): + repository = None + try: + repository = GitRepository.init(path=context.repository_path) + except GitException: + repository = None + context.repository = repository + + +@given("remote repository") +def step_impl(context: Context): + context.remote_repository = 'git@github.com:dl1998/PyGit.git' + + +@when("executing 'git clone'") +def step_impl(context: Context): + repository = None + try: + hash_name = str(uuid.uuid4()) + if platform.system().lower() == 'windows': + path = Path(r'') + else: + path = Path(fr'/tmp/git-repository-{hash_name}') + logging.info(f'Git Repository: {path}') + repository = GitRepository.clone(repository=context.remote_repository, path=path) + except GitException: + repository = None + context.repository = repository + + +@then("new empty repository will be created") +@then("new repository will be cloned") +def step_impl(context: Context): + assert context.repository is not None + + +@given("new git repository") +def step_impl(context: Context): + hash_name = str(uuid.uuid4()) + if platform.system().lower() == 'windows': + path = Path(r'') + else: + path = Path(fr'/tmp/git-repository-{hash_name}') + logging.info(f'Git Repository: {path}') + path.mkdir(parents=True, exist_ok=True) + repository = GitRepository.init(path=path) + context.repository = repository + + +@given("new file has been created") +def step_impl(context: Context): + repository: GitRepository = context.repository + new_file = repository.path.joinpath('add_file.txt') + logging.info(f'Create a new file: {new_file}') + with new_file.open('w') as file: + file.write(r'Add new file for testing "git add" command.\r\n') + context.new_file = new_file + + +@when("executing 'git add' on file") +@given("new file has been added to tracking") +def step_impl(context: Context): + repository: GitRepository = context.repository + repository.add(context.new_file) + + +@given("commit changes") +def step_impl(context: Context): + repository: GitRepository = context.repository + options = [ + CommitCommandDefinitions.Options.MESSAGE.create_option('Add new file') + ] + repository.git_command.commit(*options) + + +@then("new file will be added to the git tracking") +def step_impl(context: Context): + new_file: Path = context.new_file + repository: GitRepository = context.repository + output = repository.git_command.execute(['ls-files', '--error-unmatch', new_file.name]) + assert new_file.name in output + + +@when("executing 'git rm' on file") +def step_impl(context: Context): + new_file: Path = context.new_file + repository: GitRepository = context.repository + repository.rm(new_file) + + +@then("the file will be removed from the git tracking") +def step_impl(context: Context): + new_file: Path = context.new_file + repository: GitRepository = context.repository + successfully_removed = False + try: + repository.git_command.execute(['ls-files', '--error-unmatch', new_file.name]) + successfully_removed = False + except GitException: + successfully_removed = True + finally: + assert successfully_removed + + +@when("executing 'git mv' on file") +def step_impl(context: Context): + new_file: Path = context.new_file + repository: GitRepository = context.repository + new_path = new_file.parent.joinpath('renamed_file.txt') + mapping = PathsMapping(new_file, new_path) + repository.mv(mapping) + context.new_path = new_path + + +@then("the file will be renamed") +def step_impl(context: Context): + new_file: Path = context.new_file + new_path: Path = context.new_path + assert not new_file.exists() + assert new_path.exists() diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/pygit/__init__.py b/tests/unit_tests/pygit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/pygit/options/__init__.py b/tests/unit_tests/pygit/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/pygit/options/test_add_options.py b/tests/unit_tests/pygit/options/test_add_options.py new file mode 100644 index 0000000..aad8340 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_add_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git add' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.add_options import AddCommandDefinitions + + +class TestAddCommandDefinitions: + """ + Class contains unit tests for 'AddCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'AddCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(AddCommandDefinitions, 'add') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'AddCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(AddCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_checkout_options.py b/tests/unit_tests/pygit/options/test_checkout_options.py new file mode 100644 index 0000000..65c3dcd --- /dev/null +++ b/tests/unit_tests/pygit/options/test_checkout_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git checkout' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.checkout_options import CheckoutCommandDefinitions + + +class TestCheckoutCommandDefinitions: + """ + Class contains unit tests for 'CheckoutCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'CheckoutCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(CheckoutCommandDefinitions, 'checkout') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'CheckoutCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(CheckoutCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_clone_options.py b/tests/unit_tests/pygit/options/test_clone_options.py new file mode 100644 index 0000000..95cc1c9 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_clone_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git clone' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.clone_options import CloneCommandDefinitions + + +class TestCloneCommandDefinitions: + """ + Class contains unit tests for 'CloneCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'CloneCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(CloneCommandDefinitions, 'clone') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'CloneCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(CloneCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_commit_options.py b/tests/unit_tests/pygit/options/test_commit_options.py new file mode 100644 index 0000000..1da5841 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_commit_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git commit' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.commit_options import CommitCommandDefinitions + + +class TestCommitCommandDefinitions: + """ + Class contains unit tests for 'CommitCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'CommitCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(CommitCommandDefinitions, 'commit') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'CommitCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(CommitCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_config_options.py b/tests/unit_tests/pygit/options/test_config_options.py new file mode 100644 index 0000000..d62c4cd --- /dev/null +++ b/tests/unit_tests/pygit/options/test_config_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git config' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.config_options import ConfigCommandDefinitions + + +class TestConfigCommandDefinitions: + """ + Class contains unit tests for 'ConfigCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'ConfigCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(ConfigCommandDefinitions, 'config') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'ConfigCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(ConfigCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_for_each_ref_options.py b/tests/unit_tests/pygit/options/test_for_each_ref_options.py new file mode 100644 index 0000000..8942126 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_for_each_ref_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git for-each-ref' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.for_each_ref_options import ForEachRefCommandDefinitions + + +class TestForEachRefCommandDefinitions: + """ + Class contains unit tests for 'ForEachRefCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'ForEachRefCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(ForEachRefCommandDefinitions, 'for-each-ref') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'ForEachRefCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(ForEachRefCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_init_options.py b/tests/unit_tests/pygit/options/test_init_options.py new file mode 100644 index 0000000..88532d3 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_init_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git init' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.init_options import InitCommandDefinitions + + +class TestInitCommandDefinitions: + """ + Class contains unit tests for 'InitCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'InitCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(InitCommandDefinitions, 'init') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'InitCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(InitCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_log_options.py b/tests/unit_tests/pygit/options/test_log_options.py new file mode 100644 index 0000000..873283e --- /dev/null +++ b/tests/unit_tests/pygit/options/test_log_options.py @@ -0,0 +1,10 @@ +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.log_options import LogCommandDefinitions + + +class TestLogCommandDefinitions: + def test_command_positive(self): + CommandDefinitionsTestingUtil.test_command_positive(LogCommandDefinitions, 'log') + + def test_definitions_positive(self): + CommandDefinitionsTestingUtil.test_definitions_positive(LogCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_mv_options.py b/tests/unit_tests/pygit/options/test_mv_options.py new file mode 100644 index 0000000..a4a3733 --- /dev/null +++ b/tests/unit_tests/pygit/options/test_mv_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git mv' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.mv_options import MvCommandDefinitions + + +class TestMvCommandDefinitions: + """ + Class contains unit tests for 'MvCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'MvCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(MvCommandDefinitions, 'mv') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'MvCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(MvCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_options.py b/tests/unit_tests/pygit/options/test_options.py new file mode 100644 index 0000000..a7ce65d --- /dev/null +++ b/tests/unit_tests/pygit/options/test_options.py @@ -0,0 +1,475 @@ +""" +Module contains unit tests for base options classes of git. +""" +from typing import Any, Type, Optional, List, Tuple + +import pytest + +from pygit.options.testing_utils import GitModelGenerator +from sources.exceptions import GitException, GitMissingDefinitionException, GitIncorrectOptionValueException, \ + GitMissingRequiredOptionsException +from sources.options.options import GitOptionNameAliases, GitOptionNameAlias, GitOptionDefinition, GitOption, \ + GitCommand, CommandOptions + + +class DemoCommand(GitCommand): + """ + Class inherits 'GitCommand' class and defines 'dummy' git command for testing purposes. + """ + + class Options(CommandOptions): + """ + Class inherits 'CommandOptions' class and defines 'dummy' options for the 'dummy' git command. + """ + OPTION = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='option', short_option=False), + GitOptionNameAlias(name='o', short_option=True), + ]) + LONG_OPTION = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='long-option', short_option=False), + ]) + SHORT_OPTION = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='s', short_option=True) + ]) + POSITIONAL_OPTION = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='positional', short_option=True) + ]) + + def __init__(self): + super().__init__('command') + self.definitions = [ + GitOptionDefinition(name_aliases=self.Options.OPTION, type=(str, bool)), + GitOptionDefinition(name_aliases=self.Options.LONG_OPTION, type=str, choices=['first', 'second']), + GitOptionDefinition(name_aliases=self.Options.SHORT_OPTION, type=bool), + GitOptionDefinition(name_aliases=self.Options.POSITIONAL_OPTION, type=str, positional=True, position=0, + required=True), + ] + + +class StringOptions(CommandOptions): + first = 'one' + second = 'two' + third = 'three' + + +class TestGitOptionNameAliases: + """ + Class contains unit tests for 'GitOptionNameAliases' class. + """ + + @pytest.mark.parametrize('name,short_option,length', [('option', False, 1), ('o', True, 1)], + ids=('Get long alias', 'Get short alias')) + def test_get_aliases_positive(self, name: str, short_option: bool, length: int): + """ + Method tests that 'get_aliases' method from 'GitOptionNameAliases' class returns correct values for short and + long aliases. + + :param name: The name of the option. + :type name: str + :param short_option: Whether the tested option is short or long. + :type short_option: bool + :param length: The number fo the available aliases for this option. + :type length: int + """ + alias = GitOptionNameAlias(name=name, short_option=short_option) + option_aliases = GitOptionNameAliases(aliases=[ + alias, + ]) + aliases = option_aliases.get_aliases(short_option=short_option) + assert len(aliases) == length + assert aliases[0] == alias + + # noinspection PyTypeChecker + def test_get_aliases_negative(self): + """ + Method tests that 'get_aliases' method from 'GitOptionNameAliases' class throws an exception when an invalid + alias is on the list. + """ + alias = 'option' + try: + option_aliases = GitOptionNameAliases(aliases=[ + alias, + ]) + option_aliases.get_aliases(short_option=False) + test_result = False + except AttributeError: + test_result = True + assert test_result + + @pytest.mark.parametrize('name,short_option', [('option', False), ('o', True)], + ids=("Without short options", 'With short options')) + def test_has_short_aliases_positive(self, name: str, short_option: bool): + """ + Method tests that 'has_short_aliases' method from 'GitOptionNameAliases' class returns True, if there is at + least one short alias for this option and otherwise returns False. + + :param name: The name of the alias. + :type name: str + :param short_option: Whether this alias is short or not. + :type short_option: bool + """ + alias = GitOptionNameAlias(name=name, short_option=short_option) + option_aliases = GitOptionNameAliases(aliases=[ + alias, + ]) + has_short_aliases = option_aliases.has_short_aliases() + assert has_short_aliases == short_option + + @pytest.mark.parametrize('name,short_option', [('option', False), ('o', True)], + ids=("With long options", 'Without long options')) + def test_has_long_aliases_positive(self, name: str, short_option: bool): + """ + Method tests that 'has_long_aliases' method from 'GitOptionNameAliases' class returns True, if there is at + least one long alias for this option and otherwise returns False. + + :param name: The name of the alias. + :type name: str + :param short_option: Whether this alias is short or not. + :type short_option: bool + """ + alias = GitOptionNameAlias(name=name, short_option=short_option) + option_aliases = GitOptionNameAliases(aliases=[ + alias, + ]) + has_long_aliases = option_aliases.has_long_aliases() + assert has_long_aliases != short_option + + @pytest.mark.parametrize('name,exists', [('option', True), ('o', True), ('another_option', False)], + ids=("Long option exists", 'Short option exists', 'Missing option')) + def test_exists_positive(self, name: str, exists: bool): + """ + Method tests that 'exists' method from 'GitOptionNameAliases' class returns True, if there is an alias with + provided name. + + :param name: The name of the alias. + :type name: str + :param exists: Whether this alias exists or not. + :type exists: bool + """ + option_aliases = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='option', short_option=False), + GitOptionNameAlias(name='o', short_option=True), + ]) + option_exists = option_aliases.exists(name) + assert option_exists == exists + + def test_get_names_positive(self): + """ + Method tests that 'get_names' method from 'GitOptionNameAliases' class returns list of aliases for the option. + """ + option_aliases = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='option', short_option=False), + GitOptionNameAlias(name='o', short_option=True), + ]) + names = option_aliases.get_names() + assert names == ['option', 'o'] + + +class TestGitOptionDefinition: + """ + Class contains unit tests for 'GitOptionDefinition' class. + """ + + @pytest.mark.parametrize('name,value,expected', [ + ('option', True, True), ('option', 'value', True), ('o', True, True), ('o', 123, False), + ('another_option', 'value', False) + ], ids=('Correct long boolean option', 'Correct long string option', 'Correct short boolean option', + 'Incorrect short integer option', 'Incorrect another string option')) + def test_compare_with_option_positive(self, name: str, value: Any, expected: bool): + """ + Method tests that 'compare_with_option' method from class 'GitOptionDefinition' returns True if 'GitOption' is + corresponding to 'GitOptionDefinition', otherwise returns False. + + :param name: The name of the option. + :type name: str + :param value: The value of the option. + :type value: Any + :param expected: The expected value that shall be returned by 'compare_with_option' method. + :type expected: bool + """ + name_aliases = GitOptionNameAliases(aliases=[ + GitOptionNameAlias(name='option', short_option=False), + GitOptionNameAlias(name='o', short_option=True), + ]) + definition = GitOptionDefinition(name_aliases=name_aliases, type=(str, bool)) + option = GitOption(name=name, value=value) + comparison_result = definition.compare_with_option(option) + assert comparison_result == expected + + +class TestGitCommand: + """ + Class contains unit tests for 'GitCommand' class. + """ + + @pytest.mark.parametrize("name,value,has_definition", [ + ('option', True, True), ('option', 'value', True), ('o', True, True), ('o', 123, False), + ('another_option', 'value', False) + ], ids=("Definition exists for long boolean option", "Definition exists for long string option", + "Definition exists for short boolean option", "Definition doesn't exist for short integer option", + "Definition doesn't exist for long string option")) + def test_get_definition_positive(self, name: str, value: Any, has_definition: bool): + """ + Method tests that 'get_definition' method from 'GitCommand' class returns definition for the given 'GitOption' + if it exists, otherwise returns None. + + :param name: The name of the option. + :type name: str + :param value: The value of the option. + :type value: Any + :param has_definition: If expected is True, then non None value is expected to be returned by 'get_definition' + method, otherwise None is expected. + :type has_definition: bool + """ + option = GitOption(name=name, value=value) + command = DemoCommand() + definition = command.get_definition(option) + if has_definition: + assert definition is not None + else: + assert definition is None + + def test_validate_positive(self): + """ + Method tests that 'validate' method from 'GitCommand' class validates the options for the 'GitCommand'. It shall + not raise an exception for the correct options. + """ + options = [ + DemoCommand.Options.SHORT_OPTION.create_option(True), + DemoCommand.Options.LONG_OPTION.create_option('first'), + DemoCommand.Options.POSITIONAL_OPTION.create_option('value'), + ] + command = DemoCommand() + raise_exception = False + try: + command.validate(options) + except GitException: + raise_exception = True + assert not raise_exception + + @pytest.mark.parametrize('name,value,expected_exception', [ + ('missing_option', True, GitMissingDefinitionException), + ('long-option', 'third', GitIncorrectOptionValueException), + ('option', True, GitMissingRequiredOptionsException), + ], ids=("Missing option exception", "Incorrect option value exception", "Missing required option exception")) + def test_validate_negative(self, name: str, value: Any, expected_exception: Type[GitException]): + """ + Method tests that 'validate' method from 'GitCommand' class validates the options for the 'GitCommand'. It shall + raise specified exceptions for the incorrect options. + + Tests the following exceptions: + + - GitMissingDefinitionException: When option is not on the list of defined options for the 'GitCommand', it also + could be that option has different type than defined. + - GitIncorrectOptionValueException: When the value for the option is not the choices list. + - GitMissingRequiredOptionsException: When option that is required is not present. + + :param name: The name of the option to validate. + :type name: str + :param value: The value for the option. + :type value: Any + :param expected_exception: The exception type that shall be thrown for this option. + :type expected_exception: Type[GitException] + """ + option = GitOption(name=name, value=value) + command = DemoCommand() + raise_exception = False + try: + command.validate(option) + except expected_exception: + raise_exception = True + assert raise_exception + + @pytest.mark.parametrize('positional_definitions,expected', [ + ([(('option',), str), (('another_option',), list)], (True, None)), + ([(('option',), list), (('another_option',), str)], (False, '[option]')), + ([(('option', 'o'), list), (('another_option',), str)], (False, '[option, o]')), + ], ids=("Correct positional option", "Incorrect positional option with one alias", + "Incorrect positional option with two aliases")) + def test_validate_positional_list_positive(self, positional_definitions: List[Tuple[Tuple[str], type]], + expected: Tuple[bool, Optional[str]]): + """ + Method tests that 'validate_positional_list' method from 'GitCommand' class returns Tuple[status, incorrect + options] when positional arguments of the type list is defined not on the last position. + + :param positional_definitions: The configuration of the positional arguments. + :type positional_definitions: List[Tuple[Tuple[str], type]] + :param expected: The expected value that shall be returned by method. + :type expected: Tuple[bool, Optional[str]] + """ + definitions = [] + for definition in positional_definitions: + aliases = [] + for alias in definition[0]: + aliases.append({'name': alias, 'short-option': not len(alias) > 1}) + definitions.append({'aliases': aliases, 'positional': True, 'type': definition[1]}) + command = GitModelGenerator.generate_git_command({ + 'command': 'demo-command', + 'definitions': definitions + }) + assert command.validate_positional_list() == expected + + @pytest.mark.parametrize("option_definitions,expected", [ + (['option', 'long-option', 's'], (True, [])), + (['o', 'long-option', 's'], (True, [])), + (['option', 's'], (False, ['long-option'])), + (['option', 'long-option'], (False, ['s'])), + (['long-option', 's'], (False, ['option|o'])), + (['option'], (False, ['long-option', 's'])), + ], ids=("All required options are present (long alias for two aliases option)", + "All required options are present (short alias for two aliases option)", + "Long option is missing", "Short option is missing", "Required option with two aliases is missing", + "Long option and short option are missing")) + def test_validate_required_positive(self, option_definitions: List[str], expected: Tuple[bool, Optional[str]]): + """ + Method tests that 'validate_required' method from 'GitCommand' class returns Tuple[status, missing options], + where missing options is a list of options that are required, but missing. + + :param option_definitions: List of options. + :type option_definitions: List[str] + :param expected: The expected value that shall be returned by method. + :type expected: Tuple[bool, Optional[str]] + """ + options = [] + for option in option_definitions: + options.append(GitOption(name=option, value='value')) + command = GitModelGenerator.generate_git_command({ + 'command': 'demo-command', + 'definitions': [ + { + 'aliases': [ + { + 'name': 'option', + 'short-option': False + }, + { + 'name': 'o', + 'short-option': True + } + ], + 'required': True + }, + { + 'aliases': [ + { + 'name': 'long-option', + 'short-option': False + } + ], + 'required': True + }, + { + 'aliases': [ + { + 'name': 's', + 'short-option': True + } + ], + 'required': True + }, + ] + }) + assert command.validate_required(options) == expected + + @pytest.mark.parametrize("defined_choices,value,with_definition,expected", [ + (None, 'first', True, True), + (None, 'first', False, True), + (['one', 'first', 1], 'first', True, True), + (['one', 'first', 1], 1, False, True), + (['one', 'first', 1], 2, True, False), + (['one', 'first', 1], 2, False, False), + ], ids=("Correct option without defined choices with definition", + "Correct option without defined choices without definition", + "Correct option with defined choices with definition", + "Correct option with defined choices without definition", + "Incorrect option with defined choices with definition", + "Incorrect option with defined choices without definition")) + def test_validate_choices_positive(self, defined_choices: List, value: Any, with_definition: bool, expected: bool): + """ + Method tests that 'validate_choices' method from 'GitCommand' class returns True, if it is correct option, + otherwise return False. + + :param defined_choices: List of choices for the definition. + :type defined_choices: List + :param value: Git option value. + :type value: Any + :param with_definition: If true, then test with definition, otherwise test without definition. + :type with_definition: bool + :param expected: The expected value that shall be returned by method. + :type expected: bool + """ + option = GitOption(name='option', value=value) + command = GitModelGenerator.generate_git_command({ + 'command': 'demo-command', + 'definitions': [ + { + 'aliases': [ + { + 'name': 'option', + 'short-option': False + } + ], + 'choices': defined_choices, + 'type': (str, int) + } + ] + }) + assert command.validate_choices(option, command.definitions[0] if with_definition else None) == expected + + def test_transform_to_command_positive(self): + """ + Method tests that 'transform_to_command' method from 'GitCommand' class is able to correctly transform + 'GitOption' list to raw command arguments. + """ + options = [ + DemoCommand.Options.SHORT_OPTION.create_option(True), + DemoCommand.Options.LONG_OPTION.create_option('first'), + DemoCommand.Options.POSITIONAL_OPTION.create_option('value'), + ] + command = DemoCommand() + expected_output = ['command', '-s', '--long-option', 'first', 'value'] + transformed_command = command.transform_to_command(options) + assert transformed_command == expected_output + +class TestCommandOptions: + """ + Class contains unit tests for 'CommandOptions' enum class. + """ + @pytest.mark.parametrize("value,expected", [ + ('one', True), ('first', False) + ], ids=("Value exists", "Values not exists")) + def test_create_from_value_positive(self, value: str, expected: bool): + """ + Method tests that 'create_from_value' method from 'CommandOptions' class is able to correctly create + 'CommandOption' instance based on provided value. + + :param value: The value that shall be found in the enum class. + :type value: str + :param expected: Whether the value shall be found in the enum class or not. + :type expected: bool + """ + option = StringOptions.create_from_value(value) + if expected: + assert option is not None + else: + assert option is None + + @pytest.mark.parametrize("short_option", [ + True, False + ], ids=("Short option", "Long option")) + def test_create_option_positive(self, short_option: bool): + """ + Method tests that 'create_option' method from 'CommandOptions' class is able to correctly create 'GitOption' + instance for 'CommandOptions' class with provided value. + + :param short_option: Whether test short option or long option. + :type short_option: bool + """ + if short_option: + option = DemoCommand.Options.SHORT_OPTION.create_option('value') + option_name = 's' + else: + option = DemoCommand.Options.LONG_OPTION.create_option('value') + option_name = 'long-option' + assert option is not None + assert option.name == option_name diff --git a/tests/unit_tests/pygit/options/test_pull_options.py b/tests/unit_tests/pygit/options/test_pull_options.py new file mode 100644 index 0000000..e94094f --- /dev/null +++ b/tests/unit_tests/pygit/options/test_pull_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git pull' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.pull_options import PullCommandDefinitions + + +class TestPullCommandDefinitions: + """ + Class contains unit tests for 'PullCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'PullCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(PullCommandDefinitions, 'pull') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'PullCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(PullCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_push_options.py b/tests/unit_tests/pygit/options/test_push_options.py new file mode 100644 index 0000000..87d86ec --- /dev/null +++ b/tests/unit_tests/pygit/options/test_push_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git push' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.push_options import PushCommandDefinitions + + +class TestPushCommandDefinitions: + """ + Class contains unit tests for 'PushCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'PushCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(PushCommandDefinitions, 'push') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'PushCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(PushCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_rm_options.py b/tests/unit_tests/pygit/options/test_rm_options.py new file mode 100644 index 0000000..a34db3b --- /dev/null +++ b/tests/unit_tests/pygit/options/test_rm_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git rm' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.rm_options import RmCommandDefinitions + + +class TestRmCommandDefinitions: + """ + Class contains unit tests for 'RmCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'RmCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(RmCommandDefinitions, 'rm') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'RmCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(RmCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/test_show_options.py b/tests/unit_tests/pygit/options/test_show_options.py new file mode 100644 index 0000000..344ee7b --- /dev/null +++ b/tests/unit_tests/pygit/options/test_show_options.py @@ -0,0 +1,22 @@ +""" +Module contains unit tests for 'git show' options definition class. +""" +from pygit.options.testing_utils import CommandDefinitionsTestingUtil +from sources.options.show_options import ShowCommandDefinitions + + +class TestShowCommandDefinitions: + """ + Class contains unit tests for 'ShowCommandDefinitions' class. + """ + def test_command_positive(self): + """ + Method tests that command in the 'ShowCommandDefinitions' class has been properly set. + """ + CommandDefinitionsTestingUtil.test_command_positive(ShowCommandDefinitions, 'show') + + def test_definitions_positive(self): + """ + Method tests that definitions have been created for all options from the 'ShowCommandDefinitions' class. + """ + CommandDefinitionsTestingUtil.test_definitions_positive(ShowCommandDefinitions) diff --git a/tests/unit_tests/pygit/options/testing_utils.py b/tests/unit_tests/pygit/options/testing_utils.py new file mode 100644 index 0000000..0f14bfd --- /dev/null +++ b/tests/unit_tests/pygit/options/testing_utils.py @@ -0,0 +1,86 @@ +""" +Module contains testing utilities for options definitions classes. +""" +from typing import Type + +from sources.options.options import GitCommand, GitOptionNameAlias, GitOptionNameAliases, GitOptionDefinition + + +class CommandDefinitionsTestingUtil: + """ + Class contains unit tests for command definitions class. + """ + @staticmethod + def test_command_positive(class_type: Type[GitCommand], expected_name: str): + """ + Method tests that command in the command definitions class has been properly set. + """ + command = class_type() + assert command.command_name == expected_name + + @staticmethod + def test_definitions_positive(class_type: Type[GitCommand]): + """ + Method tests that definitions have been created for all options from the command definitions class. + """ + command = class_type() + missing_options = [] + for option in command.Options: + found = False + for definition in command.definitions: + if definition.name_aliases == option.value: + found = True + if not found: + missing_options.append(option) + assert len(missing_options) == 0 + + +class GitModelGenerator: + """ + Class contains methods to generate 'GitCommand' class instance. + """ + @staticmethod + def generate_git_command(configuration: dict) -> 'GitCommand': + """ + Method that create an instance of 'GitCommand' class based on the provided dictionary. + + :param configuration: Dictionary that is used to create 'GitCommand' class instance. + :type configuration: dict + :return: Instance of the 'GitCommand' class. + """ + command = GitCommand(configuration['command']) + definitions = [] + positional_index = 0 + for definition in configuration['definitions']: + if definition.get('positional', False): + definition['position'] = positional_index + positional_index += 1 + option_definition = GitModelGenerator.generate_git_option_definition(definition) + definitions.append(option_definition) + command.definitions = definitions + return command + + @staticmethod + def generate_git_option_definition(configuration: dict) -> GitOptionDefinition: + """ + Method that create an instance of 'GitOptionDefinition' class based on the provided dictionary. + + :param configuration: Dictionary that is used to create 'GitOptionDefinition' class instance. + :type configuration: dict + :return: Instance of the 'GitOptionDefinition' class. + """ + aliases = [] + for alias in configuration['aliases']: + aliases.append(GitOptionNameAlias(name=alias['name'], short_option=alias['short-option'])) + option_aliases = GitOptionNameAliases(aliases=aliases) + positional = configuration.get('positional', False) + position = configuration.get('position', None) + option_definition = GitOptionDefinition(name_aliases=option_aliases, type=configuration.get('type', str), + positional=positional, position=position) + required = configuration.get('required', None) + if required: + option_definition.required = required + choices = configuration.get('choices', None) + if choices: + option_definition.choices = choices + return option_definition