diff --git a/.gitignore b/.gitignore index b6e4761..3099c4a 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ venv/ ENV/ env.bak/ venv.bak/ +python-discord-insult-bot-venv/ # Spyder project settings .spyderproject @@ -127,3 +128,10 @@ dmypy.json # Pyre type checker .pyre/ + +# node_modules +node_modules/ + +# Exclude + +!tools/lib/ \ No newline at end of file diff --git a/.gitlint b/.gitlint new file mode 100644 index 0000000..14ea524 --- /dev/null +++ b/.gitlint @@ -0,0 +1,13 @@ +[general] +ignore=title-trailing-punctuation, body-min-length, body-is-missing + +extra-path=tools/lib/gitlint-rules.py + +[title-match-regex] +regex=^(.+:\ )?[A-Z].+\.$ + +[title-max-length] +line-length=76 + +[body-max-line-length] +line-length=76 diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..0182ace --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +disable=SC1091 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4ede11e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "prettier", + "version": "2.5.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "prettier": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", + "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..603a2f8 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "prettier", + "version": "2.5.1", + "description": "[![code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![mypy coverage](https://img.shields.io/badge/mypy-100%25-green.svg)](https://github.com/python/mypy)", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "dependencies": { + "prettier": "^2.5.1" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/adambirds/python-discord-insult-bot.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/adambirds/python-discord-insult-bot/issues" + }, + "homepage": "https://github.com/adambirds/python-discord-insult-bot#readme" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a8cc636 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.black] +line-length = 100 +target-version = ["py36"] + +[tool.isort] +src_paths = [".", "src"] +profile = "black" +line_length = 100 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2741956 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +pyyaml + +## Zulint + +https://github.com/zulip/zulint/archive/6cc46d23906757895e917cc75e231f81f824a31d.zip#egg=zulint==0.0.1 + +## Linters + +black +isort +gitlint +pyflakes +mypy \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4818cc5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/commit-message-lint b/tools/commit-message-lint new file mode 100755 index 0000000..16dfdde --- /dev/null +++ b/tools/commit-message-lint @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Lint all commit messages that are newer than upstream/master if running +# locally or the commits in the push or PR if in CircleCI. + +# The rules can be found in /.gitlint + +if [[ " +$(git remote -v) +" =~ ' +'([^[:space:]]*)[[:space:]]*(https://github\.com/|ssh://git@github\.com/|git@github\.com:)adambirds/python-discord-insult-bot(\.git|/)?\ \(fetch\)' +' ]]; then + range="${BASH_REMATCH[1]}/main..HEAD" +else + range="upstream/main..HEAD" +fi + +commits=$(git log "$range" | wc -l) +if [ "$commits" -gt 0 ]; then + # Only run gitlint with non-empty commit lists, to avoid a printed + # warning. + gitlint --commits "$range" +fi diff --git a/tools/commit-msg b/tools/commit-msg new file mode 100755 index 0000000..cf289c9 --- /dev/null +++ b/tools/commit-msg @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# This hook runs gitlint on your commit message. + +# Do not invoke gitlint if commit message is empty +if grep -q '^[^#]' "$1"; then + lint_cmd="tools/lint --only=gitlint" + if + ! eval "$lint_cmd" <"$1" + then + echo "WARNING: Your commit message does not match python-discord-insult-bot's style guide." + fi +fi + +exit 0 diff --git a/tools/lib/__init__.py b/tools/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/lib/custom_check.py b/tools/lib/custom_check.py new file mode 100644 index 0000000..6cc7851 --- /dev/null +++ b/tools/lib/custom_check.py @@ -0,0 +1,52 @@ +from typing import List + +from zulint.custom_rules import Rule, RuleList + +trailing_whitespace_rule: "Rule" = { + "pattern": r"\s+$", + "strip": "\n", + "description": "Fix trailing whitespace", +} +whitespace_rules: List["Rule"] = [ + # This linter should be first since bash_rules depends on it. + trailing_whitespace_rule, +] + +markdown_whitespace_rules: List["Rule"] = [ + *(rule for rule in whitespace_rules if rule["pattern"] != r"\s+$"), + # Two spaces trailing a line with other content is okay--it's a Markdown line break. + # This rule finds one space trailing a non-space, three or more trailing spaces, and + # spaces on an empty line. + { + "pattern": r"((?[^\]]+)\]\((?P=url)\)", + "description": "Linkified Markdown URLs should use cleaner syntax.", + }, + { + "pattern": r"\][(][^#h]", + "description": "Use absolute links from docs served by GitHub", + }, + ], + max_length=120, +) + +non_py_rules = [ + markdown_rules, +] diff --git a/tools/lib/gitlint-rules.py b/tools/lib/gitlint-rules.py new file mode 100644 index 0000000..ff187a1 --- /dev/null +++ b/tools/lib/gitlint-rules.py @@ -0,0 +1,110 @@ +from typing import List + +from gitlint.git import GitCommit +from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation + +# Word list from https://github.com/m1foley/fit-commit +# Copyright (c) 2015 Mike Foley +# License: MIT +# Ref: fit_commit/validators/tense.rb +TENSE_DATA = [ + (["adds", "adding", "added"], "add"), + (["allows", "allowing", "allowed"], "allow"), + (["amends", "amending", "amended"], "amend"), + (["bumps", "bumping", "bumped"], "bump"), + (["calculates", "calculating", "calculated"], "calculate"), + (["changes", "changing", "changed"], "change"), + (["cleans", "cleaning", "cleaned"], "clean"), + (["commits", "committing", "committed"], "commit"), + (["corrects", "correcting", "corrected"], "correct"), + (["creates", "creating", "created"], "create"), + (["darkens", "darkening", "darkened"], "darken"), + (["disables", "disabling", "disabled"], "disable"), + (["displays", "displaying", "displayed"], "display"), + (["documents", "documenting", "documented"], "document"), + (["drys", "drying", "dryed"], "dry"), + (["ends", "ending", "ended"], "end"), + (["enforces", "enforcing", "enforced"], "enforce"), + (["enqueues", "enqueuing", "enqueued"], "enqueue"), + (["extracts", "extracting", "extracted"], "extract"), + (["finishes", "finishing", "finished"], "finish"), + (["fixes", "fixing", "fixed"], "fix"), + (["formats", "formatting", "formatted"], "format"), + (["guards", "guarding", "guarded"], "guard"), + (["handles", "handling", "handled"], "handle"), + (["hides", "hiding", "hid"], "hide"), + (["increases", "increasing", "increased"], "increase"), + (["ignores", "ignoring", "ignored"], "ignore"), + (["implements", "implementing", "implemented"], "implement"), + (["improves", "improving", "improved"], "improve"), + (["keeps", "keeping", "kept"], "keep"), + (["kills", "killing", "killed"], "kill"), + (["makes", "making", "made"], "make"), + (["merges", "merging", "merged"], "merge"), + (["moves", "moving", "moved"], "move"), + (["permits", "permitting", "permitted"], "permit"), + (["prevents", "preventing", "prevented"], "prevent"), + (["pushes", "pushing", "pushed"], "push"), + (["rebases", "rebasing", "rebased"], "rebase"), + (["refactors", "refactoring", "refactored"], "refactor"), + (["removes", "removing", "removed"], "remove"), + (["renames", "renaming", "renamed"], "rename"), + (["reorders", "reordering", "reordered"], "reorder"), + (["replaces", "replacing", "replaced"], "replace"), + (["requires", "requiring", "required"], "require"), + (["restores", "restoring", "restored"], "restore"), + (["sends", "sending", "sent"], "send"), + (["sets", "setting"], "set"), + (["separates", "separating", "separated"], "separate"), + (["shows", "showing", "showed"], "show"), + (["simplifies", "simplifying", "simplified"], "simplify"), + (["skips", "skipping", "skipped"], "skip"), + (["sorts", "sorting"], "sort"), + (["speeds", "speeding", "sped"], "speed"), + (["starts", "starting", "started"], "start"), + (["supports", "supporting", "supported"], "support"), + (["takes", "taking", "took"], "take"), + (["testing", "tested"], "test"), # "tests" excluded to reduce false negatives + (["truncates", "truncating", "truncated"], "truncate"), + (["updates", "updating", "updated"], "update"), + (["uses", "using", "used"], "use"), +] + +TENSE_CORRECTIONS = {word: imperative for words, imperative in TENSE_DATA for word in words} + + +class ImperativeMood(LineRule): + """This rule will enforce that the commit message title uses imperative + mood. This is done by checking if the first word is in `WORD_SET`, if so + show the word in the correct mood.""" + + name = "title-imperative-mood" + id = "Z1" + target = CommitMessageTitle + + error_msg = ( + "The first word in commit title should be in imperative mood " + '("{word}" -> "{imperative}"): "{title}"' + ) + + def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]: + violations = [] + + # Ignore the section tag (ie `
: .`) + words = line.split(": ", 1)[-1].split() + first_word = words[0].lower() + + if first_word in TENSE_CORRECTIONS: + imperative = TENSE_CORRECTIONS[first_word] + violation = RuleViolation( + self.id, + self.error_msg.format( + word=first_word, + imperative=imperative, + title=commit.message.title, + ), + ) + + violations.append(violation) + + return violations diff --git a/tools/lint b/tools/lint new file mode 100755 index 0000000..5354afa --- /dev/null +++ b/tools/lint @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import, print_function + +import argparse +import re + +from lib.custom_check import non_py_rules +from zulint.command import LinterConfig, add_default_linter_arguments +from zulint.custom_rules import RuleList +from zulint.linters import run_pyflakes + +MYPY = False +if MYPY: + from typing import List, Tuple + + +def run(): + # type: () -> None + parser = argparse.ArgumentParser() + # Add custom parser arguments here. + + add_default_linter_arguments(parser) + args = parser.parse_args() + + linter_config = LinterConfig(args) + + # Linters will be run on these file types. + # eg: file_types = ['py', 'html', 'css', 'js'] + file_types = ["py", "sh", "md", "yaml"] + + EXCLUDED_FILES = [ + # No linters will be run on files in this list. + # eg: 'path/to/file.py' + ] # type: List[str] + by_lang = linter_config.list_files(file_types, exclude=EXCLUDED_FILES) + + command = ["tools/run-mypy", "--quiet"] + + linter_config.external_linter( + "mypy", + command, + ["py"], + pass_targets=False, + description="Static type checker for Python", + ) + + linter_config.external_linter( + "gitlint", + ["tools/commit-message-lint"], + description="Checks commit messages for common formatting errors (config: .gitlint)", + ) + + linter_config.external_linter( + "isort", + ["isort"], + ["py"], + description="Sorts Python import statements", + check_arg=["--check-only", "--diff"], + ) + + linter_config.external_linter( + "black", + ["black"], + ["py"], + description="Reformats Python code", + check_arg=["--check"], + suppress_line=lambda line: line == "All done! ✨ 🍰 ✨\n" + or re.fullmatch(r"\d+ files? would be left unchanged\.\n", line) is not None + or re.fullmatch(r"\d+ file? left unchanged\.\n", line) is not None, + ) + + linter_config.external_linter( + "prettier", + ["node_modules/.bin/prettier", "--check", "--loglevel=warn"], + ["json", "yaml", "yml"], + fix_arg=["--write"], + description="Formats Json, YAML", + ) + + linter_config.external_linter( + "shellcheck", + ["shellcheck", "-x", "-P", "SCRIPTDIR"], + ["sh"], + description="Standard shell script linter", + ) + linter_config.external_linter( + "shfmt", + ["shfmt"], + ["sh"], + check_arg="-d", + fix_arg="-w", + description="Formats shell scripts", + ) + + @linter_config.lint + def check_custom_rules(): + # type: () -> int + """Check trailing whitespace for specified files""" + trailing_whitespace_rule = RuleList( + langs=file_types, + rules=[ + { + "pattern": r"\s+$", + "strip": "\n", + "description": "Fix trailing whitespace", + } + ], + ) + failed = trailing_whitespace_rule.check(by_lang, verbose=args.verbose) + return 1 if failed else 0 + + @linter_config.lint + def custom_nonpy() -> int: + """Runs custom checks for non-python files (config: tools/lib/custom_check.py)""" + failed = False + for rule in non_py_rules: + failed = failed or rule.check(by_lang, verbose=args.verbose) + return 1 if failed else 0 + + @linter_config.lint + def pyflakes(): + # type: () -> int + suppress_patterns = [ + # Error patters in this list will be will not be reported by the linter. + # syntax: ('File Path', 'Error message') + # eg: ('path/to/file.py', 'imported but unused') + ] # type: List[Tuple[str, str]] + failed = run_pyflakes(by_lang["py"], args, suppress_patterns) + return 1 if failed else 0 + + linter_config.do_lint() + + +if __name__ == "__main__": + run() diff --git a/tools/pre-commit b/tools/pre-commit new file mode 100755 index 0000000..9f1885d --- /dev/null +++ b/tools/pre-commit @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# This hook runs the Zulip code linter ./tools/lint. + +# Messages from the linter will be printed out to the screen. +# + +changed_files=() +while read -r -d '' f; do + changed_files+=("$f") +done < <(git diff -z --cached --name-only --diff-filter=ACM) +if [ ${#changed_files} -eq 0 ]; then + echo "No changed files to lint." + exit 0 +fi + +./tools/lint --skip=gitlint "${changed_files[@]}" + +exit 0 diff --git a/tools/run-mypy b/tools/run-mypy new file mode 100755 index 0000000..4e4d5ad --- /dev/null +++ b/tools/run-mypy @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import argparse +import os +import subprocess +import sys +from typing import List + +from zulint import lister + +EXCLUDE_FILES = [] # type: List[str] + +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +os.chdir(os.path.dirname(TOOLS_DIR)) + +sys.path.append(os.path.dirname(TOOLS_DIR)) + +parser = argparse.ArgumentParser(description="Run mypy on files tracked" " by git.") + +parser.add_argument( + "targets", + nargs="*", + default=[], + help="""files and directories to include in the result. + If this is not specified, the current directory is used""", +) + +parser.add_argument( + "-m", + "--modified", + action="store_true", + default=False, + help="list only modified files", +) + +parser.add_argument( + "-a", + "--all", + dest="all", + action="store_true", + default=False, + help="""run mypy on all python files, + ignoring the exclude list. This is useful if you have to + find out which files fail mypy check.""", +) + +parser.add_argument( + "--no-disallow-untyped-defs", + dest="disallow_untyped_defs", + action="store_false", + default=True, + help="""Don't throw errors when functions are not + annotated""", +) + +parser.add_argument( + "--scripts-only", + dest="scripts_only", + action="store_true", + default=False, + help="""Only type check extensionless python scripts""", +) + +parser.add_argument( + "--no-strict-optional", + dest="strict_optional", + action="store_false", + default=True, + help="""Don't use the --strict-optional flag with mypy""", +) + +parser.add_argument( + "--warn-unused-ignores", + dest="warn_unused_ignores", + action="store_true", + default=False, + help="""Use the --warn-unused-ignores flag with mypy""", +) + +parser.add_argument( + "--no-ignore-missing-imports", + dest="ignore_missing_imports", + action="store_false", + default=True, + help="""Don't use the + --ignore-missing-imports flag with mypy""", +) + +parser.add_argument( + "--quick", + action="store_true", + default=False, + help="""Use the --quick flag with mypy""", +) + +parser.add_argument("--quiet", action="store_true", help="suppress mypy summary output") + +args = parser.parse_args() + +files_dict = lister.list_files( + targets=args.targets, + ftypes=["py"], + use_shebang=True, + modified_only=args.modified, + group_by_ftype=True, + exclude=EXCLUDE_FILES, +) + + +pyi_files = set(files_dict["pyi"]) +python_files = [ + fpath for fpath in files_dict["py"] if not fpath.endswith(".py") or fpath + "i" not in pyi_files +] + +if not python_files and not pyi_files: + print("There are no files to run mypy on.") + sys.exit(0) + +mypy_command = "mypy" + +extra_args = [ + "--check-untyped-defs", + "--follow-imports=silent", + "--scripts-are-modules", + "--disallow-any-generics", + "-i", +] +if args.disallow_untyped_defs: + extra_args.append("--disallow-untyped-defs") +if args.warn_unused_ignores: + extra_args.append("--warn-unused-ignores") +if args.strict_optional: + extra_args.append("--strict-optional") +if args.ignore_missing_imports: + extra_args.append("--ignore-missing-imports") +if args.quick: + extra_args.append("--quick") +if args.quiet: + extra_args.append("--no-error-summary") + +# run mypy +status = subprocess.call([mypy_command] + extra_args + python_files) + +if status != 0: + print("") + print("See https://zulip.readthedocs.io/en/latest/testing/mypy.html for debugging tips.") +sys.exit(status) diff --git a/tools/setup-git-repo b/tools/setup-git-repo new file mode 100755 index 0000000..7276934 --- /dev/null +++ b/tools/setup-git-repo @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +if ! [ -d ".git/hooks/" ]; then + echo "Error: Could not find .git/hooks directory" + echo "Please re-run this script from the root of your python-discord-insult-bot.git checkout" + exit 1 +fi + +for hook in pre-commit commit-msg; do + ln -snf ../../tools/"$hook" .git/hooks/ +done diff --git a/tools/setup/install-shellcheck b/tools/setup/install-shellcheck new file mode 100755 index 0000000..8bfa7f0 --- /dev/null +++ b/tools/setup/install-shellcheck @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -eu + +version=0.7.1 +tarball="shellcheck-v$version.linux.x86_64.tar.xz" +sha256=64f17152d96d7ec261ad3086ed42d18232fcb65148b44571b564d688269d36c8 + +check_version() { + out="$(shellcheck --version 2>/dev/null)" && [[ "$out" = *" +version: $version +"* ]] +} + +if ! check_version; then + tmpdir="$(mktemp -d)" + trap 'rm -r "$tmpdir"' EXIT + cd "$tmpdir" + wget -nv "https://github.com/koalaman/shellcheck/releases/download/v$version/$tarball" + sha256sum -c <<<"$sha256 $tarball" + tar -xJf "$tarball" --no-same-owner --strip-components=1 -C /usr/local/bin "shellcheck-v$version/shellcheck" + check_version +fi diff --git a/tools/setup/install-shfmt b/tools/setup/install-shfmt new file mode 100755 index 0000000..a2519fd --- /dev/null +++ b/tools/setup/install-shfmt @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu + +version=3.2.4 +binary="shfmt_v${version}_linux_amd64" +sha256=3f5a47f8fec27fae3e06d611559a2063f5d27e4b9501171dde9959b8c60a3538 + +check_version() { + out="$(shfmt --version 2>/dev/null)" && [ "$out" = "v$version" ] +} + +if ! check_version; then + tmpdir="$(mktemp -d)" + trap 'rm -r "$tmpdir"' EXIT + cd "$tmpdir" + wget -nv "https://github.com/mvdan/sh/releases/download/v$version/$binary" + sha256sum -c <<<"$sha256 $binary" + chmod +x "$binary" + mv "$binary" /usr/local/bin/shfmt + check_version +fi diff --git a/tools/setup/prep-dev-environment b/tools/setup/prep-dev-environment new file mode 100755 index 0000000..5a1d209 --- /dev/null +++ b/tools/setup/prep-dev-environment @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -eu + +apt install npm -y +python3 -m venv python-discord-insult-bot-venv +if [ -d python-discord-insult-bot-venv/bin ]; then + source python-discord-insult-bot-venv/bin/activate +fi +cd "$(dirname "$0")" + +pip3 install build +pip3 install wheel +pip3 install -r ../../requirements-dev.txt +./install-shellcheck +./install-shfmt +npm install diff --git a/tools/setup/prep-prod-environment b/tools/setup/prep-prod-environment new file mode 100755 index 0000000..9596795 --- /dev/null +++ b/tools/setup/prep-prod-environment @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -eu + +python3 -m venv python-discord-insult-bot-venv +if [ -d python-discord-insult-bot-venv/bin ]; then + source python-discord-insult-bot-venv/bin/activate +fi +cd "$(dirname "$0")" + +pip3 install -r ../../requirements.txt