├── tests ├── __init__.py ├── test_classes.py └── test_the_well_maintained_test.py ├── .python-version ├── src └── the_well_maintained_test │ ├── errors.py │ ├── __init__.py │ ├── data │ ├── __init__.py │ ├── urls.toml │ ├── questions.json │ └── questions.toml │ ├── console.py │ ├── styles.py │ ├── headers.py │ ├── _version.py │ ├── helpers.py │ ├── utils.py │ └── cli.py ├── .isort.cfg ├── example_auth.json ├── .coveragerc ├── .gitignore ├── noxfile.py ├── .pre-commit-config.yaml ├── .github ├── dependabot.yml ├── workflows │ ├── test.yml │ └── release.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── contributing.md ├── justfile ├── pyproject.toml ├── bump-version.py ├── README.md ├── CODE_OF_CONDUCT.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/errors.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | -------------------------------------------------------------------------------- /example_auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "github_personal_token": "github_personal_token goes here" 3 | } 4 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | console = Console(record=True) 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit= 3 | */venv/* 4 | */htmlcov/* 5 | 6 | [report] 7 | fail_under = 100 8 | show_missing = True 9 | skip_covered = False 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | .DS_Store 10 | .coverage 11 | auth.json 12 | dist 13 | 14 | # ignore the output files generated 15 | output.html 16 | output.txt 17 | build 18 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/styles.py: -------------------------------------------------------------------------------- 1 | question_style = "bold blue" 2 | answer_style = "italic" 3 | answer_padding_style = (1, 0, 1, 4) 4 | special_answer_padding_style = (0, 0, 0, 4) 5 | warning_style = "bold red" 6 | answer_link_style = "white" 7 | answer_link_padding_style = (0, 0, 0, 4) 8 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/headers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | try: # pragma: no cover 4 | with open("auth.json") as f: 5 | data = json.load(f) 6 | headers = { 7 | "Authorization": f"token {data['github_personal_token']}", 8 | } 9 | except FileNotFoundError: # pragma: no cover 10 | headers = {} 11 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | PYTHON_VERSIONS = [ 4 | "3.8", 5 | "3.9", 6 | "3.10", 7 | "3.11", 8 | "3.12", 9 | "3.13", 10 | ] 11 | 12 | 13 | @nox.session(python=PYTHON_VERSIONS) 14 | def tests(session): 15 | # Install pytest and any other test dependencies 16 | session.install("pytest") 17 | session.install("pytest-cov") 18 | 19 | # Install the package itself 20 | session.install(".") 21 | 22 | # Run pytest with coverage reporting 23 | session.run("pytest", "--cov", "--cov-report=term-missing", *session.posargs) 24 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/_version.py: -------------------------------------------------------------------------------- 1 | # file generated by setuptools-scm 2 | # don't change, don't track in version control 3 | 4 | __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] 5 | 6 | TYPE_CHECKING = False 7 | if TYPE_CHECKING: 8 | from typing import Tuple 9 | from typing import Union 10 | 11 | VERSION_TUPLE = Tuple[Union[int, str], ...] 12 | else: 13 | VERSION_TUPLE = object 14 | 15 | version: str 16 | __version__: str 17 | __version_tuple__: VERSION_TUPLE 18 | version_tuple: VERSION_TUPLE 19 | 20 | __version__ = version = "0.11.1.dev40+g63c42ea.d20250618" 21 | __version_tuple__ = version_tuple = (0, 11, 1, "dev40", "g63c42ea.d20250618") 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: [pre-commit.ci] pre-commit autoupdate" 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.14.7 8 | hooks: 9 | - id: ruff 10 | args: [--fix] 11 | - id: ruff-format 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v6.0.0 14 | hooks: 15 | - id: trailing-whitespace 16 | language_version: python3.12 17 | - id: end-of-file-fixer 18 | language_version: python3.12 19 | - id: check-yaml 20 | language_version: python3.12 21 | - id: check-added-large-files 22 | language_version: python3.12 23 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/data/urls.toml: -------------------------------------------------------------------------------- 1 | [url] 2 | pypi_url = "https://pypi.org/pypi/{name}/json" 3 | bugs_url="https://api.github.com/repos/{author}/{name}/issues?labels=bug" 4 | tree_url="https://api.github.com/repos/{author}/{name}/git/trees/{default_branch}?recursive=1" 5 | workflows_url="https://api.github.com/repos/{author}/{name}/actions/workflows" 6 | ci_status_url="https://api.github.com/repos/{author}/{name}/actions/runs" 7 | api_url="https://api.github.com/repos/{author}/{name}" 8 | commits_url="https://api.github.com/repos/{author}/{name}/commits/{default_branch}" 9 | changelog_url="https://raw.githubusercontent.com/{author}/{name}/{default_branch}/CHANGELOG.md" 10 | release_url="https://www.github.com/{author}/{name}/releases" 11 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/data/questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1": "1. Is it described as “production ready”?", 3 | "2": "2. Is there sufficient documentation?", 4 | "3": "3. Is there a changelog?", 5 | "4": "4. Is someone responding to bug reports?", 6 | "5": "5. Are there sufficient tests?", 7 | "6": "6. Are the tests running with the latest version?", 8 | "7": "7. Are the tests running with the latest version?", 9 | "8": "8. Is there a Continuous Integration (CI) configuration?", 10 | "9": "9. Is the CI passing?", 11 | "10": "10. Does it seem relatively well used?", 12 | "11": "11. Has there been a commit in the last year?", 13 | "12": "12. Has there been a release in the last year?" 14 | } 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | groups: 8 | python-packages: 9 | patterns: 10 | - "*" 11 | target-branch: "main" 12 | commit-message: 13 | prefix: "pip prod" 14 | include: "scope" 15 | labels: 16 | - "🛠️ maintenance" 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | groups: 22 | github-actions: 23 | patterns: 24 | - "*" 25 | target-branch: "main" 26 | commit-message: 27 | prefix: "github-actions" 28 | include: "scope" 29 | labels: 30 | - "🛠️ maintenance" 31 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To contribute to this library, first checkout the code. Then create a new virtual environment: 4 | 5 | cd toggl-to-sqlite 6 | python -m venv venv 7 | source venv/bin/activate 8 | 9 | Now install the dependencies and tests: 10 | 11 | pip install -e '.[test]' 12 | 13 | ## Running the tests 14 | 15 | To run the tests: 16 | 17 | pytest 18 | 19 | ## Code style 20 | 21 | This library uses 22 | 23 | * [Black](https://github.com/psf/black) and [Flake8](https://github.com/PyCQA/flake8) for code formatting. 24 | * [isort](https://github.com/PyCQA/isort) to sort packages 25 | 26 | The correct version of these libraries will be installed by `pip install -e '.[test]'` - you can run `pre-commit run --all-files` in the root directory to apply those formatting rules. 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | # Run daily at 1:23 UTC 9 | schedule: 10 | - cron: '23 1 * * *' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - uses: actions/cache@v5 26 | name: Configure pip caching 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pip- 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install '.[test]' 35 | - name: Run tests 36 | run: | 37 | pytest 38 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Pull request type 4 | 5 | 6 | 7 | Please check the type of change your PR introduces: 8 | 9 | - [ ] Bugfix 10 | - [ ] Feature 11 | - [ ] Code style update (formatting, renaming) 12 | - [ ] Refactoring (no functional changes, no api changes) 13 | - [ ] Build related changes 14 | - [ ] Documentation content changes 15 | - [ ] Other (please describe): 16 | 17 | ## What is the current behavior? 18 | 19 | 20 | 21 | Issue Number: N/A 22 | 23 | ## What is the new behavior? 24 | 25 | 26 | 27 | - 28 | - 29 | - 30 | 31 | ## Other information 32 | 33 | 34 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | @default: 2 | just --list 3 | 4 | # run tests via pytest, creates coverage report, and then opens it up 5 | test: 6 | uv run coverage run -m pytest 7 | uv run coverage html 8 | open htmlcov/index.html 9 | 10 | # runs the pre-commit check command 11 | check: lint mypy 12 | pre-commit run --all-files 13 | 14 | # run ruff linting and formatting 15 | lint: 16 | uv run ruff check --fix 17 | uv run ruff format 18 | 19 | # opens the coverage index 20 | coverage: 21 | open htmlcov/index.html 22 | 23 | # prunes remote branches from github 24 | prune: 25 | git remote prune origin 26 | 27 | # removes all but main and dev local branch 28 | gitclean: 29 | git branch | grep -v "main" | grep -v "dev"| xargs git branch -D 30 | 31 | 32 | # run mypy on the files 33 | mypy: 34 | uv run mypy pelican/plugins/pelican_to_sqlite/*.py --no-strict-optional 35 | 36 | 37 | # generates the README.md file --help section 38 | cog: 39 | cog -r README.md 40 | 41 | 42 | # generates the README.md file --help section 43 | docs: 44 | cog -r README.md 45 | cp README.md docs/index.md 46 | 47 | # pulls from branch 48 | sync branch: 49 | git switch {{branch}} 50 | git pull origin {{branch}} 51 | 52 | pre-commit: 53 | pre-commit run --all-files 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: ":bug:: " 4 | labels: ["bug"] 5 | assignees: 6 | - ryancheley 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: textarea 13 | id: what-happened 14 | attributes: 15 | label: Expected behavior 16 | description: What is the expected behavior 17 | validations: 18 | required: true 19 | - type: dropdown 20 | id: os 21 | attributes: 22 | label: What OSes are you seeing the problem on? 23 | multiple: true 24 | options: 25 | - macOS 26 | - Windows 27 | - Linux 28 | - Other 29 | - type: textarea 30 | id: os-version 31 | attributes: 32 | label: OS versions 33 | description: For the OS selected above, what version(s) of each are you seeing the error on 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: logs 38 | attributes: 39 | label: Relevant log output 40 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 41 | render: shell 42 | - type: checkboxes 43 | id: terms 44 | attributes: 45 | label: Code of Conduct 46 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/ryancheley/pelican-to-sqlite/blob/main/CODE_OF_CONDUCT.md) 47 | options: 48 | - label: I agree to follow this project's Code of Conduct 49 | required: true 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "the-well-maintained-test" 3 | dynamic = ["version"] 4 | description = "Programatically tries to answer the 12 questions from Adam Johnson's blog post https://adamj.eu/tech/2021/11/04/the-well-maintained-test/" 5 | readme = "README.md" 6 | authors = [{name = "Ryan Cheley"}] 7 | license = {text = "Apache-2.0"} 8 | classifiers = [ 9 | "Development Status :: 4 - Beta" 10 | ] 11 | dependencies = [ 12 | "click", 13 | "importlib-resources", 14 | "requests", 15 | "rich", 16 | "toml" 17 | ] 18 | requires-python = ">=3.9" 19 | 20 | [build-system] 21 | requires = ["hatchling", "hatch-vcs"] 22 | build-backend = "hatchling.build" 23 | 24 | [project.urls] 25 | Issues = "https://github.com/ryancheley/the-well-maintained-test/issues" 26 | CI = "https://github.com/ryancheley/the-well-maintained-test/actions" 27 | Changelog = "https://github.com/ryancheley/the-well-maintained-test/releases" 28 | 29 | [project.scripts] 30 | the-well-maintained-test = "the_well_maintained_test.cli:cli" 31 | 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | "pytest", 36 | "coverage", 37 | "mypy", 38 | ] 39 | docs = [ 40 | "mkdocs", 41 | "mkdocstrings[python]", 42 | "markdown-include", 43 | ] 44 | dev = [ 45 | "packaging", 46 | "ruff", 47 | "cogapp", 48 | "build>=1.2.2.post1", 49 | "pre-commit>=4.2.0", 50 | "twine>=6.1.0", 51 | ] 52 | 53 | [tool.hatch.build.targets.wheel] 54 | sources = ["src"] 55 | 56 | [tool.hatch.version] 57 | source = "vcs" 58 | fallback-version = "0.6.3" 59 | 60 | [tool.hatch.build.hooks.vcs] 61 | version-file = "src/the_well_maintained_test/_version.py" 62 | 63 | [tool.ruff] 64 | line-length = 130 65 | target-version = "py39" 66 | 67 | [tool.ruff.lint] 68 | select = ["E", "F", "I", "UP"] 69 | ignore = [] 70 | 71 | [tool.ruff.lint.per-file-ignores] 72 | "*/_version.py" = ["UP035", "UP006", "I001"] 73 | 74 | [tool.ruff.format] 75 | quote-style = "double" 76 | indent-style = "space" 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: ":sparkles:: " 4 | labels: ["enhancement"] 5 | assignees: 6 | - ryancheley 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this enhancement request! 12 | - type: textarea 13 | id: feature-problem 14 | attributes: 15 | label: Feature Problem 16 | description: Is your feature request related to a problem? Please describe. 17 | placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: feature-solution 22 | attributes: 23 | label: Feature Solution 24 | description: Describe the solution you'd like 25 | placeholder: A clear and concise description of what you want to happen. 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: feature-alternatives 30 | attributes: 31 | label: Alternatives Considered 32 | description: Describe alternatives you've considered 33 | placeholder: A clear and concise description of any alternative solutions or features you've considered. 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: feature-context 38 | attributes: 39 | label: Feature Context 40 | description: Additional context 41 | placeholder: Add any other context or screenshots about the feature request here. 42 | validations: 43 | required: true 44 | - type: checkboxes 45 | id: terms 46 | attributes: 47 | label: Code of Conduct 48 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/ryancheley/pelican-to-sqlite/blob/main/CODE_OF_CONDUCT.md) 49 | options: 50 | - label: I agree to follow this project's Code of Conduct 51 | required: true 52 | -------------------------------------------------------------------------------- /bump-version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple script to bump version using git tags. 4 | Usage: 5 | python bump-version.py patch # 0.7.1 -> 0.7.2 6 | python bump-version.py minor # 0.7.1 -> 0.8.0 7 | python bump-version.py major # 0.7.1 -> 1.0.0 8 | """ 9 | 10 | import subprocess 11 | import sys 12 | 13 | from packaging import version 14 | 15 | 16 | def get_latest_tag(): 17 | """Get the latest git tag.""" 18 | try: 19 | result = subprocess.run(["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True, check=True) 20 | return result.stdout.strip() 21 | except subprocess.CalledProcessError: 22 | return "0.0.0" 23 | 24 | 25 | def bump_version(current_version, bump_type): 26 | """Bump version based on type.""" 27 | v = version.Version(current_version) 28 | 29 | if bump_type == "patch": 30 | new_version = f"{v.major}.{v.minor}.{v.micro + 1}" 31 | elif bump_type == "minor": 32 | new_version = f"{v.major}.{v.minor + 1}.0" 33 | elif bump_type == "major": 34 | new_version = f"{v.major + 1}.0.0" 35 | else: 36 | raise ValueError(f"Invalid bump type: {bump_type}") 37 | 38 | return new_version 39 | 40 | 41 | def create_tag(new_version): 42 | """Create and push new git tag.""" 43 | tag_name = f"v{new_version}" 44 | 45 | # Create tag 46 | subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {new_version}"], check=True) 47 | 48 | # Push tag 49 | subprocess.run(["git", "push", "origin", tag_name], check=True) 50 | 51 | print(f"Created and pushed tag: {tag_name}") 52 | 53 | 54 | def main(): 55 | if len(sys.argv) != 2 or sys.argv[1] not in ["patch", "minor", "major"]: 56 | print(__doc__) 57 | sys.exit(1) 58 | 59 | bump_type = sys.argv[1] 60 | 61 | # Get current version from latest tag 62 | current_tag = get_latest_tag() 63 | current_version = current_tag.lstrip("v") if current_tag.startswith("v") else current_tag 64 | 65 | print(f"Current version: {current_version}") 66 | 67 | # Bump version 68 | new_version = bump_version(current_version, bump_type) 69 | print(f"New version: {new_version}") 70 | 71 | # Confirm 72 | response = input(f"Create tag v{new_version}? (y/N): ") 73 | if response.lower() != "y": 74 | print("Cancelled") 75 | sys.exit(0) 76 | 77 | # Create tag 78 | create_tag(new_version) 79 | print(f"Version bumped to {new_version}") 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from collections import namedtuple 4 | from datetime import datetime 5 | from operator import attrgetter 6 | from pathlib import Path 7 | from urllib.parse import urlparse 8 | 9 | import requests 10 | 11 | SORRY_MESSAGE = """ 12 | This package does not have project_urls defined. You may want to contact them or raise an issue with them to include it. 13 | 14 | Documentation for project_urls can be found here: 15 | 16 | https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#project-urls 17 | """ 18 | 19 | 20 | def _get_bug_comment_list(url: str, headers: dict) -> list: 21 | BugComments = namedtuple("BugComments", ["text", "create_date"]) 22 | bug_comment_list = [] 23 | timeline = requests.get(url, headers=headers).json() 24 | for t in timeline: 25 | if t.get("event") == "commented": 26 | bug_comment = t.get("body") 27 | bug_comment_date = datetime.strptime(t.get("created_at"), "%Y-%m-%dT%H:%M:%SZ") 28 | bug_comment_list.append(BugComments(bug_comment, bug_comment_date)) 29 | return bug_comment_list 30 | 31 | 32 | def _get_content(url: str, headers: dict) -> bytes: 33 | response = requests.get(url, headers=headers).json() 34 | if response.get("encoding") != "base64": 35 | raise TypeError 36 | else: 37 | content = response.get("content") 38 | return content 39 | 40 | 41 | def _test_method_count(content: bytes) -> int: 42 | content_list = str(base64.b64decode(content)).split("\\n") 43 | test_methods = [s for s in content_list if "test_" in s] 44 | return len(test_methods) 45 | 46 | 47 | def _get_test_files(url: str, headers: dict) -> list: 48 | test_file_list = [] 49 | r = requests.get(url, headers=headers).json() 50 | for i in r.get("tree"): 51 | if i.get("type") == "blob" and re.search(r"test(s|_(.*)).py", i.get("path")): 52 | test_file_list.append(i) 53 | 54 | return test_file_list 55 | 56 | 57 | def _get_release_date(release: dict) -> list: 58 | Release = namedtuple("Release", "version, upload_time") 59 | releases = [] 60 | for k, v in release.items(): 61 | if not re.search(r"[a-zA-Z]", k): 62 | try: 63 | releases.append(Release(k, v[0].get("upload_time"))) 64 | except IndexError: 65 | pass 66 | releases = sorted(releases, key=attrgetter("upload_time"), reverse=True) 67 | return releases 68 | 69 | 70 | def _get_requirements_txt_file(requirements_file: Path) -> list: 71 | with open(requirements_file) as f: 72 | requirements = f.readlines() 73 | packages = [s.replace("\n", "").replace("==", " ").split(" ")[0] for s in requirements] 74 | package_urls = [] 75 | for package in packages: 76 | data = _get_package_github_url(package) 77 | package_urls.append(data) 78 | return sorted(package_urls, key=lambda x: x[0].lower()) 79 | 80 | 81 | def _get_package_github_url(package: str) -> tuple: 82 | url = f"https://pypi.org/pypi/{package}/json" 83 | project_urls = requests.get(url).json().get("info").get("project_urls") 84 | for k, v in project_urls.items(): 85 | if urlparse(v).netloc == "github.com" and len(urlparse(v).path.split("/")) == 3: 86 | value = (package, v) 87 | elif urlparse(v).netloc == "github.com" and len(urlparse(v).path.split("/")) == 4: 88 | p = urlparse(v).path.split("/")[1] 89 | a = urlparse(v).path.split("/")[2] 90 | value = (package, f"https://www.github.com/{p}/{a}") 91 | return value 92 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: Build distribution 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: "3.x" 23 | - name: Install build dependencies 24 | run: python -m pip install --upgrade pip build 25 | - name: Build distributions 26 | run: python -m build 27 | - name: Store the distribution packages 28 | uses: actions/upload-artifact@v6 29 | with: 30 | name: python-package-distributions 31 | path: dist/ 32 | retention-days: 90 33 | 34 | test: 35 | name: Test package 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 40 | steps: 41 | - uses: actions/checkout@v6 42 | with: 43 | fetch-depth: 0 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v6 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Install uv 49 | uses: astral-sh/setup-uv@v7 50 | - name: Install dependencies 51 | run: uv sync --extra test 52 | - name: Run tests 53 | run: uv run pytest 54 | 55 | publish-to-pypi: 56 | name: Publish to PyPI 57 | if: startsWith(github.ref, 'refs/tags/') 58 | needs: 59 | - build 60 | - test 61 | runs-on: ubuntu-latest 62 | environment: 63 | name: pypi 64 | url: https://pypi.org/p/the-well-maintained-test 65 | permissions: 66 | id-token: write 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v6 70 | with: 71 | fetch-depth: 0 72 | - name: Check if tag is on main branch 73 | run: | 74 | # Fetch main branch to check if tag is on it 75 | git fetch origin main:main 76 | if ! git merge-base --is-ancestor ${{ github.sha }} origin/main; then 77 | echo "Tag ${{ github.ref_name }} is not on main branch. Skipping PyPI publish." 78 | exit 1 79 | fi 80 | echo "Tag ${{ github.ref_name }} is on main branch. Proceeding with PyPI publish." 81 | - name: Download all the dists 82 | uses: actions/download-artifact@v7 83 | with: 84 | name: python-package-distributions 85 | path: dist/ 86 | - name: Publish distribution to PyPI 87 | uses: pypa/gh-action-pypi-publish@release/v1 88 | 89 | github-release: 90 | name: Create Github Release 91 | needs: 92 | - publish-to-pypi 93 | runs-on: ubuntu-latest 94 | permissions: 95 | contents: write 96 | id-token: write 97 | steps: 98 | - name: Download all the dists 99 | uses: actions/download-artifact@v7 100 | with: 101 | name: python-package-distributions 102 | path: dist/ 103 | - name: Sign the dists with Sigstore 104 | uses: sigstore/gh-action-sigstore-python@v3.2.0 105 | with: 106 | inputs: >- 107 | ./dist/*.tar.gz 108 | ./dist/*.whl 109 | - name: Create GitHub Release 110 | env: 111 | GITHUB_TOKEN: ${{ github.token }} 112 | run: >- 113 | gh release create 114 | '${{ github.ref_name }}' 115 | --repo '${{ github.repository }}' 116 | --notes "" 117 | - name: Upload artifact signatures to GitHub Release 118 | env: 119 | GITHUB_TOKEN: ${{ github.token }} 120 | run: >- 121 | gh release upload 122 | '${{ github.ref_name }}' dist/** 123 | --repo '${{ github.repository }}' 124 | 125 | publish-to-testpypi: 126 | name: Publish to TestPyPI 127 | needs: 128 | - build 129 | - test 130 | runs-on: ubuntu-latest 131 | environment: 132 | name: testpypi 133 | url: https://test.pypi.org/p/the-well-maintained-test 134 | permissions: 135 | id-token: write 136 | steps: 137 | - name: Download all the dists 138 | uses: actions/download-artifact@v7 139 | with: 140 | name: python-package-distributions 141 | path: dist/ 142 | - name: Publish distribution to TestPyPI 143 | uses: pypa/gh-action-pypi-publish@release/v1 144 | with: 145 | repository-url: https://test.pypi.org/legacy/ 146 | skip-existing: true 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # the-well-maintained-test 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/the-well-maintained-test.svg)](https://pypi.org/project/the-well-maintained-test/) 4 | [![Changelog](https://img.shields.io/github/v/release/ryancheley/the-well-maintained-test?include_prereleases&label=changelog)](https://github.com/ryancheley/the-well-maintained-test/releases) 5 | [![Tests](https://github.com/ryancheley/the-well-maintained-test/workflows/Test/badge.svg)](https://github.com/ryancheley/the-well-maintained-test/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/ryancheley/the-well-maintained-test/blob/master/LICENSE) 7 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 8 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/ryancheley/the-well-maintained-test/main.svg)](https://results.pre-commit.ci/latest/github/ryancheley/the-well-maintained-test/main) 9 | 10 | 11 | 12 | Programatically tries to answer the 12 questions from Adam Johnson's [blog post](https://adamj.eu/tech/2021/11/04/the-well-maintained-test/) 13 | 14 | ## Installation 15 | 16 | ### uv (recommended) 17 | 18 | The preferred method of installation for this tool is [uv](https://docs.astral.sh/uv/). 19 | 20 | uv tool install the-well-maintained-test 21 | 22 | ### pipx 23 | 24 | Alternatively, you can use [pipx](https://pypa.github.io/pipx/). 25 | 26 | pipx install the-well-maintained-test 27 | 28 | ### virtual environment 29 | 30 | This tool can be installed in a virtual environment using pip: 31 | 32 | Create your virtual environment 33 | 34 | python3 -m venv venv 35 | source venv/bin/activate 36 | 37 | Install with pip 38 | 39 | python -m pip install the-well-maintained-test 40 | 41 | ## Authentication 42 | The GitHub API will rate limit anonymous calls. You can authenticate yourself with a personal token (documentation on how to generate is [here](https://github.com/settings/tokens)) 43 | 44 | Run this command and paste in your new token: 45 | 46 | the-well-maintained-test auth 47 | 48 | This will create a file called auth.json in your current directory containing the required value. To save the file at a different path or filename, use the `--auth=myauth.json` option. 49 | 50 | ## the-well-maintained-test --help 51 | 52 | 63 | ``` 64 | Usage: the-well-maintained-test [OPTIONS] COMMAND [ARGS]... 65 | 66 | Programatically tries to answer the 12 questions from Adam Johnson's blog post 67 | https://adamj.eu/tech/2021/11/04/the-well-maintained-test/ 68 | 69 | package is a package on pypi you'd like to check: 70 | 71 | the-well-maintained-test package the-well-maintained-test 72 | 73 | Options: 74 | --version Show the version and exit. 75 | --help Show this message and exit. 76 | 77 | Commands: 78 | auth Generates a json file with your GitHub Personal Token so... 79 | check Check your GitHub API Usage Stats 80 | package Name of a package on PyPi you'd like to check 81 | questions List of questions tested 82 | requirements Loop over a requirements.txt file 83 | 84 | ``` 85 | 86 | 87 | ## Development 88 | 89 | To contribute to this tool, first checkout the code. This project uses [uv](https://docs.astral.sh/uv/) for modern Python dependency management. 90 | 91 | ### Using uv (recommended) 92 | 93 | cd the-well-maintained-test 94 | uv sync --extra test 95 | 96 | This will create a virtual environment and install all dependencies including test dependencies. 97 | 98 | To run the tests: 99 | 100 | uv run pytest 101 | 102 | ### Alternative: Traditional setup 103 | 104 | If you prefer not to use uv, you can still use traditional tools: 105 | 106 | cd the-well-maintained-test 107 | python3 -m venv venv 108 | source venv/bin/activate 109 | pip install -e '.[test]' 110 | 111 | To run the tests: 112 | 113 | just test 114 | 115 | ### Development commands 116 | 117 | With uv: 118 | 119 | # Run the CLI tool 120 | uv run the-well-maintained-test --help 121 | 122 | # Run tests 123 | uv run pytest 124 | 125 | # Run mypy 126 | uv run mypy src/the_well_maintained_test/*.py --no-strict-optional 127 | 128 | # Install development dependencies 129 | uv sync --extra dev 130 | 131 | The commands below use the command runner [just](https://github.com/casey/just). If you would rather not use `just` the raw commands are also listed above. 132 | 133 | To run `mypy` command you'll need to run 134 | 135 | mypy --install-types 136 | 137 | Then, to run mypy: 138 | 139 | just mypy 140 | 141 | OR the raw command is 142 | 143 | mypy src/the_well_maintained_test/*.py --no-strict-optional 144 | 145 | You can also do a pre-commit check on the files by running 146 | 147 | just check 148 | 149 | OR the raw commands are 150 | 151 | pre-commit run --all-files 152 | mypy src/the_well_maintained_test/*.py --no-strict-optional 153 | 154 | This will run several pre-commit hooks, but before that it will run `mypy` 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | rcheley+ttscoc@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/data/questions.toml: -------------------------------------------------------------------------------- 1 | [question] 2 | [question.1] 3 | question_text = "1. Is it described as “production ready”?" 4 | question_description = """ 5 | We want to see evidence that the maintainers consider the software as ready for use in production. 6 | 7 | The documentation shouldn’t have any banners or wording implying a future stable release. 8 | 9 | The version number should not be a pre-release, alpha, beta, release candidate, etc. Note that some maintainers stick with a “zero version number” like 0.4.0, even when they consider the package production ready. 10 | """ 11 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-it-described-as-production-ready" 12 | question_function = "production_ready_check" 13 | question_url = "pypi_url" 14 | headers_needed = "N" 15 | [question.2] 16 | question_text = "2. Is there sufficient documentation?" 17 | question_description = """ 18 | If we can’t find information on what the package currently does, it seems doubtful the future will be easy. 19 | 20 | “Sufficient” varies based upon: the scope of the library, the ecosystem, and your preferences. 21 | 22 | Documentation comes in many forms: a README file, a documentation site, a wiki, blog posts, etc. Hopefully the package doesn’t make you hunt for it. 23 | """ 24 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-there-sufficient-documentation" 25 | question_function = "documentation_exists" 26 | question_url = "pypi_url" 27 | headers_needed = "N" 28 | [question.3] 29 | question_text = "3. Is there a changelog?" 30 | question_description = """ 31 | A changelog, or a release notes page, is vital for our ability to update the package. The changelog is the main place for communication of breaking changes. (A case for changelogs is made at keepachangelog.com.) 32 | 33 | Changelogs come in many forms: a single file, a documentation section, GitHub release descriptions, etc. Again, hopefully the package doesn’t make you hunt for it. 34 | 35 | Note that some projects “have a changelog”, but it has stopped being maintained since the project’s inception. So check that the changelog covers recent releases. 36 | """ 37 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-there-a-changelog" 38 | question_function = "change_log_check" 39 | question_url = "pypi_url" 40 | headers_needed = "N" 41 | [question.4] 42 | question_text = "4. Is someone responding to bug reports?" 43 | question_description = """ 44 | If recent bug reports have gone unanswered, it may be a sign that the package is no longer maintained. It’s worth ignoring any “spammy” open issues, and checking for recently closed issues since they are activity. 45 | 46 | Check for issues like “is this still maintained?”… the answer is probably “no”, per Betteridge's law of headlines. 47 | """ 48 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-someone-responding-to-bug-reports" 49 | question_function = "bug_responding" 50 | question_url = "bugs_url" 51 | headers_needed = "Y" 52 | [question.5] 53 | question_text = "5. Are there sufficient tests?" 54 | question_description = """ 55 | Tests give us confidence that future changes will not result in bugs. 56 | 57 | Again, “sufficient” is context-dependent: testing norms in our language and ecosystem, ease of testing the functionality, and personal preferences. 58 | 59 | Measurement of test coverage is normally a sign that the tests are higher quality. With coverage, maintainers can at least tell when changes affect untested code. 60 | 61 | If there’s no proof of coverage, it’s worth opening a few test files, to check that they aren’t auto-created empty skeletons. 62 | """ 63 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#are-there-sufficient-tests" 64 | question_function = "check_tests" 65 | question_url = "tree_url" 66 | headers_needed = "Y" 67 | [question.6] 68 | question_text = "6. Are the tests running with the latest version?" 69 | question_description = """ 70 | Most programming languages iterate regularly. Python has annual releases, as does JavaScript (ECMAScript). If a package we’re considering doesn’t support the latest version, it may prevent us from upgrading. 71 | 72 | We can grant some leeway for very recent language versions. If Python 3.10 was released last Tuesday, we cannot expect every package to be up to date. 73 | 74 | Testing against a new language version can be an easy way to contribute. Often the new version only needs adding to the test matrix, although that may reveal some bugs. 75 | """ 76 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#are-the-tests-running-with-the-latest-language-version" 77 | question_function = "language_check" 78 | question_url = "pypi_url" 79 | headers_needed = "N" 80 | [question.7] 81 | question_text = "7. Are the tests running with the latest version?" 82 | question_description = """ 83 | here could mean a framework that the package is based on, like Django, or something the package interfaces with, like PostgreSQL. It could mean several things, in which case we can check them all. 84 | 85 | The same conditions apply as for the latest version. And again, adding tests for a new version may be an easy way to contribute. 86 | """ 87 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#are-the-tests-running-with-the-latest-integration-version" 88 | question_function = "framework_check" 89 | question_url = "pypi_url" 90 | headers_needed = "N" 91 | [question.8] 92 | question_text = "8. Is there a Continuous Integration (CI) configuration?" 93 | question_description = """ 94 | If there are tests, it’s likely there’s a CI system set up, such as GitHub Actions. We should check that this in place, and running correctly for recent changes. 95 | """ 96 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-there-a-continuous-integration-ci-configuration" 97 | question_function = "ci_setup" 98 | question_url = "workflows_url" 99 | headers_needed = "Y" 100 | [question.9] 101 | question_text = "9. Is the CI passing?" 102 | question_description = """ 103 | Some projects configure CI but then ignore it or leave it unmaintained. CI may be failing, for one or more or versions. If this has gone on for a while, it is a sign that maintenance is lagging. 104 | 105 | Sometimes CI failure is caused by a single small bug, so fixing it may be a quick contribution. It can also be the case that old versions of or s can simply be dropped. 106 | """ 107 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#is-the-ci-passing" 108 | question_function = "ci_passing" 109 | question_url = "ci_status_url" 110 | headers_needed = "Y" 111 | [question.10] 112 | question_text = "10. Does it seem relatively well used?" 113 | question_description = """ 114 | We can guesstimate usage by checking recent download counts, and to a lesser extent, popularity metrics like GitHub’s “stars”. Many package indexes, like npm, show download counts on package pages. For PyPI, we can use pypistats.org. 115 | 116 | We can only compare usage relative to similar packages, popularity of any s, and our . A particularly niche tool may see minimal usage, but it might still beat any “competitor” packages. 117 | """ 118 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#does-it-seem-relatively-well-used" 119 | question_function = "well_used" 120 | question_url = "api_url" 121 | headers_needed = "Y" 122 | [question.11] 123 | question_text = "11. Has there been a commit in the last year?" 124 | question_description = """ 125 | Maintainers tend to abandon packages rather than explicitly mark them as unmaintained. So the probability of future maintenance drops off the longer a project has not seen a commit. 126 | 127 | We’d like to see at least one recent commit as a “sign of life”. 128 | 129 | Any cutoff is arbitrary, but a year aligns with most programming languages’ annual release cadence. 130 | """ 131 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#has-there-been-a-commit-in-the-last-year" 132 | question_function = "commit_in_last_year" 133 | question_url = "commits_url" 134 | headers_needed = "Y" 135 | [question.12] 136 | question_text = "12. Has there been a release in the last year?" 137 | question_description = """ 138 | A backlog of unreleased commits can also be a sign of inattention. Active maintainers may have permission to merge but not release, with the true “owner” of the project absent. 139 | """ 140 | question_link = "https://adamj.eu/tech/2021/11/04/the-well-maintained-test/#has-there-been-a-release-in-the-last-year" 141 | question_function = "release_in_last_year" 142 | question_url = "pypi_url" 143 | headers_needed = "N" 144 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime, timezone 4 | from gettext import ngettext 5 | from operator import attrgetter 6 | from pathlib import Path 7 | from time import localtime, strftime 8 | 9 | import requests 10 | from rich.progress import Progress 11 | from rich.prompt import Prompt 12 | 13 | from the_well_maintained_test.console import console 14 | from the_well_maintained_test.helpers import ( 15 | _get_bug_comment_list, 16 | _get_content, 17 | _get_release_date, 18 | _get_test_files, 19 | _test_method_count, 20 | ) 21 | 22 | 23 | def production_ready_check(pypi_api_url: str) -> str: 24 | response = requests.get(pypi_api_url).json() 25 | classifiers = response.get("info").get("classifiers") 26 | version = response.get("info").get("version") 27 | try: 28 | development_status = [s for s in classifiers if "Development Status" in s][0] 29 | development_status_start_point = re.search(r"Development Status :: [\d] \- ", development_status).span()[1] 30 | development_status_str_len = len(development_status) 31 | status = development_status[(development_status_start_point - development_status_str_len) :] 32 | except IndexError: 33 | development_status = [] 34 | if development_status: 35 | message = f"[green]The project is set to Development Status [underline]{status}" 36 | else: 37 | message = f"[red]There is no Development Status for this package. It is currently at version {version}" 38 | return message 39 | 40 | 41 | def documentation_exists(pypi_api_url: str) -> str: 42 | response = requests.get(pypi_api_url).json() 43 | docs = response.get("info").get("project_urls").get("Documentation") 44 | if docs: 45 | message = f"[green]Documentation can be found at {docs}" 46 | else: 47 | message = "[red]There is no documentation for this project" 48 | return message 49 | 50 | 51 | def change_log_check(changelog_url: str) -> str: 52 | project_urls = requests.get(changelog_url).json().get("info").get("project_urls") 53 | change_log_types = ["Release notes", "Changelog"] 54 | if any(item in change_log_types for item in list(project_urls.keys())): 55 | return "[green]Yes" 56 | else: 57 | return "[red]No" 58 | 59 | 60 | def bug_responding(bugs_url: str, headers: dict) -> str: 61 | """ 62 | 4. Is someone responding to bug reports? 63 | """ 64 | 65 | r = requests.get(bugs_url, headers=headers).json() 66 | open_bug_count = len(r) 67 | bug_comment_list = [] 68 | if open_bug_count == 0: 69 | message = "[green]There have been no bugs reported that are still open." 70 | else: 71 | for i in r: 72 | bug_create_date = datetime.strptime(i.get("created_at"), "%Y-%m-%dT%H:%M:%SZ") 73 | bug_comment_list = _get_bug_comment_list(i.get("timeline_url"), headers=headers) 74 | bug_comment_list = sorted(bug_comment_list, key=attrgetter("create_date"), reverse=True) 75 | if bug_comment_list: 76 | bug_turn_around_time_reply_days = (bug_comment_list[0].create_date - bug_create_date).days 77 | # If bug_comment_list[0].create_date is naive, make it timezone-aware 78 | if bug_comment_list[0].create_date.tzinfo is None: 79 | create_date = bug_comment_list[0].create_date.replace(tzinfo=timezone.utc) 80 | else: 81 | create_date = bug_comment_list[0].create_date 82 | days_since_last_bug_comment = (datetime.now(timezone.utc) - create_date).days 83 | # TODO: add logic to better colorize the message 84 | message1 = f"The maintainer took {bug_turn_around_time_reply_days} " 85 | message1 += "days to respond to the bug report" 86 | message2 = f"It has been {days_since_last_bug_comment} days since a comment was made on the bug." 87 | message = f"[green]{message1}\n{message2}" 88 | else: 89 | verb = ngettext("is", "are", open_bug_count) 90 | message = f"[red]There {verb} {open_bug_count} bugs with no comments" 91 | return message 92 | 93 | 94 | def check_tests(tree_url: str, headers: dict, show_progress: bool = True) -> str: 95 | """ 96 | 5. Are there sufficient tests? 97 | """ 98 | test_list = _get_test_files(tree_url, headers=headers) 99 | total = len(test_list) 100 | test_files = 0 101 | test_functions = 0 102 | with Progress() as progress: 103 | test_file_reading_task = progress.add_task("[green]Processing...", total=total, visible=show_progress) 104 | for i in test_list: 105 | content = _get_content(i.get("url"), headers) 106 | test_count = _test_method_count(content) 107 | test_files += 1 108 | test_functions = test_functions + test_count 109 | progress.update(test_file_reading_task, advance=1) 110 | progress.remove_task(test_file_reading_task) 111 | if test_files == 0: 112 | message = "[red]There are 0 tests!" 113 | else: 114 | verb = ngettext("is", "are", test_functions) 115 | message = f"[green]There {verb} {test_functions} tests in {test_files} files:\n" 116 | for test in test_list: 117 | message += f"- {test.get('path')}\n" 118 | return message 119 | 120 | 121 | def language_check(pypi_url: str) -> str: 122 | """ 123 | 6. Are the tests running with the latest Language version? 124 | """ 125 | response = requests.get(pypi_url).json() 126 | classifiers = response.get("info").get("classifiers") 127 | languages = [s.replace("Programming Language :: Python :: ", "Python ") for s in classifiers if "Programming Language" in s] 128 | message = "[green]The project supports the following programming languages\n" 129 | for language in languages: 130 | message += f"- {language}\n" 131 | return message 132 | 133 | 134 | # TODO: reqrite to list all frameworks as rich only shows IPython! 135 | def framework_check(pypi_url: str) -> str: 136 | """ 137 | 7. Are the tests running with the latest Integration version? 138 | """ 139 | response = requests.get(pypi_url).json() 140 | classifiers = response.get("info").get("classifiers") 141 | frameworks = [s.replace("Framework Django", "Framework").replace(" ::", "") for s in classifiers if "Framework" in s] 142 | if frameworks: 143 | framework = [s for s in classifiers if "Framework" in s][-1].replace(" :: ", " ") 144 | message = f"[green]The project supports the following framework as it's latest[bold] {framework}" 145 | else: 146 | message = "[green]This project has no associated frameworks" 147 | return message 148 | 149 | 150 | def ci_setup(workflows_url: str, headers: dict) -> str: 151 | """ 152 | 8. Is there a Continuous Integration (CI) configuration? 153 | """ 154 | r = requests.get(workflows_url, headers=headers).json() 155 | if r.get("total_count") > 0: 156 | workflow_count = r.get("total_count") 157 | verb = ngettext("is", "are", workflow_count) 158 | message = f"[green]There {verb} {workflow_count} workflows\n" 159 | for i in r.get("workflows"): 160 | message += f"[green]- {i.get('name')}\n" 161 | return message 162 | else: 163 | return "[red]There is no CI set up!" 164 | 165 | 166 | def ci_passing(ci_status_url: str, headers: dict) -> str: 167 | """ 168 | 9. Is the CI passing? 169 | """ 170 | r = requests.get(ci_status_url, headers=headers).json() 171 | conclusion = None 172 | try: 173 | conclusion = r.get("workflow_runs")[0].get("conclusion") 174 | except IndexError: 175 | pass 176 | if conclusion == "success": 177 | return "[green]Yes" 178 | else: 179 | return "[red]No" 180 | 181 | 182 | def well_used(api_url: str, headers: dict) -> str: 183 | """ 184 | 10. Does it seem relatively well used? 185 | """ 186 | r = requests.get(api_url, headers=headers).json() 187 | watchers = r.get("watchers") 188 | network_count = r.get("network_count") 189 | open_issues = r.get("open_issues") 190 | subscribers_count = r.get("subscribers_count") 191 | message = "The project has the following statistics:\n" 192 | message += f"- Watchers: {watchers}\n" 193 | message += f"- Forks: {network_count}\n" 194 | message += f"- Open Issues: {open_issues}\n" 195 | message += f"- Subscribers: {subscribers_count}" 196 | return f"[green]{message}" 197 | 198 | 199 | def commit_in_last_year(commits_url: str, headers: dict) -> str: 200 | """ 201 | 11. Has there been a commit in the last year? 202 | """ 203 | r = requests.get(commits_url, headers=headers).json() 204 | last_commit_date = r.get("commit").get("author").get("date") 205 | last_commit_date = datetime.strptime(last_commit_date, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) 206 | days_since_last_commit = (datetime.now(timezone.utc) - last_commit_date).days 207 | if days_since_last_commit > 365: 208 | message = f"[red]No. The last commit was {days_since_last_commit} days ago" 209 | else: 210 | message = f"[green]Yes. The last commit was on {datetime.strftime(last_commit_date, '%m-%d-%Y')} " 211 | message += f"which was {days_since_last_commit} days ago" 212 | 213 | return message 214 | 215 | 216 | def release_in_last_year(pypi_api_url: str) -> str: 217 | """ 218 | 12. Has there been a release in the last year? 219 | """ 220 | r = requests.get(pypi_api_url).json().get("releases") 221 | releases = _get_release_date(r) 222 | last_release_date = releases[0].upload_time 223 | version = releases[0].version 224 | last_release_date = datetime.strptime(last_release_date, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) 225 | days_since_last_release = (datetime.now(timezone.utc) - last_release_date).days 226 | if days_since_last_release > 365: 227 | message = f"[red]No. Version {version} was last released {days_since_last_release} days ago" 228 | else: 229 | message = f"[green]Yes. The last release was on {datetime.strftime(last_release_date, '%m-%d-%Y')}" 230 | message += f" which was {days_since_last_release} days ago" 231 | 232 | return message 233 | 234 | 235 | def get_github_api_rate_limits(headers, resource): 236 | url = "https://api.github.com/rate_limit" 237 | response = requests.get(url, headers=headers).json() 238 | core = response.get("resources").get(resource) 239 | limit = core.get("limit") 240 | used = core.get("used") 241 | remaining = core.get("remaining") 242 | reset = strftime("%Y-%m-%d %H:%M:%S", localtime(core.get("reset"))) 243 | message = f"You have used {used} out of {limit} calls.\n\n" 244 | message += f"You have {remaining} calls remaining.\n\n" 245 | message += f"Your limit will reset at {reset}." 246 | return message 247 | 248 | 249 | def get_vulnerabilities(url: str) -> int: 250 | url = url 251 | vulnerabilities = requests.get(url).json().get("vulnerabilities") 252 | vulnerability_count = len(vulnerabilities) 253 | return vulnerability_count 254 | 255 | 256 | def save_auth(auth: str) -> None: # pragma: no cover 257 | # TODO: Write Test 258 | "Save authentication credentials to a JSON file" 259 | console.print("Create a GitHub personal user token and paste it here:") 260 | personal_token = Prompt.ask("Personal token") 261 | if Path(auth).exists(): 262 | auth_data = json.load(open(auth)) 263 | else: 264 | auth_data = {} 265 | auth_data["github_personal_token"] = personal_token 266 | open(auth, "w").write(json.dumps(auth_data, indent=4) + "\n") 267 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/the_well_maintained_test/cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import system 3 | from pathlib import Path 4 | from urllib.parse import urlparse 5 | 6 | import click 7 | import importlib_resources 8 | import requests 9 | import toml 10 | from rich.padding import Padding 11 | 12 | from the_well_maintained_test.helpers import ( 13 | _get_package_github_url, 14 | _get_requirements_txt_file, 15 | ) 16 | 17 | from . import utils 18 | from .console import console 19 | from .helpers import SORRY_MESSAGE 20 | from .styles import ( 21 | answer_link_style, 22 | answer_padding_style, 23 | answer_style, 24 | question_style, 25 | special_answer_padding_style, 26 | warning_style, 27 | ) 28 | from .utils import ( 29 | bug_responding, 30 | change_log_check, 31 | check_tests, 32 | ci_passing, 33 | ci_setup, 34 | commit_in_last_year, 35 | documentation_exists, 36 | framework_check, 37 | get_github_api_rate_limits, 38 | get_vulnerabilities, 39 | language_check, 40 | production_ready_check, 41 | release_in_last_year, 42 | save_auth, 43 | well_used, 44 | ) 45 | 46 | 47 | @click.group() 48 | @click.version_option() 49 | def cli(): # pragma: no cover 50 | """ 51 | Programatically tries to answer the 12 questions from Adam Johnson's 52 | blog post https://adamj.eu/tech/2021/11/04/the-well-maintained-test/ 53 | 54 | package is a package on pypi you'd like to check: 55 | 56 | the-well-maintained-test package the-well-maintained-test 57 | 58 | """ 59 | pass 60 | 61 | 62 | @cli.command() 63 | @click.option( 64 | "-a", 65 | "--auth", 66 | type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), 67 | default="auth.json", 68 | help="Path to save tokens to, defaults to auth.json", 69 | ) 70 | def auth(auth: str) -> None: # pragma: no cover 71 | """Generates a json file with your GitHub Personal Token so that you can have up to 72 | 50,000 API calls instead of 60 for anonymous callers 73 | 74 | Args:\n 75 | auth (str): the name of the file you want to write to for your Personal Token. The default is auth.json 76 | """ 77 | save_auth(auth) 78 | 79 | 80 | @cli.command() 81 | @click.option( 82 | "-n", 83 | "--name", 84 | type=click.STRING, 85 | help="Pass the name of the package to check", 86 | ) 87 | @click.option( 88 | "-q", 89 | "--question", 90 | type=click.Choice(["all", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]), 91 | default="all", 92 | help="List of questions that are tested", 93 | ) 94 | @click.option( 95 | "-s", 96 | "--auth-string", 97 | type=click.STRING, 98 | help="GitHub API Token to pass as a string", 99 | ) 100 | def questions(name: str, question: str, auth_string: str) -> None: # pragma: no cover 101 | "List of questions tested" 102 | questions_file = importlib_resources.files(__name__) / str(Path("data").joinpath("questions.toml")) 103 | with open(Path(questions_file)) as file: 104 | questions = toml.load(file) 105 | 106 | "List of URLs to use" 107 | urls_file = importlib_resources.files(__name__) / str(Path("data").joinpath("urls.toml")) 108 | with open(Path(urls_file)) as file: 109 | urls = toml.load(file) 110 | 111 | try: 112 | with open("auth.json") as f: 113 | data = json.load(f) 114 | headers = { 115 | "Authorization": f"token {data['github_personal_token']}", 116 | } 117 | except FileNotFoundError: 118 | headers = {} 119 | if auth_string: 120 | headers = { 121 | "Authorization": f"token {auth_string}", 122 | } 123 | 124 | if question != "all": 125 | try: 126 | question_url = questions.get("question").get(question).get("question_url") 127 | console.print(questions.get("question").get(question).get("question_text"), style=question_style) 128 | console.print( 129 | Padding( 130 | questions.get("question").get(question).get("question_description"), answer_padding_style, style=answer_style 131 | ) 132 | ) 133 | question_link_verbiage = ( 134 | f"See {questions.get('question').get(question).get('question_link')} for the original source." 135 | ) 136 | console.print(question_link_verbiage, style=answer_link_style) 137 | if name: 138 | question_function = ( 139 | f"[bold green]function_name[/bold green]: {questions.get('question').get(question).get('question_function')}" 140 | ) 141 | console.print(Padding(question_function, answer_padding_style, style=question_style + " italic")) 142 | url = urls.get("url").get(question_url).replace("{name}", name) 143 | github_url = _get_package_github_url(name)[1] 144 | parse_object = urlparse(github_url) 145 | author = parse_object.path.split("/")[-2] 146 | if "{author}" in url: 147 | url = url.replace("{author}", author) 148 | if "{default_branch}" in url: 149 | api_url = f"https://api.github.com/repos/{author}/{name}" 150 | default_branch = requests.get(api_url).json().get("default_branch") 151 | url = url.replace("{default_branch}", default_branch) 152 | 153 | if questions.get("question").get(question).get("headers_needed") == "N": 154 | console.print(getattr(utils, questions.get("question").get(question).get("question_function"))(url)) 155 | else: 156 | console.print(getattr(utils, questions.get("question").get(question).get("question_function"))(url, headers)) 157 | except (AttributeError, TypeError): 158 | console.print(SORRY_MESSAGE) 159 | else: 160 | for _, v in questions.get("question").items(): 161 | console.print(v.get("question_text"), style=question_style) 162 | 163 | 164 | @cli.command() 165 | @click.option( 166 | "-r", 167 | "--requirements-file", 168 | type=click.Path(exists=True), 169 | help="List of questions that are tested", 170 | ) 171 | @click.option( 172 | "-o", 173 | "--output", 174 | type=click.Choice(["html", "txt"]), 175 | help="Show progress on test check", 176 | ) 177 | @click.option( 178 | "-a", 179 | "--auth", 180 | type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), 181 | default="auth.json", 182 | help="Path to auth tokens, defaults to auth.json", 183 | ) 184 | def requirements(requirements_file, output, auth): # pragma: no cover 185 | "Loop over a requirements.txt file" 186 | packages = _get_requirements_txt_file(requirements_file) 187 | for package in packages: 188 | console.rule(f"[bold blue] {package[0]}") 189 | cmd = f"the-well-maintained-test package '{package[0]}' --auth {auth}" 190 | system(cmd) 191 | if output == "html": 192 | console.save_html( 193 | f"output_{package[0].lower()}.html", 194 | ) 195 | 196 | if output == "txt": 197 | console.save_text(f"output_{package[0].lower()}.txt") 198 | 199 | 200 | @cli.command() 201 | @click.option( 202 | "-r", 203 | "--resource", 204 | type=click.Choice(["code_scanning_upload", "core", "graphql", "integration_manifest", "search"]), 205 | default="core", 206 | show_default=True, 207 | help="Show progress on test check", 208 | ) 209 | @click.option( 210 | "-a", 211 | "--auth", 212 | type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), 213 | default="auth.json", 214 | help="Path to auth tokens, defaults to auth.json", 215 | ) 216 | @click.option( 217 | "-s", 218 | "--auth-string", 219 | type=click.STRING, 220 | help="GitHub API Token to pass as a string", 221 | ) 222 | def check(resource: str, auth, auth_string): # pragma: no cover 223 | """Check your GitHub API Usage Stats 224 | 225 | Args:\n 226 | resource (str): Which GitHub resource to check. See Options below. 227 | """ 228 | try: 229 | with open(auth) as f: 230 | data = json.load(f) 231 | headers = { 232 | "Authorization": f"token {data['github_personal_token']}", 233 | } 234 | except FileNotFoundError: 235 | headers = {} 236 | if auth_string: 237 | headers = { 238 | "Authorization": f"token {auth_string}", 239 | } 240 | try: 241 | message = get_github_api_rate_limits(headers, resource) 242 | except AttributeError: 243 | message = f"There is an issue with the Token '{auth_string}'" 244 | 245 | console.print(Padding(message, answer_padding_style, style=answer_style)) 246 | 247 | 248 | @cli.command() 249 | @click.argument("package", type=click.STRING, required=True) 250 | @click.option( 251 | "-b", 252 | "--branch", 253 | type=click.STRING, 254 | help="The branch to check", 255 | ) 256 | @click.option( 257 | "-p", 258 | "--progress", 259 | type=click.BOOL, 260 | default=True, 261 | help="Show or hide the progress on Test Checking Question", 262 | ) 263 | @click.option( 264 | "-o", 265 | "--output", 266 | type=click.Choice(["html", "txt"]), 267 | help="Save the output as HTML or TXT", 268 | ) 269 | @click.option( 270 | "-a", 271 | "--auth", 272 | type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), 273 | default="auth.json", 274 | help="Path to auth tokens, defaults to auth.json", 275 | ) 276 | @click.option( 277 | "-s", 278 | "--auth-string", 279 | type=click.STRING, 280 | help="GitHub API Token to pass as a string", 281 | ) 282 | def package(package: str, branch: str, progress: bool, output: str, auth, auth_string) -> None: # pragma: no cover 283 | """Name of a package on PyPi you'd like to check 284 | 285 | Args:\n 286 | name (str): The name of the Package from PyPi 287 | """ 288 | try: 289 | with open(auth) as f: 290 | data = json.load(f) 291 | headers = { 292 | "Authorization": f"token {data['github_personal_token']}", 293 | } 294 | except FileNotFoundError: 295 | headers = {} 296 | if auth_string: 297 | headers = { 298 | "Authorization": f"token {auth_string}", 299 | } 300 | try: 301 | pypi_url = f"https://pypi.org/pypi/{package}/json" 302 | url = _get_package_github_url(package)[1] 303 | 304 | "url to a github repository you'd like to check" 305 | if url[-1] == "/": 306 | url = url.strip("/") 307 | questions_file = importlib_resources.files(__name__) / str(Path("data").joinpath("questions.toml")) 308 | with open(Path(questions_file)) as file: 309 | questions = toml.load(file) 310 | 311 | parse_object = urlparse(url) 312 | author = parse_object.path.split("/")[-2] 313 | package = parse_object.path.split("/")[-1] 314 | api_url = f"https://api.github.com/repos/{author}/{package}" 315 | if not branch: 316 | default_branch = requests.get(api_url).json().get("default_branch") 317 | else: 318 | default_branch = branch 319 | commits_url = f"https://api.github.com/repos/{author}/{package}/commits/{default_branch}" 320 | workflows_url = f"https://api.github.com/repos/{author}/{package}/actions/workflows" 321 | ci_status_url = f"https://api.github.com/repos/{author}/{package}/actions/runs" 322 | bugs_url = f"https://api.github.com/repos/{author}/{package}/issues?labels=bug" 323 | tree_url = f"https://api.github.com/repos/{author}/{package}/git/trees/{default_branch}?recursive=1" 324 | 325 | vulnerabilities = get_vulnerabilities(pypi_url) 326 | if vulnerabilities > 0: 327 | console.rule("[bold red]Vulnerabilities detected!!!") 328 | console.print( 329 | Padding(f"There are {vulnerabilities} vulnerabilities in this package", answer_padding_style, style=warning_style) 330 | ) 331 | console.rule() 332 | 333 | console.print(questions.get("question").get("1").get("question_text"), style=question_style) 334 | console.print(Padding(production_ready_check(pypi_url), answer_padding_style, style=answer_style)) 335 | 336 | console.print(questions.get("question").get("2").get("question_text"), style=question_style) 337 | console.print(Padding(documentation_exists(pypi_url), answer_padding_style, style=answer_style)) 338 | 339 | console.print(questions.get("question").get("3").get("question_text"), style=question_style) 340 | console.print(Padding(change_log_check(pypi_url), answer_padding_style, style=answer_style)) 341 | 342 | console.print(questions.get("question").get("4").get("question_text"), style=question_style) 343 | console.print(Padding(bug_responding(bugs_url, headers), answer_padding_style, style=answer_style)) 344 | 345 | console.print(questions.get("question").get("5").get("question_text"), style=question_style) 346 | console.print(Padding(check_tests(tree_url, headers, progress), special_answer_padding_style, style=answer_style)) 347 | 348 | console.print(questions.get("question").get("6").get("question_text"), style=question_style) 349 | console.print(Padding(language_check(pypi_url), answer_padding_style, style=answer_style)) 350 | 351 | console.print(questions.get("question").get("7").get("question_text"), style=question_style) 352 | console.print(Padding(framework_check(pypi_url), answer_padding_style, style=answer_style)) 353 | 354 | console.print(questions.get("question").get("8").get("question_text"), style=question_style) 355 | console.print(Padding(ci_setup(workflows_url, headers), answer_padding_style, style=answer_style)) 356 | 357 | console.print(questions.get("question").get("9").get("question_text"), style=question_style) 358 | console.print(Padding(ci_passing(ci_status_url, headers), answer_padding_style, style=answer_style)) 359 | 360 | console.print(questions.get("question").get("10").get("question_text"), style=question_style) 361 | console.print(Padding(well_used(api_url, headers), answer_padding_style, style=answer_style)) 362 | 363 | console.print(questions.get("question").get("11").get("question_text"), style=question_style) 364 | console.print(Padding(commit_in_last_year(commits_url, headers), answer_padding_style, style=answer_style)) 365 | 366 | console.print(questions.get("question").get("12").get("question_text"), style=question_style) 367 | console.print(Padding(release_in_last_year(pypi_url), answer_padding_style, style=answer_style)) 368 | 369 | if output == "html": 370 | console.save_html("output.html") 371 | 372 | if output == "txt": 373 | console.save_text("output.txt") 374 | 375 | except (AttributeError, TypeError): 376 | console.print(SORRY_MESSAGE) 377 | -------------------------------------------------------------------------------- /tests/test_classes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from random import randrange 3 | 4 | today = datetime.now() 5 | random_days_good = -randrange(25, 250) 6 | random_days_bad = -randrange(400, 600) 7 | 8 | 9 | GOOD_DATE = datetime.strftime(today + timedelta(days=random_days_good), "%Y-%m-%dT%H:%M:%S") 10 | GOOD_DATE_Z = datetime.strftime(today + timedelta(days=random_days_good), "%Y-%m-%dT%H:%M:%SZ") 11 | BAD_DATE = datetime.strftime(today + timedelta(days=random_days_bad), "%Y-%m-%dT%H:%M:%S") 12 | BAD_DATE_Z = datetime.strftime(today + timedelta(days=random_days_bad), "%Y-%m-%dT%H:%M:%SZ") 13 | 14 | 15 | class MockResponseCIPassing: 16 | # mock json() method always returns a specific testing dictionary 17 | @staticmethod 18 | def json(): 19 | return {"workflow_runs": [{"conclusion": "success"}]} 20 | 21 | 22 | class MockResponseCINoConclusion: 23 | # mock json() method always returns a specific testing dictionary 24 | @staticmethod 25 | def json(): 26 | return {"workflow_runs": []} 27 | 28 | 29 | class MockResponseCIFailing: 30 | # mock json() method always returns a specific testing dictionary 31 | @staticmethod 32 | def json(): 33 | return {"workflow_runs": [{"conclusion": "fail"}]} 34 | 35 | 36 | class MockResponseWellUsed: 37 | @staticmethod 38 | def json(): 39 | return { 40 | "watchers": 5, 41 | "network_count": 6, 42 | "open_issues": 6, 43 | "subscribers_count": 10, 44 | } 45 | 46 | 47 | class MockResponseCommitsYes: 48 | @staticmethod 49 | def json(): 50 | return {"commit": {"author": {"date": GOOD_DATE_Z}}} 51 | 52 | 53 | class MockResponseCommitsNo: 54 | @staticmethod 55 | def json(): 56 | return {"commit": {"author": {"date": BAD_DATE_Z}}} 57 | 58 | 59 | class MockResponseReleasesYes: 60 | @staticmethod 61 | def json(): 62 | return { 63 | "releases": { 64 | "1.1.1": [{"upload_time": GOOD_DATE}], 65 | "1.1.1a": [{"upload_time": GOOD_DATE}], 66 | } 67 | } 68 | 69 | 70 | class MockResponseReleasesNo: 71 | @staticmethod 72 | def json(): 73 | return {"releases": {"1.1.1": [{"upload_time": BAD_DATE}]}} 74 | 75 | 76 | class MockResponseCISetUpYes: 77 | @staticmethod 78 | def json(): 79 | return {"total_count": 1, "workflows": [{"name": "Test"}]} 80 | 81 | 82 | class MockResponseCISetUpNo: 83 | @staticmethod 84 | def json(): 85 | return {"total_count": 0} 86 | 87 | 88 | class MockResponseBugsYes: 89 | @staticmethod 90 | def json(): 91 | return [ 92 | { 93 | "id": 1, 94 | "node_id": ";lkjsdf", 95 | "url": "https://fakeurl", 96 | "event": "labeled", 97 | "commit_id": "null", 98 | "commit_url": "null", 99 | "created_at": "2019-07-14T00:00:00Z", 100 | "label": {"name": "bug", "color": "d73a4a"}, 101 | "performed_via_github_app": "null", 102 | }, 103 | { 104 | "id": 2, 105 | "node_id": "asdfsadf=", 106 | "url": "https://fakeurl", 107 | "event": "labeled", 108 | "commit_id": "null", 109 | "commit_url": "null", 110 | "created_at": "2019-07-14T00:00:00Z", 111 | "label": {"name": "help wanted", "color": "008672"}, 112 | "performed_via_github_app": "null", 113 | }, 114 | { 115 | "url": "https://fakeurl", 116 | "html_url": "https://fakeurl", 117 | "issue_url": "https://fakeurl", 118 | "id": 3, 119 | "node_id": "asdfs", 120 | "created_at": "2019-07-14T00:00:00Z", 121 | "updated_at": "2021-06-12T00:00:00Z", 122 | "author_association": "OWNER", 123 | "body": "This is the body.", 124 | "event": "commented", 125 | }, 126 | ] 127 | 128 | 129 | class MockResponseBugsNo: 130 | @staticmethod 131 | def json(): 132 | return [] 133 | 134 | 135 | class MockResponseBugsWithNoResponse: 136 | @staticmethod 137 | def json(): 138 | return [ 139 | { 140 | "id": 1, 141 | "node_id": ";lkjsdf", 142 | "url": "https://fakeurl", 143 | "event": "labeled", 144 | "commit_id": "null", 145 | "commit_url": "null", 146 | "created_at": "2019-07-14T00:00:00Z", 147 | "label": {"name": "bug", "color": "d73a4a"}, 148 | "performed_via_github_app": "null", 149 | }, 150 | ] 151 | 152 | 153 | class MockResponseProductionReadyYes: 154 | @staticmethod 155 | def json(): 156 | return {"info": {"classifiers": ["Development Status :: 3 - Alpha"]}} 157 | 158 | 159 | class MockResponseProductionReadyNo: 160 | @staticmethod 161 | def json(): 162 | return {"info": {"classifiers": [], "version": "0.5"}} 163 | 164 | 165 | class MockResponseDocumentationYes: 166 | @staticmethod 167 | def json(): 168 | return {"info": {"project_urls": {"Documentation": "https://fakeurl/blob/main/README.md"}}} 169 | 170 | 171 | class MockResponseDocumentationNo: 172 | @staticmethod 173 | def json(): 174 | return {"info": {"project_urls": {}}} 175 | 176 | 177 | class MockResponseLanguageCheck: 178 | @staticmethod 179 | def json(): 180 | return { 181 | "info": { 182 | "classifiers": [ 183 | "Development Status :: 3 - Alpha", 184 | "Intended Audience :: Developers", 185 | "Intended Audience :: End Users/Desktop", 186 | "Intended Audience :: Science/Research", 187 | "License :: OSI Approved :: Apache Software License", 188 | "Programming Language :: Python :: 3.6", 189 | "Programming Language :: Python :: 3.7", 190 | "Topic :: Database", 191 | ], 192 | } 193 | } 194 | 195 | 196 | class MockResponseFrameworkCheck: 197 | @staticmethod 198 | def json(): 199 | return { 200 | "info": { 201 | "classifiers": [ 202 | "Development Status :: 4 - Beta", 203 | "Environment :: Web Environment", 204 | "Framework :: Django", 205 | "Framework :: Django :: 2.0", 206 | "Framework :: Django :: 2.1", 207 | "Framework :: Django :: 2.2", 208 | "Framework :: Django :: 3.0", 209 | "Framework :: Django :: 3.1", 210 | "Framework :: Django :: 3.2", 211 | "Intended Audience :: Developers", 212 | "License :: OSI Approved :: MIT License", 213 | "Operating System :: OS Independent", 214 | "Programming Language :: Python", 215 | "Programming Language :: Python :: 3", 216 | "Programming Language :: Python :: 3.5", 217 | "Programming Language :: Python :: 3.6", 218 | "Programming Language :: Python :: 3.7", 219 | "Programming Language :: Python :: 3.8", 220 | "Programming Language :: Python :: 3.9", 221 | "Topic :: Internet", 222 | "Topic :: Software Development :: Libraries :: Python Modules", 223 | ], 224 | "version": "0.5", 225 | } 226 | } 227 | 228 | 229 | class MockResponseCommentList: 230 | @staticmethod 231 | def json(): 232 | return [ 233 | { 234 | "created_at": "2019-07-14T00:00:00Z", 235 | "body": "This is the body.", 236 | "event": "commented", 237 | } 238 | ] 239 | 240 | 241 | class MockGitHubFileCheckAPIWithTestFiles: 242 | @staticmethod 243 | def json(): 244 | return { 245 | "sha": "98352ddf3a1ccffe8d38ecb34e1a51ed58d29cf3", 246 | "url": "https://api.github.com/repos/django/django/git/trees/98352ddf3a1ccffe8d38ecb34e1a51ed58d29cf3", 247 | "tree": [ 248 | { 249 | "path": ".editorconfig", 250 | "mode": "100644", 251 | "type": "blob", 252 | "sha": "fa6c23c1fc276940d65520daedccc03d59b4b79c", 253 | "size": 807, 254 | "url": "https://api.github.com/repos/django/django/git/blobs/fa6c23c1fc276940d65520daedccc03d59b4b79c", 255 | }, 256 | { 257 | "path": ".eslintignore", 258 | "mode": "100644", 259 | "type": "blob", 260 | "sha": "9c273ed532043fae2166ed38c281c510a841dccc", 261 | "size": 120, 262 | "url": "https://api.github.com/repos/django/django/git/blobs/9c273ed532043fae2166ed38c281c510a841dccc", 263 | }, 264 | { 265 | "path": "tests/admin_changelist/test_date_hierarchy.py", 266 | "mode": "100644", 267 | "type": "blob", 268 | "sha": "a321650b32b0f3666d8470e9a69711756907f6ba", 269 | "size": 3483, 270 | "url": "https://api.github.com/repos/django/django/git/blobs/a321650b32b0f3666d8470e9a69711756907f6ba", 271 | }, 272 | ], 273 | } 274 | 275 | 276 | class MockGitHubFileCheckAPIWithOutTestFiles: 277 | @staticmethod 278 | def json(): 279 | return { 280 | "sha": "98352ddf3a1ccffe8d38ecb34e1a51ed58d29cf3", 281 | "url": "https://api.github.com/repos/django/django/git/trees/98352ddf3a1ccffe8d38ecb34e1a51ed58d29cf3", 282 | "tree": [ 283 | { 284 | "path": ".editorconfig", 285 | "mode": "100644", 286 | "type": "blob", 287 | "sha": "fa6c23c1fc276940d65520daedccc03d59b4b79c", 288 | "size": 807, 289 | "url": "https://api.github.com/repos/django/django/git/blobs/fa6c23c1fc276940d65520daedccc03d59b4b79c", 290 | }, 291 | { 292 | "path": ".eslintignore", 293 | "mode": "100644", 294 | "type": "blob", 295 | "sha": "9c273ed532043fae2166ed38c281c510a841dccc", 296 | "size": 120, 297 | "url": "https://api.github.com/repos/django/django/git/blobs/9c273ed532043fae2166ed38c281c510a841dccc", 298 | }, 299 | ], 300 | } 301 | 302 | 303 | class MockResponseContentBase64: 304 | @staticmethod 305 | def json(): 306 | return {"encoding": "base64", "content": "test"} 307 | 308 | 309 | class MockResponseContentNotBase64: 310 | @staticmethod 311 | def json(): 312 | return {"encoding": "notbase64"} 313 | 314 | 315 | class MockResponseTestFilesExist: 316 | @staticmethod 317 | def json(): 318 | return { 319 | "tree": [ 320 | { 321 | "type": "blob", 322 | "path": "tests/test_management.py", 323 | }, 324 | { 325 | "path": "friendship/tests/tests.py", 326 | "type": "blob", 327 | }, 328 | ] 329 | } 330 | 331 | 332 | class MockResponseTestFilesDoNotExist: 333 | @staticmethod 334 | def json(): 335 | return { 336 | "tree": [ 337 | { 338 | "type": "blob", 339 | "path": "requirements/py38-django40.txt", 340 | } 341 | ] 342 | } 343 | 344 | 345 | class MockResponseTestFilesNoBlobs: 346 | @staticmethod 347 | def json(): 348 | return {"tree": [{"type": "tree"}]} 349 | 350 | 351 | class MockResponseProjectURLs: 352 | # mock json() method always returns a specific testing dictionary 353 | @staticmethod 354 | def json(): 355 | return { 356 | "info": { 357 | "project_urls": { 358 | "Documentation": "https://docs.djangoproject.com/", 359 | "Funding": "https://www.djangoproject.com/fundraising/", 360 | "Homepage": "https://www.djangoproject.com/", 361 | "Release notes": "https://docs.djangoproject.com/en/stable/releases/", 362 | "Source": "https://github.com/django/django", 363 | "Tracker": "https://code.djangoproject.com/", 364 | } 365 | } 366 | } 367 | 368 | 369 | class MockResponseGitHubRateLimit: 370 | # mock json() method always returns a specific testing dictionary 371 | @staticmethod 372 | def json(): 373 | return { 374 | "resources": { 375 | "core": {"limit": 5000, "remaining": 4999, "reset": 1372700873, "used": 1}, 376 | "search": {"limit": 30, "remaining": 18, "reset": 1372697452, "used": 12}, 377 | "graphql": {"limit": 5000, "remaining": 4993, "reset": 1372700389, "used": 7}, 378 | "integration_manifest": {"limit": 5000, "remaining": 4999, "reset": 1551806725, "used": 1}, 379 | "code_scanning_upload": {"limit": 500, "remaining": 499, "reset": 1551806725, "used": 1}, 380 | }, 381 | "rate": {"limit": 5000, "remaining": 4999, "reset": 1372700873, "used": 1}, 382 | } 383 | 384 | 385 | class MockResponseWithVulnerabilities: 386 | # mock json() method always returns a specific testing dictionary 387 | @staticmethod 388 | def json(): 389 | return { 390 | "vulnerabilities": [ 391 | { 392 | "aliases": ["CVE-2014-0472"], 393 | "details": "details", 394 | "fixed_in": ["1.4.11", "1.5.6", "1.6.3"], 395 | "id": "PYSEC-2014-1", 396 | "link": "https://osv.dev/vulnerability/PYSEC-2014-1", 397 | "source": "osv", 398 | }, 399 | { 400 | "aliases": ["CVE-2011-4136"], 401 | "details": "details", 402 | "fixed_in": ["1.2.7", "1.3.1"], 403 | "id": "PYSEC-2011-1", 404 | "link": "https://osv.dev/vulnerability/PYSEC-2011-1", 405 | "source": "osv", 406 | }, 407 | { 408 | "aliases": ["CVE-2011-4140"], 409 | "details": "details", 410 | "fixed_in": ["1.2.7", "1.3.1"], 411 | "id": "PYSEC-2011-5", 412 | "link": "https://osv.dev/vulnerability/PYSEC-2011-5", 413 | "source": "osv", 414 | }, 415 | ] 416 | } 417 | 418 | 419 | class MockResponseWithoutVulnerabilities: 420 | # mock json() method always returns a specific testing dictionary 421 | @staticmethod 422 | def json(): 423 | return {"vulnerabilities": []} 424 | 425 | 426 | class MockResponseChangelogYes: 427 | # mock json() method always returns a specific testing dictionary 428 | @staticmethod 429 | def json(): 430 | return { 431 | "info": { 432 | "project_urls": { 433 | "Changelog": "https://github.com/ryancheley/the-well-maintained-test/releases", 434 | } 435 | } 436 | } 437 | 438 | 439 | class MockResponseChangelogNo: 440 | # mock json() method always returns a specific testing dictionary 441 | @staticmethod 442 | def json(): 443 | return { 444 | "info": { 445 | "project_urls": { 446 | "Home Page": "https://github.com/ryancheley/the-well-maintained-test/releases", 447 | } 448 | } 449 | } 450 | 451 | 452 | class MockResponseNonGitHubHomePage: 453 | @staticmethod 454 | def json(): 455 | return { 456 | "info": { 457 | "project_urls": { 458 | "CI": "https://github.com/author/package/actions", 459 | "Changelog": "https://github.com/author/package/releases", 460 | "Homepage": "https://www.package.com", 461 | "Issues": "https://github.com/author/package/issues", 462 | } 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /tests/test_the_well_maintained_test.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from datetime import date, datetime, timezone 3 | from time import localtime, strftime 4 | 5 | import pytest 6 | import requests 7 | from click.testing import CliRunner 8 | 9 | from tests.test_classes import ( 10 | BAD_DATE, 11 | BAD_DATE_Z, 12 | GOOD_DATE, 13 | GOOD_DATE_Z, 14 | MockGitHubFileCheckAPIWithOutTestFiles, 15 | MockGitHubFileCheckAPIWithTestFiles, 16 | MockResponseBugsNo, 17 | MockResponseBugsWithNoResponse, 18 | MockResponseBugsYes, 19 | MockResponseChangelogNo, 20 | MockResponseChangelogYes, 21 | MockResponseCIFailing, 22 | MockResponseCINoConclusion, 23 | MockResponseCIPassing, 24 | MockResponseCISetUpNo, 25 | MockResponseCISetUpYes, 26 | MockResponseCommentList, 27 | MockResponseCommitsNo, 28 | MockResponseCommitsYes, 29 | MockResponseContentBase64, 30 | MockResponseContentNotBase64, 31 | MockResponseDocumentationNo, 32 | MockResponseDocumentationYes, 33 | MockResponseFrameworkCheck, 34 | MockResponseGitHubRateLimit, 35 | MockResponseLanguageCheck, 36 | MockResponseNonGitHubHomePage, 37 | MockResponseProductionReadyNo, 38 | MockResponseProductionReadyYes, 39 | MockResponseProjectURLs, 40 | MockResponseReleasesNo, 41 | MockResponseReleasesYes, 42 | MockResponseTestFilesDoNotExist, 43 | MockResponseTestFilesExist, 44 | MockResponseTestFilesNoBlobs, 45 | MockResponseWellUsed, 46 | MockResponseWithoutVulnerabilities, 47 | MockResponseWithVulnerabilities, 48 | ) 49 | from the_well_maintained_test.cli import cli 50 | from the_well_maintained_test.helpers import ( 51 | _get_package_github_url, 52 | _get_requirements_txt_file, 53 | ) 54 | from the_well_maintained_test.utils import ( 55 | _get_bug_comment_list, 56 | _get_content, 57 | _get_release_date, 58 | _get_test_files, 59 | _test_method_count, 60 | bug_responding, 61 | change_log_check, 62 | check_tests, 63 | ci_passing, 64 | ci_setup, 65 | commit_in_last_year, 66 | documentation_exists, 67 | framework_check, 68 | get_github_api_rate_limits, 69 | get_vulnerabilities, 70 | language_check, 71 | production_ready_check, 72 | release_in_last_year, 73 | well_used, 74 | ) 75 | 76 | 77 | def test_version(): 78 | runner = CliRunner() 79 | with runner.isolated_filesystem(): 80 | result = runner.invoke(cli, ["--version"]) 81 | assert result.exit_code == 0 82 | assert result.output.startswith("cli, version ") 83 | 84 | 85 | def test_changelog_exists(monkeypatch): 86 | """ 87 | 3. Is there a changelog? 88 | """ 89 | 90 | def mock_get(*args, **kwargs): 91 | return MockResponseChangelogYes() 92 | 93 | # apply the monkeypatch for requests.get to mock_get 94 | monkeypatch.setattr(requests, "get", mock_get) 95 | url = "https://fakeurl" 96 | expected = "[green]Yes" 97 | actual = change_log_check(url) 98 | assert actual == expected 99 | 100 | 101 | def test_changelog_does_not_exist(monkeypatch): 102 | """ 103 | 3. Is there a changelog? 104 | """ 105 | 106 | def mock_get(*args, **kwargs): 107 | return MockResponseChangelogNo() 108 | 109 | # apply the monkeypatch for requests.get to mock_get 110 | monkeypatch.setattr(requests, "get", mock_get) 111 | url = "https://fakeurl" 112 | expected = "[red]No" 113 | actual = change_log_check(url) 114 | assert actual == expected 115 | 116 | 117 | def test_bug_response_yes(monkeypatch): 118 | """ 119 | 4. Is someone responding to bug reports? 120 | """ 121 | headers = {} 122 | 123 | def mock_get_bug_comment(url, headers=headers): 124 | BugComments = namedtuple("BugComments", ["text", "create_date"]) 125 | return [BugComments(text="Test", create_date=datetime.today())] 126 | 127 | def mock_get(*args, **kwargs): 128 | return MockResponseBugsYes() 129 | 130 | monkeypatch.setattr(requests, "get", mock_get) 131 | url = "https://fakeurl/17/timeline" 132 | 133 | headers = {} 134 | monkeypatch.setattr("the_well_maintained_test.utils._get_bug_comment_list", mock_get_bug_comment) 135 | 136 | today = date.today() 137 | 138 | bug_comment_list = _get_bug_comment_list(url, headers=headers) 139 | 140 | bug_turn_around_time_reply_days = (today - bug_comment_list[0].create_date.date()).days 141 | 142 | days_since_last_bug_comment = 0 143 | expected = bug_responding(url, headers=headers) 144 | message1 = f"The maintainer took {bug_turn_around_time_reply_days} days to respond to the bug report" 145 | message2 = f"It has been {days_since_last_bug_comment} days since a comment was made on the bug." 146 | actual = f"[green]{message1}\n{message2}" 147 | assert expected == actual 148 | 149 | 150 | def test_bug_response_timezone_aware_date(monkeypatch): 151 | """ 152 | Test the timezone-aware branch (line 81) in bug_responding function. 153 | """ 154 | headers = {} 155 | 156 | def mock_get_bug_comment(url, headers=headers): 157 | BugComments = namedtuple("BugComments", ["text", "create_date"]) 158 | # Return timezone-aware datetime to trigger else branch at line 81 159 | return [BugComments(text="Test", create_date=datetime(2019, 7, 15, 12, 0, 0, tzinfo=timezone.utc))] 160 | 161 | # Custom mock that returns timezone-aware bug_create_date 162 | class MockResponseBugsTimezoneAware: 163 | @staticmethod 164 | def json(): 165 | return [ 166 | { 167 | "id": 1, 168 | "created_at": "2019-07-14T00:00:00Z", 169 | "timeline_url": "https://fakeurl/17/timeline", 170 | } 171 | ] 172 | 173 | def mock_get(*args, **kwargs): 174 | return MockResponseBugsTimezoneAware() 175 | 176 | # Mock the datetime strptime to return timezone-aware datetime 177 | original_strptime = datetime.strptime 178 | 179 | def mock_strptime(date_string, format_string): 180 | dt = original_strptime(date_string, format_string) 181 | if format_string == "%Y-%m-%dT%H:%M:%SZ": 182 | return dt.replace(tzinfo=timezone.utc) 183 | return dt 184 | 185 | # Monkey patch using monkeypatch (even though it's tricky with datetime) 186 | import the_well_maintained_test.utils 187 | 188 | # Store original 189 | original_datetime_class = the_well_maintained_test.utils.datetime 190 | 191 | # Create a mock datetime class 192 | class MockDatetime: 193 | @staticmethod 194 | def strptime(date_string, format_string): 195 | dt = original_strptime(date_string, format_string) 196 | if format_string == "%Y-%m-%dT%H:%M:%SZ": 197 | return dt.replace(tzinfo=timezone.utc) 198 | return dt 199 | 200 | @staticmethod 201 | def now(tz=None): 202 | return original_datetime_class.now(tz) 203 | 204 | # Replace in module 205 | the_well_maintained_test.utils.datetime = MockDatetime 206 | 207 | try: 208 | monkeypatch.setattr(requests, "get", mock_get) 209 | monkeypatch.setattr("the_well_maintained_test.utils._get_bug_comment_list", mock_get_bug_comment) 210 | 211 | url = "https://fakeurl/17/timeline" 212 | expected = bug_responding(url, headers=headers) 213 | 214 | assert expected.startswith("[green]") 215 | assert "days to respond to the bug report" in expected 216 | 217 | finally: 218 | the_well_maintained_test.utils.datetime = original_datetime_class 219 | 220 | 221 | def test__get_bug_comment_list(monkeypatch): 222 | """A helper function to get the details of the comments for bugs 223 | 224 | Args: 225 | monkeypatch ([type]): [description] 226 | """ 227 | BugComments = namedtuple("BugComments", ["text", "create_date"]) 228 | headers = {} 229 | 230 | def mock_get(*args, **kwargs): 231 | return MockResponseCommentList() 232 | 233 | monkeypatch.setattr(requests, "get", mock_get) 234 | url = "https://fakeurl/17/timeline" 235 | actual = _get_bug_comment_list(url, headers=headers) 236 | expected = [ 237 | BugComments( 238 | text="This is the body.", 239 | create_date=datetime(2019, 7, 14, 0, 0, 0), 240 | ) 241 | ] 242 | assert actual == expected 243 | 244 | 245 | def test_bug_response_no(monkeypatch): 246 | """ 247 | 4. Is someone responding to bug reports? 248 | """ 249 | headers = {} 250 | 251 | def mock_get(*args, **kwargs): 252 | return MockResponseBugsNo() 253 | 254 | # apply the monkeypatch for requests.get to mock_get 255 | monkeypatch.setattr(requests, "get", mock_get) 256 | url = "https://fakeurl" 257 | actual = bug_responding(url, headers=headers) 258 | expected = "[green]There have been no bugs reported that are still open." 259 | assert actual == expected 260 | 261 | 262 | def test_bug_response_yes_no_response(monkeypatch): 263 | """ 264 | 4. Is someone responding to bug reports? 265 | """ 266 | headers = {} 267 | 268 | def mock_get(*args, **kwargs): 269 | return MockResponseBugsWithNoResponse() 270 | 271 | # apply the monkeypatch for requests.get to mock_get 272 | monkeypatch.setattr(requests, "get", mock_get) 273 | url = "https://fakeurl" 274 | actual = bug_responding(url, headers=headers) 275 | expected = "[red]There is 1 bugs with no comments" 276 | assert actual == expected 277 | 278 | 279 | def test_ci_setup_yes(monkeypatch): 280 | """ 281 | 8. Is there a Continuous Integration (CI) configuration? 282 | """ 283 | headers = {} 284 | 285 | def mock_get(*args, **kwargs): 286 | return MockResponseCISetUpYes() 287 | 288 | # apply the monkeypatch for requests.get to mock_get 289 | monkeypatch.setattr(requests, "get", mock_get) 290 | url = "https://fakeurl" 291 | actual = ci_setup(url, headers=headers) 292 | expected = "[green]There is 1 workflows\n[green]- Test\n" 293 | assert actual == expected 294 | 295 | 296 | def test_ci_setup_no(monkeypatch): 297 | """ 298 | 8. Is there a Continuous Integration (CI) configuration? 299 | """ 300 | headers = {} 301 | 302 | def mock_get(*args, **kwargs): 303 | return MockResponseCISetUpNo() 304 | 305 | # apply the monkeypatch for requests.get to mock_get 306 | monkeypatch.setattr(requests, "get", mock_get) 307 | url = "https://fakeurl" 308 | actual = ci_setup(url, headers=headers) 309 | expected = "[red]There is no CI set up!" 310 | assert actual == expected 311 | 312 | 313 | def test_ci_passing_yes(monkeypatch): 314 | """ 315 | 9. Is the CI passing? 316 | """ 317 | headers = {} 318 | 319 | def mock_get(*args, **kwargs): 320 | return MockResponseCIPassing() 321 | 322 | # apply the monkeypatch for requests.get to mock_get 323 | monkeypatch.setattr(requests, "get", mock_get) 324 | url = "https://fakeurl" 325 | actual = ci_passing(url, headers=headers) 326 | expected = "[green]Yes" 327 | assert actual == expected 328 | 329 | 330 | def test_ci_passing_no_conclusion(monkeypatch): 331 | """ 332 | 9. Is the CI passing? 333 | """ 334 | headers = {} 335 | 336 | def mock_get(*args, **kwargs): 337 | return MockResponseCINoConclusion() 338 | 339 | # apply the monkeypatch for requests.get to mock_get 340 | monkeypatch.setattr(requests, "get", mock_get) 341 | url = "https://fakeurl" 342 | actual = ci_passing(url, headers=headers) 343 | expected = "[red]No" 344 | assert actual == expected 345 | 346 | 347 | def test_ci_passing_no(monkeypatch): 348 | """ 349 | 9. Is the CI passing? 350 | """ 351 | headers = {} 352 | 353 | def mock_get(*args, **kwargs): 354 | return MockResponseCIFailing() 355 | 356 | # apply the monkeypatch for requests.get to mock_get 357 | monkeypatch.setattr(requests, "get", mock_get) 358 | url = "https://fakeurl" 359 | actual = ci_passing(url, headers=headers) 360 | expected = "[red]No" 361 | assert actual == expected 362 | 363 | 364 | def test_well_used(monkeypatch): 365 | """ 366 | 10. Does it seem relatively well used? 367 | """ 368 | headers = {} 369 | 370 | def mock_get(*args, **kwargs): 371 | return MockResponseWellUsed() 372 | 373 | # apply the monkeypatch for requests.get to mock_get 374 | monkeypatch.setattr(requests, "get", mock_get) 375 | url = "https://fakeurl" 376 | actual = well_used(url, headers=headers) 377 | message = "The project has the following statistics:\n" 378 | message += "- Watchers: 5\n" 379 | message += "- Forks: 6\n" 380 | message += "- Open Issues: 6\n" 381 | message += "- Subscribers: 10" 382 | message = f"[green]{message}" 383 | 384 | expected = message 385 | assert actual == expected 386 | 387 | 388 | def test_commit_in_last_year_yes(monkeypatch): 389 | """ 390 | 11. Has there been a commit in the last year? 391 | """ 392 | headers = {} 393 | 394 | def mock_get(*args, **kwargs): 395 | return MockResponseCommitsYes() 396 | 397 | today = datetime.now() 398 | test_date = datetime.strptime(GOOD_DATE_Z, "%Y-%m-%dT%H:%M:%SZ") 399 | days = (today - test_date).days 400 | 401 | # apply the monkeypatch for requests.get to mock_get 402 | monkeypatch.setattr(requests, "get", mock_get) 403 | url = "https://fakeurl" 404 | actual = commit_in_last_year(url, headers=headers) 405 | expected = f"[green]Yes. The last commit was on {datetime.strftime(test_date, '%m-%d-%Y')} which was {days} days ago" 406 | assert actual == expected 407 | 408 | 409 | def test_commit_in_last_year_no(monkeypatch): 410 | """ 411 | 11. Has there been a commit in the last year? 412 | """ 413 | headers = {} 414 | 415 | def mock_get(*args, **kwargs): 416 | return MockResponseCommitsNo() 417 | 418 | today = datetime.now() 419 | test_date = datetime.strptime(BAD_DATE_Z, "%Y-%m-%dT%H:%M:%SZ") 420 | days = (today - test_date).days 421 | 422 | # apply the monkeypatch for requests.get to mock_get 423 | monkeypatch.setattr(requests, "get", mock_get) 424 | url = "https://fakeurl" 425 | actual = commit_in_last_year(url, headers=headers) 426 | expected = f"[red]No. The last commit was {days} days ago" 427 | assert actual == expected 428 | 429 | 430 | def test_release_in_last_year_yes(monkeypatch): 431 | """ 432 | 12. Has there been a release in the last year? 433 | """ 434 | 435 | def mock_get(*args, **kwargs): 436 | return MockResponseReleasesYes() 437 | 438 | today = datetime.now() 439 | test_date = datetime.strptime(GOOD_DATE, "%Y-%m-%dT%H:%M:%S") 440 | 441 | days = (today - test_date).days 442 | 443 | # apply the monkeypatch for requests.get to mock_get 444 | monkeypatch.setattr(requests, "get", mock_get) 445 | url = "https://fakeurl" 446 | actual = release_in_last_year(url) 447 | expected = f"[green]Yes. The last release was on {datetime.strftime(test_date, '%m-%d-%Y')} which was {days} days ago" 448 | assert actual == expected 449 | 450 | 451 | def test_release_in_last_year_no(monkeypatch): 452 | """ 453 | 12. Has there been a release in the last year? 454 | """ 455 | 456 | def mock_get(*args, **kwargs): 457 | return MockResponseReleasesNo() 458 | 459 | today = datetime.now() 460 | test_date = datetime.strptime(BAD_DATE, "%Y-%m-%dT%H:%M:%S") 461 | days = (today - test_date).days 462 | 463 | # apply the monkeypatch for requests.get to mock_get 464 | monkeypatch.setattr(requests, "get", mock_get) 465 | url = "https://fakeurl" 466 | actual = release_in_last_year(url) 467 | expected = f"[red]No. Version 1.1.1 was last released {days} days ago" 468 | assert actual == expected 469 | 470 | 471 | def test_production_ready_check_yes(monkeypatch): 472 | """ 473 | 1. Is it described as 'production ready'? 474 | """ 475 | 476 | def mock_get(*args, **kwargs): 477 | return MockResponseProductionReadyYes() 478 | 479 | # apply the monkeypatch for requests.get to mock_get 480 | monkeypatch.setattr(requests, "get", mock_get) 481 | url = "https://fakeurl" 482 | actual = production_ready_check(url) 483 | expected = "[green]The project is set to Development Status [underline]Alpha" 484 | assert actual == expected 485 | 486 | 487 | def test_production_ready_check_no(monkeypatch): 488 | """ 489 | 1. Is it described as 'production ready'? 490 | """ 491 | 492 | def mock_get(*args, **kwargs): 493 | return MockResponseProductionReadyNo() 494 | 495 | # apply the monkeypatch for requests.get to mock_get 496 | monkeypatch.setattr(requests, "get", mock_get) 497 | url = "https://fakeurl" 498 | actual = production_ready_check(url) 499 | expected = "[red]There is no Development Status for this package. It is currently at version 0.5" 500 | assert actual == expected 501 | 502 | 503 | def test_document_exists_yes(monkeypatch): 504 | """ 505 | 2. Is there sufficient documentation? 506 | """ 507 | 508 | def mock_get(*args, **kwargs): 509 | return MockResponseDocumentationYes() 510 | 511 | # apply the monkeypatch for requests.get to mock_get 512 | monkeypatch.setattr(requests, "get", mock_get) 513 | url = "https://fakeurl" 514 | actual = documentation_exists(url) 515 | expected = "[green]Documentation can be found at https://fakeurl/blob/main/README.md" 516 | assert actual == expected 517 | 518 | 519 | def test_document_exists_no(monkeypatch): 520 | """ 521 | 2. Is there sufficient documentation? 522 | """ 523 | 524 | def mock_get(*args, **kwargs): 525 | return MockResponseDocumentationNo() 526 | 527 | # apply the monkeypatch for requests.get to mock_get 528 | monkeypatch.setattr(requests, "get", mock_get) 529 | url = "https://fakeurl" 530 | actual = documentation_exists(url) 531 | expected = "[red]There is no documentation for this project" 532 | assert actual == expected 533 | 534 | 535 | def test_language_check(monkeypatch): 536 | """ 537 | 6. Are the tests running with the latest Language version? 538 | """ 539 | 540 | def mock_get(*args, **kwargs): 541 | return MockResponseLanguageCheck() 542 | 543 | # apply the monkeypatch for requests.get to mock_get 544 | monkeypatch.setattr(requests, "get", mock_get) 545 | url = "https://fakeurl" 546 | actual = language_check(url) 547 | expected = "[green]The project supports the following programming languages\n- Python 3.6\n- Python 3.7\n" 548 | assert actual == expected 549 | 550 | 551 | def test_framework_check_exists(monkeypatch): 552 | """ 553 | 7. Are the tests running with the latest Integration version? 554 | """ 555 | 556 | def mock_get(*args, **kwargs): 557 | return MockResponseFrameworkCheck() 558 | 559 | # apply the monkeypatch for requests.get to mock_get 560 | monkeypatch.setattr(requests, "get", mock_get) 561 | url = "https://fakeurl" 562 | actual = framework_check(url) 563 | expected = "[green]The project supports the following framework as it's latest[bold] Framework Django 3.2" 564 | assert actual == expected 565 | 566 | 567 | def test_framework_check_does_not_exist(monkeypatch): 568 | """ 569 | 7. Are the tests running with the latest Integration version? 570 | """ 571 | 572 | def mock_get(*args, **kwargs): 573 | return MockResponseLanguageCheck() 574 | 575 | # apply the monkeypatch for requests.get to mock_get 576 | monkeypatch.setattr(requests, "get", mock_get) 577 | url = "https://fakeurl" 578 | actual = framework_check(url) 579 | expected = "[green]This project has no associated frameworks" 580 | assert actual == expected 581 | 582 | 583 | # TODO: Rewrite test 584 | def test_check_tests_exist(monkeypatch): 585 | """ 586 | 5. Are there sufficient tests? 587 | """ 588 | headers = {} 589 | 590 | def mock_get(*args, **kwargs): 591 | return MockGitHubFileCheckAPIWithTestFiles() 592 | 593 | def mock__get_content(url, headers): 594 | content = """ 595 | ZnJvbSBmdW5jdG9vbHMgaW1wb3J0IHBhcnRpYWwKZnJvbSBpbnNwZWN0IGlt 596 | cG9ydCBQYXJhbWV0ZXIsIFNpZ25hdHVyZSwgc2lnbmF0dXJlCmZyb20gaW8g 597 | aW1wb3J0IFN0cmluZ0lPCmZyb20gdW5pdHRlc3QgaW1wb3J0IG1vY2sKCmlt 598 | cG9ydCBweXRlc3QKZnJvbSBkamFuZ28uY29yZS5tYW5hZ2VtZW50IGltcG9y 599 | dCBCYXNlQ29tbWFuZCwgQ29tbWFuZEVycm9yLCBjYWxsX2NvbW1hbmQKZnJv 600 | bSBkamFuZ28udGVzdCBpbXBvcnQgU2ltcGxlVGVzdENhc2UKZnJvbSByaWNo 601 | LmNvbnNvbGUgaW1wb3J0IENvbnNvbGUKCmZyb20gZGphbmdvX3JpY2gubWFu 602 | YWdlbWVudCBpbXBvcnQgUmljaENvbW1hbmQKZnJvbSB0ZXN0cy50ZXN0YXBw 603 | Lm1hbmFnZW1lbnQuY29tbWFuZHMuZXhhbXBsZSBpbXBvcnQgQ29tbWFuZCBh 604 | cyBFeGFtcGxlQ29tbWFuZAoKCmRlZiBzdHJpcF9hbm5vdGF0aW9ucyhvcmln 605 | aW5hbDogU2lnbmF0dXJlKSAtPiBTaWduYXR1cmU6CiAgICByZXR1cm4gU2ln 606 | bmF0dXJlKAogICAgICAgIHBhcmFtZXRlcnM9WwogICAgICAgICAgICBwYXJh 607 | bS5yZXBsYWNlKGFubm90YXRpb249UGFyYW1ldGVyLmVtcHR5KQogICAgICAg 608 | ICAgICBmb3IgcGFyYW0gaW4gb3JpZ2luYWwucGFyYW1ldGVycy52YWx1ZXMo 609 | KQogICAgICAgIF0KICAgICkKCgpjbGFzcyBGYWtlVHR5U3RyaW5nSU8oU3Ry 610 | aW5nSU8pOgogICAgZGVmIGlzYXR0eShzZWxmKSAtPiBib29sOgogICAgICAg 611 | IHJldHVybiBUcnVlCgoKY2xhc3MgUmljaENvbW1hbmRUZXN0cyhTaW1wbGVU 612 | ZXN0Q2FzZSk6CiAgICBkZWYgdGVzdF9pbml0X3NpZ25hdHVyZShzZWxmKToK 613 | ICAgICAgICByY19zaWduYXR1cmUgPSBzdHJpcF9hbm5vdGF0aW9ucyhzaWdu 614 | YXR1cmUoUmljaENvbW1hbmQuX19pbml0X18pKQoKICAgICAgICBhc3NlcnQg 615 | cmNfc2lnbmF0dXJlID09IHNpZ25hdHVyZShCYXNlQ29tbWFuZC5fX2luaXRf 616 | XykKCiAgICBkZWYgdGVzdF9leGVjdXRlX3NpZ25hdHVyZShzZWxmKToKICAg 617 | ICAgICByY19zaWduYXR1cmUgPSBzdHJpcF9hbm5vdGF0aW9ucyhzaWduYXR1 618 | cmUoUmljaENvbW1hbmQuZXhlY3V0ZSkpCgogICAgICAgIGFzc2VydCByY19z 619 | aWduYXR1cmUgPT0gc2lnbmF0dXJlKEJhc2VDb21tYW5kLmV4ZWN1dGUpCgog 620 | ICAgZGVmIHRlc3RfY29tYmluZWRfY29sb3JfZmxhZ3NfZXJyb3Ioc2VsZik6 621 | CiAgICAgICAgd2l0aCBweXRlc3QucmFpc2VzKENvbW1hbmRFcnJvcikgYXMg 622 | ZXhjaW5mbzoKICAgICAgICAgICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwg 623 | Ii0tbm8tY29sb3IiLCAiLS1mb3JjZS1jb2xvciIpCgogICAgICAgIGFzc2Vy 624 | dCAoCiAgICAgICAgICAgIHN0cihleGNpbmZvLnZhbHVlKQogICAgICAgICAg 625 | ICA9PSAiVGhlIC0tbm8tY29sb3IgYW5kIC0tZm9yY2UtY29sb3Igb3B0aW9u 626 | cyBjYW4ndCBiZSB1c2VkIHRvZ2V0aGVyLiIKICAgICAgICApCgogICAgZGVm 627 | IHRlc3Rfb3V0cHV0X25vbl90dHkoc2VsZik6CiAgICAgICAgc3Rkb3V0ID0g 628 | U3RyaW5nSU8oKQoKICAgICAgICBjYWxsX2NvbW1hbmQoImV4YW1wbGUiLCBz 629 | dGRvdXQ9c3Rkb3V0KQoKICAgICAgICBhc3NlcnQgc3Rkb3V0LmdldHZhbHVl 630 | KCkgPT0gIkFsZXJ0IVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF90dHkoc2Vs 631 | Zik6CiAgICAgICAgc3Rkb3V0ID0gRmFrZVR0eVN0cmluZ0lPKCkKCiAgICAg 632 | ICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwgc3Rkb3V0PXN0ZG91dCkKCiAg 633 | ICAgICAgYXNzZXJ0IHN0ZG91dC5nZXR2YWx1ZSgpID09ICJceDFiWzE7MzFt 634 | QWxlcnQhXHgxYlswbVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF90dHlfbm9f 635 | Y29sb3Ioc2VsZik6CiAgICAgICAgc3Rkb3V0ID0gRmFrZVR0eVN0cmluZ0lP 636 | KCkKCiAgICAgICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwgIi0tbm8tY29s 637 | b3IiLCBzdGRvdXQ9c3Rkb3V0KQoKICAgICAgICBhc3NlcnQgc3Rkb3V0Lmdl 638 | dHZhbHVlKCkgPT0gIkFsZXJ0IVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF9m 639 | b3JjZV9jb2xvcihzZWxmKToKICAgICAgICBzdGRvdXQgPSBTdHJpbmdJTygp 640 | CgogICAgICAgIGNhbGxfY29tbWFuZCgiZXhhbXBsZSIsICItLWZvcmNlLWNv 641 | bG9yIiwgc3Rkb3V0PXN0ZG91dCkKCiAgICAgICAgYXNzZXJ0IHN0ZG91dC5n 642 | ZXR2YWx1ZSgpID09ICJceDFiWzE7MzFtQWxlcnQhXHgxYlswbVxuIgoKICAg 643 | IGRlZiB0ZXN0X291dHB1dF9tYWtlX3JpY2hfY29uc29sZShzZWxmKToKICAg 644 | ICAgICBzdGRvdXQgPSBGYWtlVHR5U3RyaW5nSU8oKQogICAgICAgIG1ha2Vf 645 | Y29uc29sZSA9IHBhcnRpYWwoQ29uc29sZSwgbWFya3VwPUZhbHNlLCBoaWdo 646 | bGlnaHQ9RmFsc2UpCiAgICAgICAgcGF0Y2hlciA9IG1vY2sucGF0Y2gub2Jq 647 | ZWN0KEV4YW1wbGVDb21tYW5kLCAibWFrZV9yaWNoX2NvbnNvbGUiLCBtYWtl 648 | X2NvbnNvbGUpCgogICAgICAgIHdpdGggcGF0Y2hlcjoKICAgICAgICAgICAg 649 | Y2FsbF9jb21tYW5kKCJleGFtcGxlIiwgc3Rkb3V0PXN0ZG91dCkKCiAgICAg 650 | ICAgYXNzZXJ0IHN0ZG91dC5nZXR2YWx1ZSgpID09ICJbYm9sZCByZWRdQWxl 651 | cnQhWy9ib2xkIHJlZF1cbiIK 652 | """ 653 | return content 654 | 655 | # # apply the monkeypatch for requests.get to mock_get 656 | monkeypatch.setattr(requests, "get", mock_get) 657 | monkeypatch.setattr("the_well_maintained_test.utils._get_content", mock__get_content) 658 | url = "https://fakeurl" 659 | actual = check_tests(url, headers=headers, show_progress=True) 660 | expected = "[green]There are 8 tests in 1 files:\n- tests/admin_changelist/test_date_hierarchy.py\n" 661 | assert actual == expected 662 | 663 | 664 | def test_check_tests_do_not_exist(monkeypatch): 665 | """ 666 | 5. Are there sufficient tests? 667 | """ 668 | headers = {} 669 | 670 | def mock_get(*args, **kwargs): 671 | return MockGitHubFileCheckAPIWithOutTestFiles() 672 | 673 | # apply the monkeypatch for requests.get to mock_get 674 | monkeypatch.setattr(requests, "get", mock_get) 675 | url = "https://fakeurl" 676 | actual = check_tests(url, headers=headers, show_progress=True) 677 | expected = "[red]There are 0 tests!" 678 | assert actual == expected 679 | 680 | 681 | def test__get_content_base64(monkeypatch): 682 | def mock_get(*args, **kwargs): 683 | return MockResponseContentBase64() 684 | 685 | headers = {} 686 | monkeypatch.setattr(requests, "get", mock_get) 687 | url = "https://fakeurl" 688 | actual = _get_content(url, headers) 689 | expected = "test" 690 | assert actual == expected 691 | 692 | 693 | def test__get_content_not_base64(monkeypatch): 694 | def mock_get(*args, **kwargs): 695 | return MockResponseContentNotBase64() 696 | 697 | headers = {} 698 | monkeypatch.setattr(requests, "get", mock_get) 699 | url = "https://fakeurl" 700 | with pytest.raises(TypeError): 701 | _get_content(url, headers) 702 | 703 | 704 | def test__test_method_count(): 705 | content = """ 706 | ZnJvbSBmdW5jdG9vbHMgaW1wb3J0IHBhcnRpYWwKZnJvbSBpbnNwZWN0IGlt 707 | cG9ydCBQYXJhbWV0ZXIsIFNpZ25hdHVyZSwgc2lnbmF0dXJlCmZyb20gaW8g 708 | aW1wb3J0IFN0cmluZ0lPCmZyb20gdW5pdHRlc3QgaW1wb3J0IG1vY2sKCmlt 709 | cG9ydCBweXRlc3QKZnJvbSBkamFuZ28uY29yZS5tYW5hZ2VtZW50IGltcG9y 710 | dCBCYXNlQ29tbWFuZCwgQ29tbWFuZEVycm9yLCBjYWxsX2NvbW1hbmQKZnJv 711 | bSBkamFuZ28udGVzdCBpbXBvcnQgU2ltcGxlVGVzdENhc2UKZnJvbSByaWNo 712 | LmNvbnNvbGUgaW1wb3J0IENvbnNvbGUKCmZyb20gZGphbmdvX3JpY2gubWFu 713 | YWdlbWVudCBpbXBvcnQgUmljaENvbW1hbmQKZnJvbSB0ZXN0cy50ZXN0YXBw 714 | Lm1hbmFnZW1lbnQuY29tbWFuZHMuZXhhbXBsZSBpbXBvcnQgQ29tbWFuZCBh 715 | cyBFeGFtcGxlQ29tbWFuZAoKCmRlZiBzdHJpcF9hbm5vdGF0aW9ucyhvcmln 716 | aW5hbDogU2lnbmF0dXJlKSAtPiBTaWduYXR1cmU6CiAgICByZXR1cm4gU2ln 717 | bmF0dXJlKAogICAgICAgIHBhcmFtZXRlcnM9WwogICAgICAgICAgICBwYXJh 718 | bS5yZXBsYWNlKGFubm90YXRpb249UGFyYW1ldGVyLmVtcHR5KQogICAgICAg 719 | ICAgICBmb3IgcGFyYW0gaW4gb3JpZ2luYWwucGFyYW1ldGVycy52YWx1ZXMo 720 | KQogICAgICAgIF0KICAgICkKCgpjbGFzcyBGYWtlVHR5U3RyaW5nSU8oU3Ry 721 | aW5nSU8pOgogICAgZGVmIGlzYXR0eShzZWxmKSAtPiBib29sOgogICAgICAg 722 | IHJldHVybiBUcnVlCgoKY2xhc3MgUmljaENvbW1hbmRUZXN0cyhTaW1wbGVU 723 | ZXN0Q2FzZSk6CiAgICBkZWYgdGVzdF9pbml0X3NpZ25hdHVyZShzZWxmKToK 724 | ICAgICAgICByY19zaWduYXR1cmUgPSBzdHJpcF9hbm5vdGF0aW9ucyhzaWdu 725 | YXR1cmUoUmljaENvbW1hbmQuX19pbml0X18pKQoKICAgICAgICBhc3NlcnQg 726 | cmNfc2lnbmF0dXJlID09IHNpZ25hdHVyZShCYXNlQ29tbWFuZC5fX2luaXRf 727 | XykKCiAgICBkZWYgdGVzdF9leGVjdXRlX3NpZ25hdHVyZShzZWxmKToKICAg 728 | ICAgICByY19zaWduYXR1cmUgPSBzdHJpcF9hbm5vdGF0aW9ucyhzaWduYXR1 729 | cmUoUmljaENvbW1hbmQuZXhlY3V0ZSkpCgogICAgICAgIGFzc2VydCByY19z 730 | aWduYXR1cmUgPT0gc2lnbmF0dXJlKEJhc2VDb21tYW5kLmV4ZWN1dGUpCgog 731 | ICAgZGVmIHRlc3RfY29tYmluZWRfY29sb3JfZmxhZ3NfZXJyb3Ioc2VsZik6 732 | CiAgICAgICAgd2l0aCBweXRlc3QucmFpc2VzKENvbW1hbmRFcnJvcikgYXMg 733 | ZXhjaW5mbzoKICAgICAgICAgICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwg 734 | Ii0tbm8tY29sb3IiLCAiLS1mb3JjZS1jb2xvciIpCgogICAgICAgIGFzc2Vy 735 | dCAoCiAgICAgICAgICAgIHN0cihleGNpbmZvLnZhbHVlKQogICAgICAgICAg 736 | ICA9PSAiVGhlIC0tbm8tY29sb3IgYW5kIC0tZm9yY2UtY29sb3Igb3B0aW9u 737 | cyBjYW4ndCBiZSB1c2VkIHRvZ2V0aGVyLiIKICAgICAgICApCgogICAgZGVm 738 | IHRlc3Rfb3V0cHV0X25vbl90dHkoc2VsZik6CiAgICAgICAgc3Rkb3V0ID0g 739 | U3RyaW5nSU8oKQoKICAgICAgICBjYWxsX2NvbW1hbmQoImV4YW1wbGUiLCBz 740 | dGRvdXQ9c3Rkb3V0KQoKICAgICAgICBhc3NlcnQgc3Rkb3V0LmdldHZhbHVl 741 | KCkgPT0gIkFsZXJ0IVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF90dHkoc2Vs 742 | Zik6CiAgICAgICAgc3Rkb3V0ID0gRmFrZVR0eVN0cmluZ0lPKCkKCiAgICAg 743 | ICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwgc3Rkb3V0PXN0ZG91dCkKCiAg 744 | ICAgICAgYXNzZXJ0IHN0ZG91dC5nZXR2YWx1ZSgpID09ICJceDFiWzE7MzFt 745 | QWxlcnQhXHgxYlswbVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF90dHlfbm9f 746 | Y29sb3Ioc2VsZik6CiAgICAgICAgc3Rkb3V0ID0gRmFrZVR0eVN0cmluZ0lP 747 | KCkKCiAgICAgICAgY2FsbF9jb21tYW5kKCJleGFtcGxlIiwgIi0tbm8tY29s 748 | b3IiLCBzdGRvdXQ9c3Rkb3V0KQoKICAgICAgICBhc3NlcnQgc3Rkb3V0Lmdl 749 | dHZhbHVlKCkgPT0gIkFsZXJ0IVxuIgoKICAgIGRlZiB0ZXN0X291dHB1dF9m 750 | b3JjZV9jb2xvcihzZWxmKToKICAgICAgICBzdGRvdXQgPSBTdHJpbmdJTygp 751 | CgogICAgICAgIGNhbGxfY29tbWFuZCgiZXhhbXBsZSIsICItLWZvcmNlLWNv 752 | bG9yIiwgc3Rkb3V0PXN0ZG91dCkKCiAgICAgICAgYXNzZXJ0IHN0ZG91dC5n 753 | ZXR2YWx1ZSgpID09ICJceDFiWzE7MzFtQWxlcnQhXHgxYlswbVxuIgoKICAg 754 | IGRlZiB0ZXN0X291dHB1dF9tYWtlX3JpY2hfY29uc29sZShzZWxmKToKICAg 755 | ICAgICBzdGRvdXQgPSBGYWtlVHR5U3RyaW5nSU8oKQogICAgICAgIG1ha2Vf 756 | Y29uc29sZSA9IHBhcnRpYWwoQ29uc29sZSwgbWFya3VwPUZhbHNlLCBoaWdo 757 | bGlnaHQ9RmFsc2UpCiAgICAgICAgcGF0Y2hlciA9IG1vY2sucGF0Y2gub2Jq 758 | ZWN0KEV4YW1wbGVDb21tYW5kLCAibWFrZV9yaWNoX2NvbnNvbGUiLCBtYWtl 759 | X2NvbnNvbGUpCgogICAgICAgIHdpdGggcGF0Y2hlcjoKICAgICAgICAgICAg 760 | Y2FsbF9jb21tYW5kKCJleGFtcGxlIiwgc3Rkb3V0PXN0ZG91dCkKCiAgICAg 761 | ICAgYXNzZXJ0IHN0ZG91dC5nZXR2YWx1ZSgpID09ICJbYm9sZCByZWRdQWxl 762 | cnQhWy9ib2xkIHJlZF1cbiIK 763 | """ 764 | actual = _test_method_count(content) 765 | expected = 8 766 | assert actual == expected 767 | 768 | 769 | def test__get_test_files_exist(monkeypatch): 770 | def mock_get(*args, **kwargs): 771 | return MockResponseTestFilesExist() 772 | 773 | headers = {} 774 | monkeypatch.setattr(requests, "get", mock_get) 775 | url = "https://fakeurl" 776 | actual = _get_test_files(url, headers) 777 | expected = [ 778 | { 779 | "type": "blob", 780 | "path": "tests/test_management.py", 781 | }, 782 | { 783 | "path": "friendship/tests/tests.py", 784 | "type": "blob", 785 | }, 786 | ] 787 | assert actual == expected 788 | 789 | 790 | def test__get_test_files_do_not_exist(monkeypatch): 791 | def mock_get(*args, **kwargs): 792 | return MockResponseTestFilesDoNotExist() 793 | 794 | headers = {} 795 | monkeypatch.setattr(requests, "get", mock_get) 796 | url = "https://fakeurl" 797 | actual = _get_test_files(url, headers) 798 | expected = [] 799 | assert actual == expected 800 | 801 | 802 | def test__get_test_files_no_blobs(monkeypatch): 803 | def mock_get(*args, **kwargs): 804 | return MockResponseTestFilesNoBlobs() 805 | 806 | headers = {} 807 | monkeypatch.setattr(requests, "get", mock_get) 808 | url = "https://fakeurl" 809 | actual = _get_test_files(url, headers) 810 | expected = [] 811 | assert actual == expected 812 | 813 | 814 | def test__get_release_date(): 815 | Release = namedtuple("Release", "version, upload_time") 816 | release = { 817 | "9.9.0": [ 818 | { 819 | "upload_time": "2021-01-16T15:12:25", 820 | } 821 | ], 822 | "10.14.0": [ 823 | { 824 | "upload_time": "2021-10-16T15:12:25", 825 | } 826 | ], 827 | "10.15.0a1": [ 828 | { 829 | "upload_time": "2021-11-16T15:12:25", 830 | } 831 | ], 832 | } 833 | actual = _get_release_date(release) 834 | expected = [ 835 | Release(version="10.14.0", upload_time="2021-10-16T15:12:25"), 836 | Release(version="9.9.0", upload_time="2021-01-16T15:12:25"), 837 | ] 838 | assert actual == expected 839 | 840 | 841 | def test__get_release_date_missing(): 842 | release = { 843 | "9.9.0": [], 844 | } 845 | actual = _get_release_date(release) 846 | expected = [] 847 | assert actual == expected 848 | 849 | 850 | def test__get_requirements_txt_file(tmpdir, monkeypatch): 851 | def mock_get(*args, **kwargs): 852 | return MockResponseProjectURLs() 853 | 854 | monkeypatch.setattr(requests, "get", mock_get) 855 | 856 | p = tmpdir.mkdir("sub").join("requirements.txt") 857 | p.write("Django==3.2.9") 858 | actual = _get_requirements_txt_file(p) 859 | expected = [ 860 | ("Django", "https://github.com/django/django"), 861 | ] 862 | assert actual == expected 863 | 864 | 865 | def test_get_github_api_rate_limits(monkeypatch): 866 | def mock_get(*args, **kwargs): 867 | return MockResponseGitHubRateLimit() 868 | 869 | resource = "core" 870 | headers = {} 871 | 872 | monkeypatch.setattr(requests, "get", mock_get) 873 | reset_date = strftime("%Y-%m-%d %H:%M:%S", localtime(1372700873)) 874 | actual = get_github_api_rate_limits(headers, resource) 875 | message = "You have used 1 out of 5000 calls.\n\n" 876 | message += "You have 4999 calls remaining.\n\n" 877 | message += f"Your limit will reset at {reset_date}." 878 | 879 | expected = message 880 | assert actual == expected 881 | 882 | 883 | def test_get_vulnerabilities_yes(monkeypatch): 884 | def mock_get(*args, **kwargs): 885 | return MockResponseWithVulnerabilities() 886 | 887 | monkeypatch.setattr(requests, "get", mock_get) 888 | url = "https://fakeurl" 889 | actual = get_vulnerabilities(url) 890 | expected = 3 891 | assert actual == expected 892 | 893 | 894 | def test_get_vulnerabilities_no(monkeypatch): 895 | def mock_get(*args, **kwargs): 896 | return MockResponseWithoutVulnerabilities() 897 | 898 | monkeypatch.setattr(requests, "get", mock_get) 899 | url = "https://fakeurl" 900 | actual = get_vulnerabilities(url) 901 | expected = 0 902 | assert actual == expected 903 | 904 | 905 | def test__get_package_github_url_non_github_homepage(monkeypatch): 906 | def mock_get(*args, **kwargs): 907 | return MockResponseNonGitHubHomePage() 908 | 909 | monkeypatch.setattr(requests, "get", mock_get) 910 | url = "https://fakeurl" 911 | actual = _get_package_github_url(url)[1] 912 | expected = "https://www.github.com/author/package" 913 | assert actual == expected 914 | --------------------------------------------------------------------------------