├── tests ├── __init__.py ├── data │ ├── __init__.py │ ├── sample_response.py │ └── expected_output.py ├── test_cache.py └── test_norwegianblue.py ├── .yamlfmt.yaml ├── img └── eol-python.png ├── src └── norwegianblue │ ├── __main__.py │ ├── _cache.py │ ├── _data.py │ ├── cli.py │ └── __init__.py ├── requirements-mypy.txt ├── .github ├── zizmor.yml ├── renovate.json ├── workflows │ ├── labels.yml │ ├── require-pr-label.yml │ ├── lint.yml │ ├── release-drafter.yml │ ├── test.yml │ └── deploy.yml ├── release-drafter.yml └── labels.yml ├── requirements.txt ├── .editorconfig ├── scripts └── run_command.py ├── RELEASING.md ├── tox.ini ├── LICENSE.txt ├── .gitignore ├── .pre-commit-config.yaml ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yamlfmt.yaml: -------------------------------------------------------------------------------- 1 | formatter: 2 | retain_line_breaks_single: true 3 | -------------------------------------------------------------------------------- /img/eol-python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugovk/norwegianblue/HEAD/img/eol-python.png -------------------------------------------------------------------------------- /src/norwegianblue/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from norwegianblue import cli 4 | 5 | if __name__ == "__main__": 6 | cli.main() 7 | -------------------------------------------------------------------------------- /requirements-mypy.txt: -------------------------------------------------------------------------------- 1 | argcomplete 2 | freezegun 3 | mypy==1.19.0 4 | platformdirs 5 | prettytable 6 | pytablewriter 7 | pytest 8 | termcolor 9 | types-python-slugify 10 | urllib3 11 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | # Configuration for the zizmor static analysis tool, run via pre-commit in CI 2 | # https://woodruffw.github.io/zizmor/configuration/ 3 | rules: 4 | unpinned-uses: 5 | config: 6 | policies: 7 | "*": ref-pin 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Only for testing pinned versions on the CI 2 | freezegun==1.5.5 3 | platformdirs==4.5.0 4 | prettytable==3.17.0 5 | pytablewriter[html]==1.2.1 6 | pytest==9.0.1 7 | pytest-cov==7.0.0 8 | python-slugify==8.0.4 9 | termcolor==3.2.0 10 | urllib3==2.6.0 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | 10 | # Four-space indentation 11 | [*.py] 12 | indent_size = 4 13 | indent_style = space 14 | trim_trailing_whitespace = true 15 | 16 | [expected_output.py] 17 | trim_trailing_whitespace = false 18 | 19 | # Two-space indentation 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":semanticCommitsDisabled"], 4 | "labels": ["changelog: skip", "dependencies"], 5 | "packageRules": [ 6 | { 7 | "groupName": "github-actions", 8 | "matchManagers": ["github-actions"], 9 | "separateMajorMinor": "false" 10 | }, 11 | { 12 | "groupName": "requirements.txt", 13 | "matchPaths": ["requirements.txt"] 14 | } 15 | ], 16 | "schedule": ["on the first day of the month"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - .github/labels.yml 9 | workflow_dispatch: 10 | 11 | jobs: 12 | sync: 13 | permissions: 14 | pull-requests: write 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | with: 19 | persist-credentials: false 20 | - uses: micnncim/action-label-syncer@v1 21 | with: 22 | prune: false 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /scripts/run_command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | 5 | 6 | def run(command: str, with_console: bool = True, line_limit: int | None = None) -> None: 7 | output = subprocess.run(command.split(), capture_output=True, text=True) 8 | print() 9 | if with_console: 10 | print("```console") 11 | print(f"$ {command}") 12 | 13 | output = output.stdout.strip() 14 | if line_limit: 15 | output = "".join(output.splitlines(keepends=True)[:line_limit]) + "..." 16 | print(output) 17 | 18 | if with_console: 19 | print("```") 20 | print() 21 | -------------------------------------------------------------------------------- /.github/workflows/require-pr-label.yml: -------------------------------------------------------------------------------- 1 | name: Require PR label 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, labeled, unlabeled, synchronize] 6 | 7 | jobs: 8 | label: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | 15 | steps: 16 | - uses: mheap/github-action-required-labels@v5 17 | with: 18 | mode: minimum 19 | count: 1 20 | labels: | 21 | changelog: Added 22 | changelog: Changed 23 | changelog: Deprecated 24 | changelog: Fixed 25 | changelog: Removed 26 | changelog: Security 27 | changelog: skip 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: {} 6 | 7 | env: 8 | FORCE_COLOR: 1 9 | RUFF_OUTPUT_FORMAT: github 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | persist-credentials: false 19 | - uses: j178/prek-action@v1 20 | 21 | mypy: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v6 26 | with: 27 | persist-credentials: false 28 | - uses: actions/setup-python@v6 29 | with: 30 | python-version: "3.x" 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@v7 33 | - name: Mypy 34 | run: uvx --with tox-uv tox -e mypy 35 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release checklist 2 | 3 | - [ ] Get `main` to the appropriate code release state. 4 | [GitHub Actions](https://github.com/hugovk/norwegianblue/actions) should be 5 | running cleanly for all merges to `main`. 6 | [![GitHub Actions status](https://github.com/hugovk/norwegianblue/workflows/Test/badge.svg)](https://github.com/hugovk/norwegianblue/actions) 7 | 8 | - [ ] Edit release draft, adjust text if needed: 9 | https://github.com/hugovk/norwegianblue/releases 10 | 11 | - [ ] Check next tag is correct, amend if needed 12 | 13 | - [ ] Publish release 14 | 15 | - [ ] Check the tagged 16 | [GitHub Actions build](https://github.com/hugovk/norwegianblue/actions/workflows/deploy.yml) 17 | has deployed to [PyPI](https://pypi.org/project/norwegianblue/#history) 18 | 19 | - [ ] Check installation: 20 | 21 | ```bash 22 | pip3 uninstall -y norwegianblue && pip3 install -U norwegianblue && norwegianblue --version 23 | ``` 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | cog 6 | cli 7 | lint 8 | mypy 9 | pins 10 | py{py3, 315, 314, 313, 312, 311, 310} 11 | 12 | [testenv] 13 | extras = 14 | tests 15 | commands = 16 | {envpython} -m pytest \ 17 | --cov norwegianblue \ 18 | --cov tests \ 19 | --cov-report html \ 20 | --cov-report term \ 21 | --cov-report xml \ 22 | {posargs} 23 | 24 | [testenv:cog] 25 | skip_install = true 26 | deps = 27 | cogapp 28 | commands = 29 | cog -Pr README.md 30 | 31 | [testenv:cli] 32 | commands = 33 | norwegianblue --version 34 | norwegianblue --help 35 | eol --version 36 | eol --help 37 | 38 | [testenv:lint] 39 | skip_install = true 40 | deps = 41 | prek 42 | commands = 43 | prek run --all-files --show-diff-on-failure 44 | 45 | [testenv:mypy] 46 | deps = 47 | -r requirements-mypy.txt 48 | commands = 49 | mypy src {posargs} 50 | 51 | [testenv:pins] 52 | deps = 53 | -r requirements.txt 54 | extras = 55 | None 56 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "$RESOLVED_VERSION" 3 | 4 | categories: 5 | - title: "Added" 6 | labels: 7 | - "changelog: Added" 8 | - "enhancement" 9 | - title: "Changed" 10 | label: "changelog: Changed" 11 | - title: "Deprecated" 12 | label: "changelog: Deprecated" 13 | - title: "Removed" 14 | label: "changelog: Removed" 15 | - title: "Fixed" 16 | labels: 17 | - "changelog: Fixed" 18 | - "bug" 19 | - title: "Security" 20 | label: "changelog: Security" 21 | 22 | exclude-labels: 23 | - "changelog: skip" 24 | 25 | autolabeler: 26 | - label: "changelog: skip" 27 | branch: 28 | - "/pre-commit-ci-update-config/" 29 | 30 | template: | 31 | $CHANGES 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "changelog: Removed" 37 | minor: 38 | labels: 39 | - "changelog: Added" 40 | - "changelog: Changed" 41 | - "changelog: Deprecated" 42 | - "enhancement" 43 | 44 | patch: 45 | labels: 46 | - "changelog: Fixed" 47 | - "bug" 48 | default: minor 49 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 Hugo van Kemenade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | # pull_request event is required only for autolabeler 9 | pull_request: 10 | # Only following types are handled by the action, but one can default to all as well 11 | types: [opened, reopened, synchronize] 12 | # pull_request_target event is required for autolabeler to support PRs from forks 13 | # pull_request_target: 14 | # types: [opened, reopened, synchronize] 15 | workflow_dispatch: 16 | 17 | jobs: 18 | update_release_draft: 19 | if: github.event.repository.fork == false 20 | permissions: 21 | # write permission is required to create a GitHub Release 22 | contents: write 23 | # write permission is required for autolabeler 24 | # otherwise, read permission is required at least 25 | pull-requests: write 26 | runs-on: ubuntu-latest 27 | steps: 28 | # Drafts your next release notes as pull requests are merged into "main" 29 | - uses: release-drafter/release-drafter@v6 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Keep a Changelog labels 2 | # https://keepachangelog.com/en/1.0.0/ 3 | - color: 0e8a16 4 | description: "For new features" 5 | name: "changelog: Added" 6 | - color: af99e5 7 | description: "For changes in existing functionality" 8 | name: "changelog: Changed" 9 | - color: FFA500 10 | description: "For soon-to-be removed features" 11 | name: "changelog: Deprecated" 12 | - color: 00A800 13 | description: "For any bug fixes" 14 | name: "changelog: Fixed" 15 | - color: ff0000 16 | description: "For now removed features" 17 | name: "changelog: Removed" 18 | - color: 045aa0 19 | description: "In case of vulnerabilities" 20 | name: "changelog: Security" 21 | - color: fbca04 22 | description: "Exclude PR from release draft" 23 | name: "changelog: skip" 24 | 25 | # Other labels 26 | - color: 0366d6 27 | description: "For dependencies" 28 | name: dependencies 29 | - color: f4660e 30 | description: "" 31 | name: Hacktoberfest 32 | - color: f4660e 33 | description: "To credit accepted Hacktoberfest PRs" 34 | name: hacktoberfest-accepted 35 | - color: d65e88 36 | description: "Deploy and release" 37 | name: release 38 | - color: fbca04 39 | description: "Unit tests, linting, CI, etc." 40 | name: testing 41 | -------------------------------------------------------------------------------- /src/norwegianblue/_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cache functions 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import datetime as dt 8 | import json 9 | from pathlib import Path 10 | 11 | from platformdirs import user_cache_dir 12 | 13 | CACHE_DIR = Path(user_cache_dir("norwegianblue")) 14 | 15 | 16 | def filename(url: str) -> Path: 17 | """yyyy-mm-dd-url-slug.json""" 18 | from slugify import slugify 19 | 20 | today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") 21 | slug = slugify(url) 22 | return CACHE_DIR / f"{today}-{slug}.json" 23 | 24 | 25 | def load(cache_file: Path): 26 | """Load data from cache_file""" 27 | if not cache_file.exists(): 28 | return {} 29 | 30 | with cache_file.open("r") as f: 31 | try: 32 | data = json.load(f) 33 | except json.decoder.JSONDecodeError: 34 | return {} 35 | 36 | return data 37 | 38 | 39 | def save(cache_file: Path, data) -> None: 40 | """Save data to cache_file""" 41 | try: 42 | if not CACHE_DIR.exists(): 43 | CACHE_DIR.mkdir(parents=True) 44 | 45 | with cache_file.open("w") as f: 46 | json.dump(data, f) 47 | 48 | except OSError: 49 | pass 50 | 51 | 52 | def clear(clear_all: bool = False) -> None: 53 | """Delete all or old cache files""" 54 | cache_files = CACHE_DIR.glob("**/*.json") 55 | today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") 56 | for cache_file in cache_files: 57 | if clear_all or not cache_file.name.startswith(today): 58 | cache_file.unlink() 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: {} 6 | 7 | env: 8 | FORCE_COLOR: 1 9 | 10 | jobs: 11 | test: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["pypy3.11", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] 17 | os: [windows-latest, macos-latest, ubuntu-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | allow-prereleases: true 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v7 32 | 33 | - name: Tox tests 34 | run: | 35 | uvx --with tox-uv tox -e py 36 | 37 | - name: Test CLI 38 | run: | 39 | uvx --with tox-uv tox -e cli 40 | 41 | - name: Tox tests (pins) 42 | if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' 43 | run: | 44 | uvx --with tox-uv tox -e pins 45 | 46 | - name: Upload coverage 47 | uses: codecov/codecov-action@v5 48 | with: 49 | flags: ${{ matrix.os }} 50 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 51 | 52 | success: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | name: Test successful 56 | steps: 57 | - name: Success 58 | run: echo Test successful 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | pip-wheel-metadata/ 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # hatch-vcs 115 | src/*/_version.py 116 | -------------------------------------------------------------------------------- /src/norwegianblue/_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | 5 | d = {} 6 | for c in (32, 126): 7 | for i in range(94): 8 | d[chr(i + c)] = chr((i + 47) % 94 + c) 9 | 10 | 11 | def text(s: str) -> str: 12 | return "".join([d.get(c, c) for c in s]) 13 | 14 | 15 | prefix = text( 16 | """ 17 | OOOOOOOOOOOOOOOOOOOOOOO]]OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO]ilZZl] 18 | OOOOOOOOOOOOOOOOOOOOOOO\\lYZ\\OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO]\\lYRYli 19 | OOOOOOOOOOOOOOOOOOOOOOOOO]\\ZZ\\OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOi\\ZRRYl\\] 20 | OOOOOOOOOOOOOOOOOOOOO]]]]OO]lRYZ\\]OOOOOOOOOOOOOOOOOOOOOi\\lllYRRYli] 21 | OOOOO]OOOO]\\lll\\lZRTTTTTTTTRYRRRliOOOOOOOOOOOOO]iiilZRTTTRRYZi 22 | OO]lYY\\OiYRRRRRTTTTRRRRRRRRRRTRRRRRl]]O]]ii\\ZYYYYYYYRRYl\\i 23 | O\\lYRYRYRRRRRRRRRRYYYYYYYYYRRRRRRRRTTTTTRYYYYYYYYYZli] 24 | lRYlIllZYYYRRRRRRRYZZZZZZZZZYRRRRRRRRRRRRRRRZl\\i 25 | OiZZZZZYYYYRRRRYRYYZZlZllZZZYRYYYYYYRRRRRYRRYli 26 | OOO]\\ZZYYYYYYRRRYYYYZZlllZZllYYYYYYYYYYYYYRRRYi] 27 | OOOOOO]i\\lll\\\\\\lZZZZZZYYZZl\\lllZZYYl\\llZZZZYYZl\\i 28 | 29 | """ 30 | ) 31 | latest = random.choice( 32 | ( 33 | "5625", 34 | "C6DE:?8", 35 | "DE@?6O5625", 36 | "DEF??65", 37 | "A:?:?8O7@COE96O7;@C5D", 38 | "A2DD65O@?", 39 | "?@O>@C6", 40 | "462D65OE@O36", 41 | "6IA:C65O2?5O8@?6OE@O>66EO9:DO>2<6C", 42 | "2ODE:77", 43 | "36C67EO@7O=:76", 44 | "C6DEDO:?OA6246", 45 | "AFD9:?8OFAOE96O52:D:6D", 46 | ">6E23@=:4OAC@46DD6DO2C6O?@HO9:DE@CJ", 47 | "@77OE96OEH:8", 48 | "<:4<65OE96O3F4<6E", 49 | "D9F77=65O@77O9:DO>@CE2=O4@:=", 50 | "CF?O5@H?OE96O4FCE2:?O2?5O;@:?65OE96O3=665:?VO49@:CO:?G:D:3=6", 51 | r"6I\A2CC@E", 52 | ) 53 | ) 54 | 55 | 56 | res = [ 57 | { 58 | "cycle": text("}@CH68:2?Oq=F6"), 59 | "release": text("`heh\\`a\\_f"), 60 | "eol": text("`heh\\`a\\_f"), 61 | "latest": text(latest), 62 | }, 63 | ] 64 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | pull_request: 8 | branches: [main] 9 | release: 10 | types: 11 | - published 12 | workflow_dispatch: 13 | 14 | permissions: {} 15 | 16 | env: 17 | FORCE_COLOR: 1 18 | 19 | jobs: 20 | # Always build & lint package. 21 | build-package: 22 | name: Build & verify package 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v6 27 | with: 28 | fetch-depth: 0 29 | persist-credentials: false 30 | 31 | - uses: hynek/build-and-inspect-python-package@v2 32 | 33 | # Upload to Test PyPI on every commit on main. 34 | release-test-pypi: 35 | name: Publish in-dev package to test.pypi.org 36 | if: | 37 | github.event.repository.fork == false 38 | && github.event_name == 'push' 39 | && github.ref == 'refs/heads/main' 40 | runs-on: ubuntu-latest 41 | needs: build-package 42 | 43 | permissions: 44 | id-token: write 45 | 46 | steps: 47 | - name: Download packages built by build-and-inspect-python-package 48 | uses: actions/download-artifact@v6 49 | with: 50 | name: Packages 51 | path: dist 52 | 53 | - name: Upload package to Test PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | with: 56 | repository-url: https://test.pypi.org/legacy/ 57 | 58 | # Upload to real PyPI on GitHub Releases. 59 | release-pypi: 60 | name: Publish released package to pypi.org 61 | if: | 62 | github.event.repository.fork == false 63 | && github.event.action == 'published' 64 | runs-on: ubuntu-latest 65 | needs: build-package 66 | 67 | permissions: 68 | id-token: write 69 | 70 | steps: 71 | - name: Download packages built by build-and-inspect-python-package 72 | uses: actions/download-artifact@v6 73 | with: 74 | name: Packages 75 | path: dist 76 | 77 | - name: Upload package to PyPI 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.13.3 4 | hooks: 5 | - id: ruff-check 6 | args: [--exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 25.9.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v6.0.0 15 | hooks: 16 | - id: check-added-large-files 17 | - id: check-case-conflict 18 | - id: check-merge-conflict 19 | - id: check-shebang-scripts-are-executable 20 | - id: check-json 21 | - id: check-toml 22 | - id: check-yaml 23 | - id: debug-statements 24 | - id: end-of-file-fixer 25 | - id: forbid-submodules 26 | - id: requirements-txt-fixer 27 | - id: trailing-whitespace 28 | exclude: tests/data/expected_output.py 29 | 30 | - repo: https://github.com/python-jsonschema/check-jsonschema 31 | rev: 0.34.0 32 | hooks: 33 | - id: check-github-workflows 34 | - id: check-renovate 35 | 36 | - repo: https://github.com/rhysd/actionlint 37 | rev: v1.7.7 38 | hooks: 39 | - id: actionlint 40 | 41 | - repo: https://github.com/woodruffw/zizmor-pre-commit 42 | rev: v1.14.2 43 | hooks: 44 | - id: zizmor 45 | 46 | - repo: https://github.com/tox-dev/pyproject-fmt 47 | rev: v2.7.0 48 | hooks: 49 | - id: pyproject-fmt 50 | 51 | - repo: https://github.com/abravalheri/validate-pyproject 52 | rev: v0.24.1 53 | hooks: 54 | - id: validate-pyproject 55 | 56 | - repo: https://github.com/tox-dev/tox-ini-fmt 57 | rev: 1.6.0 58 | hooks: 59 | - id: tox-ini-fmt 60 | 61 | - repo: https://github.com/google/yamlfmt 62 | rev: v0.17.2 63 | hooks: 64 | - id: yamlfmt 65 | 66 | - repo: https://github.com/rbubley/mirrors-prettier 67 | rev: v3.6.2 68 | hooks: 69 | - id: prettier 70 | args: [--prose-wrap=always, --print-width=88] 71 | exclude_types: [yaml] 72 | 73 | - repo: meta 74 | hooks: 75 | - id: check-hooks-apply 76 | - id: check-useless-excludes 77 | 78 | ci: 79 | autoupdate_schedule: quarterly 80 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for norwegianblue cache 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import tempfile 8 | from pathlib import Path 9 | 10 | from freezegun import freeze_time 11 | 12 | from norwegianblue import _cache 13 | 14 | 15 | class TestCache: 16 | def setup_method(self) -> None: 17 | # Choose a new cache dir that doesn't exist 18 | self.original_cache_dir = _cache.CACHE_DIR 19 | self.temp_dir = tempfile.TemporaryDirectory() 20 | _cache.CACHE_DIR = Path(self.temp_dir.name) / "norwegianblue" 21 | 22 | def teardown_method(self) -> None: 23 | # Reset original 24 | _cache.CACHE_DIR = self.original_cache_dir 25 | 26 | @freeze_time("2018-12-26") 27 | def test__cache_filename(self) -> None: 28 | # Arrange 29 | url = "https://endoflife.date/api/python.json" 30 | 31 | # Act 32 | out = _cache.filename(url) 33 | 34 | # Assert 35 | assert str(out).endswith("2018-12-26-https-endoflife-date-api-python-json.json") 36 | 37 | def test_load_cache_not_exist(self) -> None: 38 | # Arrange 39 | filename = Path("file-does-not-exist") 40 | 41 | # Act 42 | data = _cache.load(filename) 43 | 44 | # Assert 45 | assert data == {} 46 | 47 | def test_load_cache_bad_data(self) -> None: 48 | # Arrange 49 | with tempfile.NamedTemporaryFile(delete=False) as f: 50 | f.write(b"Invalid JSON!") 51 | 52 | # Act 53 | data = _cache.load(Path(f.name)) 54 | 55 | # Assert 56 | assert data == {} 57 | 58 | def test_cache_round_trip(self) -> None: 59 | # Arrange 60 | filename = _cache.CACHE_DIR / "test_cache_round_trip.json" 61 | data = "test data" 62 | 63 | # Act 64 | _cache.save(filename, data) 65 | new_data = _cache.load(filename) 66 | 67 | # Tidy up 68 | filename.unlink() 69 | 70 | # Assert 71 | assert new_data == data 72 | 73 | @freeze_time("2021-10-25") 74 | def test_cache_clear_all(self) -> None: 75 | # Arrange 76 | # Create old cache file 77 | cache_file_old = _cache.CACHE_DIR / "2021-10-24-old-cache-file.json" 78 | cache_file_new = _cache.CACHE_DIR / "2021-10-25-new-cache-file.json" 79 | _cache.save(cache_file_old, data={}) 80 | _cache.save(cache_file_new, data={}) 81 | assert cache_file_new.exists() 82 | assert cache_file_old.exists() 83 | 84 | # Act 85 | _cache.clear(clear_all=True) 86 | 87 | # Assert 88 | assert not cache_file_old.exists() 89 | assert not cache_file_new.exists() 90 | 91 | @freeze_time("2021-10-25") 92 | def test_cache_clear(self) -> None: 93 | # Arrange 94 | # Create old cache file 95 | cache_file_old = _cache.CACHE_DIR / "2021-10-24-old-cache-file.json" 96 | cache_file_new = _cache.CACHE_DIR / "2021-10-25-new-cache-file.json" 97 | _cache.save(cache_file_old, data={}) 98 | _cache.save(cache_file_new, data={}) 99 | assert cache_file_new.exists() 100 | assert cache_file_old.exists() 101 | 102 | # Act 103 | _cache.clear() 104 | 105 | # Assert 106 | assert not cache_file_old.exists() 107 | assert cache_file_new.exists() 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "norwegianblue" 10 | description = "CLI to show end-of-life dates for a number of products" 11 | readme = "README.md" 12 | keywords = [ 13 | "cli", 14 | "end-of-life", 15 | "endoflife", 16 | "eol", 17 | ] 18 | license = "MIT" 19 | license-files = [ "LICENSE.txt" ] 20 | authors = [ { name = "Hugo van Kemenade" } ] 21 | requires-python = ">=3.10" 22 | classifiers = [ 23 | "Development Status :: 3 - Alpha", 24 | "Intended Audience :: Developers", 25 | "Intended Audience :: End Users/Desktop", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3.14", 33 | "Programming Language :: Python :: 3.15", 34 | "Programming Language :: Python :: Implementation :: CPython", 35 | "Programming Language :: Python :: Implementation :: PyPy", 36 | ] 37 | dynamic = [ "version" ] 38 | dependencies = [ 39 | "argcomplete>=3.4; platform_system!='Windows'", 40 | "platformdirs", 41 | "prettytable>=3.16", 42 | "pytablewriter[html]>=0.63", 43 | "python-slugify", 44 | "termcolor>=2.1", 45 | "urllib3>=2", 46 | ] 47 | optional-dependencies.tests = [ 48 | "freezegun", 49 | "pytest", 50 | "pytest-cov", 51 | "termcolor>=3.2", 52 | ] 53 | urls.Changelog = "https://github.com/hugovk/norwegianblue/releases" 54 | urls.Homepage = "https://github.com/hugovk/norwegianblue" 55 | urls.Source = "https://github.com/hugovk/norwegianblue" 56 | scripts.eol = "norwegianblue.cli:main" 57 | scripts.norwegianblue = "norwegianblue.cli:main" 58 | 59 | [tool.hatch] 60 | version.source = "vcs" 61 | 62 | [tool.hatch.build.hooks.vcs] 63 | version-file = "src/norwegianblue/_version.py" 64 | 65 | [tool.hatch.version.raw-options] 66 | local_scheme = "no-local-version" 67 | 68 | [tool.ruff] 69 | fix = true 70 | 71 | lint.select = [ 72 | "C4", # flake8-comprehensions 73 | "E", # pycodestyle errors 74 | "EM", # flake8-errmsg 75 | "F", # pyflakes 76 | "I", # isort 77 | "ICN", # flake8-import-conventions 78 | "ISC", # flake8-implicit-str-concat 79 | "LOG", # flake8-logging 80 | "PGH", # pygrep-hooks 81 | "PIE", # flake8-pie 82 | "PT", # flake8-pytest-style 83 | "PYI", # flake8-pyi 84 | "RUF022", # unsorted-dunder-all 85 | "RUF100", # unused noqa (yesqa) 86 | "UP", # pyupgrade 87 | "W", # pycodestyle warnings 88 | "YTT", # flake8-2020 89 | ] 90 | lint.ignore = [ 91 | "E203", # Whitespace before ':' 92 | "E221", # Multiple spaces before operator 93 | "E226", # Missing whitespace around arithmetic operator 94 | "E241", # Multiple spaces after ',' 95 | "PIE790", # flake8-pie: unnecessary-placeholder 96 | ] 97 | lint.flake8-import-conventions.aliases.datetime = "dt" 98 | lint.flake8-import-conventions.banned-from = [ "datetime" ] 99 | lint.flake8-pytest-style.parametrize-names-type = "csv" 100 | lint.isort.known-first-party = [ "norwegianblue" ] 101 | lint.isort.required-imports = [ "from __future__ import annotations" ] 102 | lint.future-annotations = true 103 | 104 | [tool.pyproject-fmt] 105 | max_supported_python = "3.15" 106 | 107 | [tool.pytest.ini_options] 108 | addopts = "--color=yes" 109 | testpaths = [ "tests" ] 110 | 111 | [tool.coverage.run] 112 | omit = [ 113 | "**/__main__.py", 114 | "**/cli.py", 115 | ] 116 | 117 | [tool.coverage.report] 118 | # Regexes for lines to exclude from consideration 119 | exclude_also = [ 120 | # Don't complain if non-runnable code isn't run: 121 | "if __name__ == .__main__.:", 122 | ] 123 | 124 | [tool.mypy] 125 | pretty = true 126 | show_error_codes = true 127 | -------------------------------------------------------------------------------- /src/norwegianblue/cli.py: -------------------------------------------------------------------------------- 1 | # PYTHON_ARGCOMPLETE_OK 2 | """ 3 | CLI to show end-of-life dates for a number of products, from https://endoflife.date 4 | 5 | For example: 6 | 7 | * `eol python` to see Python EOLs 8 | * `eol ubuntu` to see Ubuntu EOLs 9 | * `eol centos fedora` to see CentOS and Fedora EOLs 10 | * `eol all` or `eol` to list all available products 11 | 12 | Something missing? Please contribute! https://endoflife.date/contribute 13 | """ 14 | 15 | from __future__ import annotations 16 | 17 | import argparse 18 | import atexit 19 | import logging 20 | import platform 21 | 22 | from termcolor import colored 23 | 24 | try: 25 | import argcomplete 26 | except ImportError: 27 | argcomplete = None # type: ignore[assignment] 28 | 29 | import norwegianblue 30 | from norwegianblue import _cache 31 | 32 | atexit.register(_cache.clear) 33 | 34 | 35 | def product_completer(**kwargs) -> list[str]: 36 | """The list of all products to feed autocompletion""" 37 | return norwegianblue.all_products() 38 | 39 | 40 | def main() -> None: 41 | parser = argparse.ArgumentParser( 42 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 43 | ) 44 | # Added in Python 3.14 45 | parser.suggest_on_error = True 46 | parser.add_argument( 47 | "product", 48 | nargs="*", 49 | default=["all"], 50 | help="product to check, or 'all' to list all available (default: 'all')", 51 | ).completer = product_completer # type: ignore[attr-defined] 52 | parser.add_argument( 53 | "-c", 54 | "--color", 55 | default="auto", 56 | choices=("yes", "no", "auto"), 57 | help="colour the output (default: auto)", 58 | ) 59 | parser.add_argument( 60 | "--clear-cache", action="store_true", help="clear cache before running" 61 | ) 62 | parser.add_argument( 63 | "--show-title", 64 | default="auto", 65 | choices=("yes", "no", "auto"), 66 | help="show or hide product title, 'auto' to show title " 67 | "only for multiple products (default: auto)", 68 | ) 69 | parser.add_argument( 70 | "-v", 71 | "--verbose", 72 | action="store_const", 73 | dest="loglevel", 74 | const=logging.INFO, 75 | default=logging.WARNING, 76 | help="print extra messages to stderr", 77 | ) 78 | parser.add_argument( 79 | "-V", 80 | "--version", 81 | action="version", 82 | version=f"%(prog)s {norwegianblue.__version__} " 83 | f"(Python {platform.python_version()})", 84 | ) 85 | parser.add_argument( 86 | "-w", "--web", action="store_true", help="open product page in web browser" 87 | ) 88 | 89 | format_group = parser.add_argument_group("formatters") 90 | format_group = format_group.add_mutually_exclusive_group() 91 | 92 | for name, help_text in ( 93 | ("pretty", "pretty (default)"), 94 | ("md", "Markdown"), 95 | ("rst", "reStructuredText"), 96 | ("json", "JSON"), 97 | ("csv", "CSV"), 98 | ("tsv", "TSV"), 99 | ("html", "HTML"), 100 | ("yaml", "YAML"), 101 | ): 102 | format_group.add_argument( 103 | f"--{name}", 104 | action="store_const", 105 | const=name, 106 | dest="formatter", 107 | help=f"output in {help_text}", 108 | ) 109 | parser.set_defaults(formatter="pretty") 110 | if argcomplete: 111 | argcomplete.autocomplete(parser) 112 | args = parser.parse_args() 113 | 114 | logging.basicConfig(level=args.loglevel, format="%(message)s") 115 | if args.clear_cache: 116 | _cache.clear(clear_all=True) 117 | 118 | multiple_products = len(args.product) >= 2 119 | show_title = (args.show_title == "yes") or ( 120 | multiple_products and args.show_title != "no" 121 | ) 122 | for product in args.product: 123 | try: 124 | output = norwegianblue.norwegianblue( 125 | product=product, 126 | format=args.formatter, 127 | color=args.color, 128 | show_title=show_title, 129 | ) 130 | except ValueError as e: 131 | suggestion = norwegianblue.suggest_product(product) 132 | 133 | prompt = f"{e}{' [Y/n] ' if suggestion else ''}" 134 | if args.color != "no": 135 | prompt = colored(prompt, "yellow") 136 | if not suggestion: 137 | print(prompt) 138 | print() 139 | continue 140 | answer = input(prompt) 141 | if answer not in ("", "y", "Y"): 142 | print() 143 | continue 144 | output = norwegianblue.norwegianblue( 145 | product=suggestion, 146 | format=args.formatter, 147 | color=args.color, 148 | show_title=show_title, 149 | ) 150 | print(output) 151 | print() 152 | if args.web: 153 | import webbrowser 154 | 155 | webbrowser.open_new_tab(f"https://endoflife.date/{product.lower()}") 156 | 157 | 158 | if __name__ == "__main__": 159 | main() 160 | -------------------------------------------------------------------------------- /tests/data/sample_response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | SAMPLE_RESPONSE_ALL_JSON = """ 4 | [ 5 | "alpine", 6 | "amazon-linux", 7 | "android", 8 | "bootstrap", 9 | "centos" 10 | ] 11 | """ 12 | 13 | 14 | SAMPLE_RESPONSE_JSON_LOG4J = """ 15 | [ 16 | { 17 | "cycle": "2", 18 | "cycleShortHand": 299, 19 | "eol": false, 20 | "latest": "2.17.2", 21 | "releaseDate": "2014-07-12" 22 | }, 23 | { 24 | "cycle": "2.12", 25 | "cycleShortHand": 212, 26 | "eol": "2021-12-14", 27 | "latest": "2.12.4", 28 | "releaseDate": "2019-06-23" 29 | }, 30 | { 31 | "cycle": "2.3", 32 | "cycleShortHand": 203, 33 | "eol": "2015-09-20", 34 | "latest": "2.3.2", 35 | "releaseDate": "2015-05-09" 36 | }, 37 | { 38 | "cycle": "1", 39 | "cycleShortHand": 100, 40 | "eol": "2015-10-15", 41 | "latest": "1.2.17", 42 | "releaseDate": "2001-01-08" 43 | } 44 | ] 45 | """ 46 | 47 | SAMPLE_RESPONSE_JSON_PYTHON = """ 48 | [ 49 | { 50 | "cycle": "3.10", 51 | "eol": "2026-10-04", 52 | "latest": "3.10.5", 53 | "latestReleaseDate": "2022-06-06", 54 | "releaseDate": "2021-10-04" 55 | }, 56 | { 57 | "cycle": "3.9", 58 | "eol": "2025-10-05", 59 | "latest": "3.9.13", 60 | "latestReleaseDate": "2022-05-17", 61 | "releaseDate": "2020-10-05" 62 | }, 63 | { 64 | "cycle": "3.8", 65 | "eol": "2024-10-14", 66 | "latest": "3.8.13", 67 | "latestReleaseDate": "2022-03-16", 68 | "releaseDate": "2019-10-14" 69 | }, 70 | { 71 | "cycle": "3.7", 72 | "eol": "2023-06-27", 73 | "latest": "3.7.13", 74 | "latestReleaseDate": "2022-03-16", 75 | "releaseDate": "2018-06-26" 76 | }, 77 | { 78 | "cycle": "3.6", 79 | "eol": "2021-12-23", 80 | "latest": "3.6.15", 81 | "latestReleaseDate": "2021-09-03", 82 | "releaseDate": "2016-12-22" 83 | }, 84 | { 85 | "cycle": "3.5", 86 | "eol": "2020-09-13", 87 | "latest": "3.5.10", 88 | "latestReleaseDate": "2020-09-05", 89 | "releaseDate": "2015-09-12" 90 | }, 91 | { 92 | "cycle": "3.4", 93 | "eol": "2019-03-18", 94 | "latest": "3.4.10", 95 | "latestReleaseDate": "2019-03-18", 96 | "releaseDate": "2014-03-15" 97 | }, 98 | { 99 | "cycle": "3.3", 100 | "eol": "2017-09-29", 101 | "latest": "3.3.7", 102 | "latestReleaseDate": "2017-09-19", 103 | "releaseDate": "2012-09-29" 104 | }, 105 | { 106 | "cycle": "2.7", 107 | "eol": "2020-01-01", 108 | "latest": "2.7.18", 109 | "latestReleaseDate": "2020-04-19", 110 | "releaseDate": "2010-07-03" 111 | } 112 | ] 113 | """ 114 | 115 | SAMPLE_RESPONSE_JSON_RHEL = """ 116 | [ 117 | { 118 | "cycle": "9", 119 | "releaseDate": "2022-05-17", 120 | "support": "2027-05-31", 121 | "eol": "2032-05-31", 122 | "lts": "2032-05-31", 123 | "extendedSupport": "2035-05-31", 124 | "latest": "9.3", 125 | "latestReleaseDate": "2023-11-07" 126 | }, 127 | { 128 | "cycle": "8", 129 | "releaseDate": "2019-05-07", 130 | "support": "2024-05-31", 131 | "eol": "2029-05-31", 132 | "lts": "2029-05-31", 133 | "extendedSupport": "2032-05-31", 134 | "latest": "8.9", 135 | "latestReleaseDate": "2023-11-14" 136 | }, 137 | { 138 | "cycle": "7", 139 | "releaseDate": "2013-12-11", 140 | "support": "2019-08-06", 141 | "eol": "2024-06-30", 142 | "lts": "2024-06-30", 143 | "extendedSupport": "2028-06-30", 144 | "latest": "7.9", 145 | "latestReleaseDate": "2020-09-29" 146 | }, 147 | { 148 | "cycle": "6", 149 | "releaseDate": "2010-11-09", 150 | "support": "2016-05-10", 151 | "eol": "2020-11-30", 152 | "lts": "2020-11-30", 153 | "extendedSupport": "2024-06-30", 154 | "latest": "6.10", 155 | "latestReleaseDate": "2018-06-19" 156 | }, 157 | { 158 | "cycle": "5", 159 | "releaseDate": "2007-03-15", 160 | "support": "2013-01-08", 161 | "eol": "2017-03-31", 162 | "lts": "2017-03-31", 163 | "extendedSupport": "2020-11-30", 164 | "latest": "5.11", 165 | "latestReleaseDate": "2014-09-16" 166 | }, 167 | { 168 | "cycle": "4", 169 | "releaseDate": "2005-02-15", 170 | "support": "2009-03-31", 171 | "eol": "2012-02-29", 172 | "extendedSupport": "2017-03-31", 173 | "latest": "4.9", 174 | "latestReleaseDate": "2011-02-16", 175 | "lts": false 176 | } 177 | ] 178 | """ 179 | 180 | SAMPLE_RESPONSE_JSON_UBUNTU = """ 181 | [ 182 | { 183 | "cycle": "22.04", 184 | "codename": "Jammy Jellyfish", 185 | "support": "2027-04-02", 186 | "eol": "2032-04-01", 187 | "lts": true, 188 | "latest": "22.04", 189 | "link": "https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/", 190 | "releaseDate": "2022-04-21" 191 | }, 192 | { 193 | "cycle": "21.10", 194 | "codename": "Impish Indri", 195 | "support": "2022-07-31", 196 | "eol": "2022-07-31", 197 | "latest": "21.10", 198 | "link": "https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/", 199 | "releaseDate": "2021-10-14" 200 | }, 201 | { 202 | "cycle": "21.04", 203 | "codename": "Hirsute Hippo", 204 | "support": "2022-01-20", 205 | "eol": "2022-01-20", 206 | "latest": "21.04", 207 | "link": "https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/", 208 | "releaseDate": "2021-04-22" 209 | }, 210 | { 211 | "cycle": "20.10", 212 | "codename": "Groovy Gorilla", 213 | "support": "2021-07-22", 214 | "eol": "2021-07-22", 215 | "latest": "20.10", 216 | "releaseDate": "2020-10-22" 217 | }, 218 | { 219 | "cycle": "20.04", 220 | "codename": "Focal Fossa", 221 | "lts": true, 222 | "support": "2025-04-02", 223 | "eol": "2030-04-01", 224 | "latest": "20.04.4", 225 | "releaseDate": "2020-04-23" 226 | }, 227 | { 228 | "cycle": "19.10", 229 | "codename": "Karmic Koala", 230 | "support": "2020-07-06", 231 | "eol": "2020-07-06", 232 | "latest": "19.10", 233 | "releaseDate": "2019-10-17" 234 | }, 235 | { 236 | "cycle": "18.04", 237 | "codename": "Bionic Beaver", 238 | "lts": true, 239 | "support": "2023-04-02", 240 | "eol": "2028-04-01", 241 | "latest": "18.04.6", 242 | "link": "https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes", 243 | "releaseDate": "2018-04-26" 244 | }, 245 | { 246 | "cycle": "16.04", 247 | "codename": "Xenial Xerus", 248 | "lts": true, 249 | "support": "2021-04-02", 250 | "eol": "2026-04-01", 251 | "latest": "16.04.7", 252 | "releaseDate": "2016-04-21" 253 | }, 254 | { 255 | "cycle": "14.04", 256 | "codename": "Trusty Tahr", 257 | "lts": true, 258 | "support": "2019-04-02", 259 | "eol": "2024-04-01", 260 | "latest": "14.04.6", 261 | "releaseDate": "2014-04-17" 262 | } 263 | ] 264 | """ 265 | -------------------------------------------------------------------------------- /src/norwegianblue/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python interface to endoflife.date API 3 | https://endoflife.date/docs/api/ 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import datetime as dt 9 | import json 10 | import logging 11 | from functools import cache 12 | 13 | from termcolor import colored 14 | 15 | from norwegianblue import _cache, _version 16 | 17 | __version__ = _version.__version__ 18 | 19 | __all__ = ["__version__"] 20 | 21 | BASE_URL = "https://endoflife.date/api/" 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | def error_404_text(product: str, suggestion: str) -> str: 27 | return f"Product '{product}' not found, run 'eol all' for list." + ( 28 | f" Did you mean: '{suggestion}'?" if suggestion else "" 29 | ) 30 | 31 | 32 | def norwegianblue( 33 | product: str = "all", 34 | format: str | None = "pretty", 35 | color: str = "yes", 36 | show_title: bool = False, 37 | ) -> str | list[dict]: 38 | """Call the API and return result""" 39 | if format == "md": 40 | format = "markdown" 41 | if product == "norwegianblue": 42 | from ._data import prefix, res 43 | else: 44 | url = BASE_URL + product.lower() + ".json" 45 | cache_file = _cache.filename(url) 46 | logger.info("Human URL:\thttps://endoflife.date/%s", product.lower()) 47 | logger.info("API URL:\t%s", url) 48 | logger.info( 49 | "Source URL:\thttps://github.com/endoflife-date/endoflife.date/" 50 | "blob/master/products/%s.md", 51 | product.lower(), 52 | ) 53 | logger.info("Cache file:\t%s", cache_file) 54 | 55 | res = [] 56 | if cache_file.is_file(): 57 | logger.info("Cache file exists") 58 | res = _cache.load(cache_file) 59 | 60 | if not res: 61 | # No cache, or couldn't load cache 62 | import urllib3 63 | 64 | r = urllib3.request( 65 | "GET", 66 | url, 67 | headers={"User-Agent": f"norwegianblue/{__version__}"}, 68 | redirect=True, 69 | ) 70 | 71 | logger.info("HTTP status code: %d", r.status) 72 | if r.status == 404: 73 | suggestion = suggest_product(product) 74 | msg = error_404_text(product, suggestion) 75 | raise ValueError(msg) 76 | 77 | # Raise if we made a bad request 78 | # (4XX client error or 5XX server error response) 79 | if r.status >= 400: 80 | msg = f"HTTP {r.status} error for url: {url}" 81 | raise urllib3.exceptions.HTTPError(msg) 82 | 83 | res = json.loads(r.data.decode("utf-8")) 84 | 85 | _cache.save(cache_file, res) 86 | 87 | if format == "json": 88 | return json.dumps(res) 89 | 90 | data: list[dict] = list(res) 91 | 92 | if format is None: 93 | return data 94 | 95 | if product == "all": 96 | return "\n".join(data) # type: ignore[arg-type] 97 | 98 | data = _ltsify(data) 99 | if color != "no" and format != "yaml": 100 | data = _colourify(data, is_html=format == "html") 101 | 102 | if format in ("pretty", "markdown", "rst", "html"): 103 | data = linkify(data, format) 104 | 105 | output = _tabulate(data, format, color, product if show_title else None) 106 | logger.info("") 107 | 108 | if product == "norwegianblue": 109 | return prefix + output 110 | 111 | return output 112 | 113 | 114 | def linkify(data: list[dict], format_: str) -> list[dict]: 115 | """If a cycle has a link, add a hyperlink and remove the link column""" 116 | for cycle in data: 117 | if "link" in cycle: 118 | if cycle["link"]: 119 | if format_ == "pretty": 120 | cycle["cycle"] = ( 121 | f"\033]8;;{cycle['link']}\033\\{cycle['cycle']}\033]8;;\033\\" 122 | ) 123 | elif format_ == "markdown": 124 | cycle["cycle"] = f"[{cycle['cycle']}]({cycle['link']})" 125 | elif format_ == "rst": 126 | cycle["cycle"] = f"`{cycle['cycle']} <{cycle['link']}>`__" 127 | elif format_ == "html": 128 | cycle["cycle"] = f'{cycle["cycle"]}' 129 | 130 | cycle.pop("link") 131 | 132 | return data 133 | 134 | 135 | def all_products() -> list[str]: 136 | """Get all known products from the API or cache""" 137 | result = norwegianblue("all") 138 | assert isinstance(result, str) 139 | return result.splitlines() 140 | 141 | 142 | @cache 143 | def suggest_product(product: str) -> str: 144 | """Provide the best suggestion based on a typed product""" 145 | import difflib 146 | 147 | # Find the closest match 148 | result = difflib.get_close_matches(product, all_products(), n=1) 149 | logger.info("Suggestion:\t%s (score: %d)", *result) 150 | return result[0] if result else "" 151 | 152 | 153 | def _ltsify(data: list[dict]) -> list[dict]: 154 | """If a cycle is LTS, append LTS to the cycle version and remove the LTS column""" 155 | for cycle in data: 156 | if "lts" in cycle: 157 | if cycle["lts"]: 158 | cycle["cycle"] = f"{cycle['cycle']} LTS" 159 | cycle.pop("lts") 160 | return data 161 | 162 | 163 | def _colourify(data: list[dict], *, is_html: bool = False) -> list[dict]: 164 | """Add colour to dates: 165 | red: in the past 166 | yellow: will pass in six months 167 | green: will pass after six months 168 | """ 169 | now = dt.datetime.now(dt.timezone.utc) 170 | six_months_from_now = now + dt.timedelta(days=180) 171 | 172 | for cycle in data: 173 | for property_ in ("discontinued", "support", "eol", "extendedSupport"): 174 | if property_ not in cycle: 175 | continue 176 | 177 | # Handle Boolean 178 | if isinstance(cycle[property_], bool): 179 | if property_ in ("support", "extendedSupport"): 180 | colour = "green" if cycle[property_] else "red" 181 | else: # "discontinued" or "eol" 182 | colour = "red" if cycle[property_] else "green" 183 | 184 | cycle[property_] = _apply_colour( 185 | cycle[property_], colour, is_html=is_html 186 | ) 187 | continue 188 | 189 | # Handle date 190 | date_str = cycle[property_] 191 | # Convert "2020-01-01" string to datetime 192 | date_datetime = dt.datetime.strptime(date_str, "%Y-%m-%d").replace( 193 | tzinfo=dt.timezone.utc 194 | ) 195 | if date_datetime < now: 196 | cycle[property_] = _apply_colour(date_str, "red", is_html=is_html) 197 | elif date_datetime < six_months_from_now: 198 | cycle[property_] = _apply_colour(date_str, "yellow", is_html=is_html) 199 | else: 200 | cycle[property_] = _apply_colour(date_str, "green", is_html=is_html) 201 | return data 202 | 203 | 204 | def _apply_colour(text: str, colour: str, *, is_html: bool = False) -> str: 205 | if is_html: 206 | return f'{text}' 207 | 208 | return colored(text, colour) 209 | 210 | 211 | def _tabulate( 212 | data: list[dict], 213 | format_: str = "markdown", 214 | color: str = "yes", 215 | title: str | None = None, 216 | ) -> str: 217 | """Return data in specified format""" 218 | 219 | # Rename some headers 220 | for row in data: 221 | if "releaseDate" in row: 222 | row["release"] = row.pop("releaseDate") 223 | if "latestReleaseDate" in row: 224 | row["latest release"] = row.pop("latestReleaseDate") 225 | if "extendedSupport" in row: 226 | row["extended support"] = row.pop("extendedSupport") 227 | 228 | headers = sorted(set().union(*(d.keys() for d in data))) 229 | 230 | # Skip some headers, only used internally at https://endoflife.date 231 | for header in ("cycleShortHand", "latestShortHand", "releaseLabel"): 232 | if header in headers: 233 | headers.remove(header) 234 | 235 | # Put headers in preferred order, with the rest at the end 236 | new_headers = [] 237 | for preferred in ( 238 | "cycle", 239 | "codename", 240 | "release", 241 | "latest", 242 | "latest release", 243 | "discontinued", 244 | "support", 245 | "eol", 246 | "extended support", 247 | ): 248 | if preferred in headers: 249 | new_headers.append(preferred) 250 | headers.remove(preferred) 251 | headers = new_headers + headers 252 | 253 | if format_ in ("markdown", "pretty"): 254 | return _prettytable(headers, data, format_, color, title) 255 | else: 256 | return _pytablewriter(headers, data, format_, title) 257 | 258 | 259 | def _prettytable( 260 | headers: list[str], 261 | data: list[dict], 262 | format_: str, 263 | color: str = "yes", 264 | title: str | None = None, 265 | ) -> str: 266 | from prettytable import PrettyTable, TableStyle 267 | 268 | table = PrettyTable() 269 | table.set_style( 270 | TableStyle.MARKDOWN if format_ == "markdown" else TableStyle.SINGLE_BORDER 271 | ) 272 | do_color = color != "no" and format_ == "pretty" 273 | 274 | for header in headers: 275 | left_align = header in ("cycle", "latest", "link") 276 | display_header = colored(header, attrs=["bold"]) if do_color else header 277 | col_data = [row[header] if header in row else "" for row in data] 278 | table.add_column(display_header, col_data) 279 | 280 | if left_align: 281 | table.align[display_header] = "l" 282 | 283 | title_prefix = "" 284 | if title: 285 | if format_ == "pretty": 286 | table.title = colored(title, attrs=["bold"]) if do_color else title 287 | else: 288 | title_prefix = f"## {title}\n\n" 289 | 290 | return title_prefix + table.get_string() 291 | 292 | 293 | def _pytablewriter( 294 | headers: list[str], data: list[dict], format_: str, title: str | None = None 295 | ) -> str: 296 | from pytablewriter import ( 297 | CsvTableWriter, 298 | HtmlTableWriter, 299 | RstSimpleTableWriter, 300 | String, 301 | TsvTableWriter, 302 | YamlTableWriter, 303 | ) 304 | from pytablewriter.style import Align, Style 305 | 306 | format_writers = { 307 | "csv": CsvTableWriter, 308 | "html": HtmlTableWriter, 309 | "rst": RstSimpleTableWriter, 310 | "tsv": TsvTableWriter, 311 | "yaml": YamlTableWriter, 312 | } 313 | 314 | writer = format_writers[format_]() # type: ignore[abstract] 315 | if format_ != "html": 316 | writer.margin = 1 317 | 318 | writer.table_name = title # type: ignore[assignment] 319 | writer.headers = headers 320 | writer.value_matrix = data 321 | 322 | # Custom alignment and format 323 | column_styles = [] 324 | type_hints = [] 325 | 326 | for header in headers: 327 | align = Align.AUTO 328 | type_hint = None 329 | if header in ("cycle", "latest"): 330 | type_hint = String 331 | style = Style(align=align) 332 | column_styles.append(style) 333 | type_hints.append(type_hint) 334 | 335 | writer.column_styles = column_styles 336 | writer.type_hints = type_hints 337 | 338 | return writer.dumps() 339 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # norwegianblue 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/norwegianblue.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/norwegianblue/) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/norwegianblue.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/norwegianblue/) 5 | [![PyPI downloads](https://img.shields.io/pypi/dm/norwegianblue.svg)](https://pypistats.org/packages/norwegianblue) 6 | [![GitHub Actions status](https://github.com/hugovk/norwegianblue/actions/workflows/test.yml/badge.svg)](https://github.com/hugovk/norwegianblue/actions) 7 | [![Codecov](https://codecov.io/gh/hugovk/norwegianblue/branch/main/graph/badge.svg)](https://codecov.io/gh/hugovk/norwegianblue) 8 | [![Licence](https://img.shields.io/github/license/hugovk/norwegianblue.svg)](LICENSE.txt) 9 | [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) 10 | 11 |

12 | 13 | Python interface to [endoflife.date](https://endoflife.date/docs/api/) to show 14 | end-of-life dates for a number of products. 15 | 16 | ## Installation 17 | 18 | ### From PyPI 19 | 20 | ```bash 21 | python3 -m pip install --upgrade norwegianblue 22 | ``` 23 | 24 | ### With [pipx][pipx] 25 | 26 | ```bash 27 | pipx install norwegianblue 28 | ``` 29 | 30 | [pipx]: https://github.com/pypa/pipx 31 | 32 | ### From [conda-forge][conda-forge] 33 | 34 | #### With [Pixi][pixi] 35 | 36 | ```bash 37 | pixi add norwegianblue 38 | ``` 39 | 40 | #### With [conda][conda] 41 | 42 | ```bash 43 | conda install --channel conda-forge norwegianblue 44 | ``` 45 | 46 | [conda-forge]: https://github.com/conda-forge/norwegianblue-feedstock 47 | [pixi]: https://pixi.sh/ 48 | [conda]: https://docs.conda.io/projects/conda/ 49 | 50 | ### With [`pixi global`][pixi-global] 51 | 52 | ```bash 53 | pixi global install norwegianblue 54 | ``` 55 | 56 | [pixi-global]: https://pixi.sh/latest/global_tools/introduction/ 57 | 58 | ### From source 59 | 60 | ```bash 61 | git clone https://github.com/hugovk/norwegianblue 62 | cd norwegianblue 63 | python3 -m pip install . 64 | ``` 65 | 66 | To install tab completion on supported Linux and macOS shells, see 67 | https://kislyuk.github.io/argcomplete/ 68 | 69 | ## Example command-line use 70 | 71 | Run `norwegianblue` or `eol`, they do the same thing. 72 | 73 | Top-level help: 74 | 75 | 79 | 80 | ```console 81 | $ eol --help 82 | usage: eol [-h] [-c {yes,no,auto}] [--clear-cache] [--show-title {yes,no,auto}] [-v] [-V] [-w] 83 | [--pretty | --md | --rst | --json | --csv | --tsv | --html | --yaml] 84 | [product ...] 85 | 86 | CLI to show end-of-life dates for a number of products, from https://endoflife.date 87 | 88 | For example: 89 | 90 | * `eol python` to see Python EOLs 91 | * `eol ubuntu` to see Ubuntu EOLs 92 | * `eol centos fedora` to see CentOS and Fedora EOLs 93 | * `eol all` or `eol` to list all available products 94 | 95 | Something missing? Please contribute! https://endoflife.date/contribute 96 | 97 | positional arguments: 98 | product product to check, or 'all' to list all available (default: 'all') 99 | 100 | options: 101 | -h, --help show this help message and exit 102 | -c, --color {yes,no,auto} 103 | colour the output (default: auto) 104 | --clear-cache clear cache before running 105 | --show-title {yes,no,auto} 106 | show or hide product title, 'auto' to show title only for multiple products (default: auto) 107 | -v, --verbose print extra messages to stderr 108 | -V, --version show program's version number and exit 109 | -w, --web open product page in web browser 110 | 111 | formatters: 112 | --pretty output in pretty (default) 113 | --md output in Markdown 114 | --rst output in reStructuredText 115 | --json output in JSON 116 | --csv output in CSV 117 | --tsv output in TSV 118 | --html output in HTML 119 | --yaml output in YAML 120 | ``` 121 | 122 | 123 | 124 | List all available products with end-of-life dates: 125 | 126 | ```console 127 | $ # eol all 128 | $ # or: 129 | ``` 130 | 131 | 134 | 135 | ```console 136 | $ eol 137 | adonisjs 138 | akeneo-pim 139 | alibaba-ack 140 | alibaba-dragonwell 141 | almalinux 142 | ... 143 | ``` 144 | 145 | 146 | 147 | Show end-of-life dates: 148 | 149 | 152 | 153 | ```console 154 | $ norwegianblue python 155 | ┌───────┬────────────┬─────────┬────────────────┬────────────┬────────────┐ 156 | │ cycle │ release │ latest │ latest release │ support │ eol │ 157 | ├───────┼────────────┼─────────┼────────────────┼────────────┼────────────┤ 158 | │ 3.14 │ 2025-10-07 │ 3.14.0 │ 2025-10-07 │ 2027-10-01 │ 2030-10-31 │ 159 | │ 3.13 │ 2024-10-07 │ 3.13.9 │ 2025-10-14 │ 2026-10-01 │ 2029-10-31 │ 160 | │ 3.12 │ 2023-10-02 │ 3.12.12 │ 2025-10-09 │ 2025-04-02 │ 2028-10-31 │ 161 | │ 3.11 │ 2022-10-24 │ 3.11.14 │ 2025-10-09 │ 2024-04-01 │ 2027-10-31 │ 162 | │ 3.10 │ 2021-10-04 │ 3.10.19 │ 2025-10-09 │ 2023-04-05 │ 2026-10-31 │ 163 | │ 3.9 │ 2020-10-05 │ 3.9.24 │ 2025-10-09 │ 2022-05-17 │ 2025-10-31 │ 164 | │ 3.8 │ 2019-10-14 │ 3.8.20 │ 2024-09-06 │ 2021-05-03 │ 2024-10-07 │ 165 | │ 3.7 │ 2018-06-27 │ 3.7.17 │ 2023-06-05 │ 2020-06-27 │ 2023-06-27 │ 166 | │ 3.6 │ 2016-12-23 │ 3.6.15 │ 2021-09-03 │ 2018-12-24 │ 2021-12-23 │ 167 | │ 3.5 │ 2015-09-13 │ 3.5.10 │ 2020-09-05 │ False │ 2020-09-30 │ 168 | │ 3.4 │ 2014-03-16 │ 3.4.10 │ 2019-03-18 │ False │ 2019-03-18 │ 169 | │ 3.3 │ 2012-09-29 │ 3.3.7 │ 2017-09-19 │ False │ 2017-09-29 │ 170 | │ 3.2 │ 2011-02-20 │ 3.2.6 │ 2014-10-12 │ False │ 2016-02-20 │ 171 | │ 2.7 │ 2010-07-03 │ 2.7.18 │ 2020-04-19 │ False │ 2020-01-01 │ 172 | │ 3.1 │ 2009-06-27 │ 3.1.5 │ 2012-04-06 │ False │ 2012-04-09 │ 173 | │ 3.0 │ 2008-12-03 │ 3.0.1 │ 2009-02-12 │ False │ 2009-06-27 │ 174 | │ 2.6 │ 2008-10-01 │ 2.6.9 │ 2013-10-29 │ False │ 2013-10-29 │ 175 | └───────┴────────────┴─────────┴────────────────┴────────────┴────────────┘ 176 | ``` 177 | 178 | 179 | 180 | You can format in Markdown, ready for pasting in GitHub issues and PRs: 181 | 182 | 185 | 186 | | cycle | release | latest | latest release | support | eol | 187 | | :---- | :--------: | :------ | :------------: | :--------: | :--------: | 188 | | 3.14 | 2025-10-07 | 3.14.0 | 2025-10-07 | 2027-10-01 | 2030-10-31 | 189 | | 3.13 | 2024-10-07 | 3.13.9 | 2025-10-14 | 2026-10-01 | 2029-10-31 | 190 | | 3.12 | 2023-10-02 | 3.12.12 | 2025-10-09 | 2025-04-02 | 2028-10-31 | 191 | | 3.11 | 2022-10-24 | 3.11.14 | 2025-10-09 | 2024-04-01 | 2027-10-31 | 192 | | 3.10 | 2021-10-04 | 3.10.19 | 2025-10-09 | 2023-04-05 | 2026-10-31 | 193 | | 3.9 | 2020-10-05 | 3.9.24 | 2025-10-09 | 2022-05-17 | 2025-10-31 | 194 | | 3.8 | 2019-10-14 | 3.8.20 | 2024-09-06 | 2021-05-03 | 2024-10-07 | 195 | | 3.7 | 2018-06-27 | 3.7.17 | 2023-06-05 | 2020-06-27 | 2023-06-27 | 196 | | 3.6 | 2016-12-23 | 3.6.15 | 2021-09-03 | 2018-12-24 | 2021-12-23 | 197 | | 3.5 | 2015-09-13 | 3.5.10 | 2020-09-05 | False | 2020-09-30 | 198 | | 3.4 | 2014-03-16 | 3.4.10 | 2019-03-18 | False | 2019-03-18 | 199 | | 3.3 | 2012-09-29 | 3.3.7 | 2017-09-19 | False | 2017-09-29 | 200 | | 3.2 | 2011-02-20 | 3.2.6 | 2014-10-12 | False | 2016-02-20 | 201 | | 2.7 | 2010-07-03 | 2.7.18 | 2020-04-19 | False | 2020-01-01 | 202 | | 3.1 | 2009-06-27 | 3.1.5 | 2012-04-06 | False | 2012-04-09 | 203 | | 3.0 | 2008-12-03 | 3.0.1 | 2009-02-12 | False | 2009-06-27 | 204 | | 2.6 | 2008-10-01 | 2.6.9 | 2013-10-29 | False | 2013-10-29 | 205 | 206 | 207 | 208 | With options: 209 | 210 | 213 | 214 | ```console 215 | $ eol nodejs --rst 216 | .. table:: 217 | 218 | ============================================================================================== ============ ========== ================ ============ ============ ================== 219 | cycle release latest latest release support eol extended support 220 | ============================================================================================== ============ ========== ================ ============ ============ ================== 221 | 24 LTS 2025-05-06 24.10.0 2025-10-08 2026-10-20 2028-04-30 False 222 | 23 2024-10-16 23.11.1 2025-05-14 2025-04-01 2025-06-01 False 223 | 22 LTS 2024-04-24 22.20.0 2025-09-24 2025-10-21 2027-04-30 False 224 | 21 2023-10-17 21.7.3 2024-04-10 2024-04-01 2024-06-01 False 225 | 20 LTS 2023-04-18 20.19.5 2025-09-03 2024-10-22 2026-04-30 False 226 | 19 2022-10-18 19.9.0 2023-04-10 2023-04-01 2023-06-01 False 227 | 18 LTS 2022-04-19 18.20.8 2025-03-27 2023-10-18 2025-04-30 True 228 | 17 2021-10-19 17.9.1 2022-06-01 2022-04-01 2022-06-01 False 229 | 16 LTS 2021-04-20 16.20.2 2023-08-09 2022-10-18 2023-09-11 True 230 | 15 2020-10-20 15.14.0 2021-04-06 2021-04-01 2021-06-01 False 231 | 14 LTS 2020-04-21 14.21.3 2023-02-16 2021-10-19 2023-04-30 True 232 | 13 2019-10-22 13.14.0 2020-04-29 2020-04-01 2020-06-01 False 233 | 12 LTS 2019-04-23 12.22.12 2022-04-05 2020-10-20 2022-04-30 True 234 | 11 2018-10-23 11.15.0 2019-04-30 2019-04-01 2019-06-30 False 235 | 10 LTS 2018-04-24 10.24.1 2021-04-06 2020-05-19 2021-04-30 False 236 | 9 2017-10-31 9.11.2 2018-06-12 2018-06-30 2018-06-30 False 237 | 8 LTS 2017-05-30 8.17.0 2019-12-17 2019-01-01 2019-12-31 False 238 | 7 2016-10-25 7.10.1 2017-07-11 2017-06-30 2017-06-30 False 239 | 6 LTS 2016-04-26 6.17.1 2019-04-03 2018-04-30 2019-04-30 False 240 | 5 2015-10-30 5.12.0 2016-06-23 2016-06-30 2016-06-30 False 241 | 4 LTS 2015-09-09 4.9.1 2018-03-29 2017-04-01 2018-04-30 False 242 | `3 `__ 2015-08-04 3.3.1 2015-09-15 False True False 243 | `2 `__ 2015-05-04 2.5.0 2015-07-28 False True False 244 | `1 `__ 2015-01-20 1.8.4 2015-07-09 False True False 245 | ============================================================================================== ============ ========== ================ ============ ============ ================== 246 | ``` 247 | 248 | 249 | 250 | ## Example programmatic use 251 | 252 | Return values are from the JSON responses documented in the API: 253 | https://endoflife.date/docs/api/ 254 | 255 | ```python 256 | import norwegianblue 257 | 258 | # Call the API 259 | print(norwegianblue.norwegianblue()) 260 | print(norwegianblue.norwegianblue(product="ubuntu")) 261 | print(norwegianblue.norwegianblue(format="json")) 262 | ``` 263 | 264 | ## Why "Norwegian Blue"? 265 | 266 | [The Norwegian Blue has reached end-of-life.](https://youtu.be/vnciwwsvNcc) 267 | -------------------------------------------------------------------------------- /tests/data/expected_output.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | EXPECTED_HTML = """ 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
cyclecodenamereleaselatestsupporteol
22.04 LTSJammy Jellyfish2022-04-2122.042027-04-022032-04-01
21.10Impish Indri2021-10-1421.102022-07-312022-07-31
21.04Hirsute Hippo2021-04-2221.042022-01-202022-01-20
20.10Groovy Gorilla2020-10-2220.102021-07-222021-07-22
20.04 LTSFocal Fossa2020-04-2320.04.42025-04-022030-04-01
19.10Karmic Koala2019-10-1719.102020-07-062020-07-06
18.04 LTSBionic Beaver2018-04-2618.04.62023-04-022028-04-01
16.04 LTSXenial Xerus2016-04-2116.04.72021-04-022026-04-01
14.04 LTSTrusty Tahr2014-04-1714.04.62019-04-022024-04-01
90 | """ # noqa: E501 91 | 92 | EXPECTED_MD = """ 93 | | cycle | codename | release | latest | support | eol | 94 | | :-----------------------------------------------------------------| :-------------: | :--------: | :-------| :--------: | :--------: | 95 | | [22.04 LTS](https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/) | Jammy Jellyfish | 2022-04-21 | 22.04 | 2027-04-02 | 2032-04-01 | 96 | | [21.10](https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/) | Impish Indri | 2021-10-14 | 21.10 | 2022-07-31 | 2022-07-31 | 97 | | [21.04](https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/) | Hirsute Hippo | 2021-04-22 | 21.04 | 2022-01-20 | 2022-01-20 | 98 | | 20.10 | Groovy Gorilla | 2020-10-22 | 20.10 | 2021-07-22 | 2021-07-22 | 99 | | 20.04 LTS | Focal Fossa | 2020-04-23 | 20.04.4 | 2025-04-02 | 2030-04-01 | 100 | | 19.10 | Karmic Koala | 2019-10-17 | 19.10 | 2020-07-06 | 2020-07-06 | 101 | | [18.04 LTS](https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes) | Bionic Beaver | 2018-04-26 | 18.04.6 | 2023-04-02 | 2028-04-01 | 102 | | 16.04 LTS | Xenial Xerus | 2016-04-21 | 16.04.7 | 2021-04-02 | 2026-04-01 | 103 | | 14.04 LTS | Trusty Tahr | 2014-04-17 | 14.04.6 | 2019-04-02 | 2024-04-01 | 104 | """ # noqa: E501 105 | 106 | 107 | EXPECTED_MD_COLOUR = """ 108 | | cycle | codename | release | latest | support | eol | 109 | | :-----------------------------------------------------------------| :-------------: | :--------: | :-------| :--------: | :--------: | 110 | | [22.04 LTS](https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/) | Jammy Jellyfish | 2022-04-21 | 22.04 | \x1b[32m2027-04-02\x1b[0m | \x1b[32m2032-04-01\x1b[0m | 111 | | [21.10](https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/) | Impish Indri | 2021-10-14 | 21.10 | \x1b[32m2022-07-31\x1b[0m | \x1b[32m2022-07-31\x1b[0m | 112 | | [21.04](https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/) | Hirsute Hippo | 2021-04-22 | 21.04 | \x1b[33m2022-01-20\x1b[0m | \x1b[33m2022-01-20\x1b[0m | 113 | | 20.10 | Groovy Gorilla | 2020-10-22 | 20.10 | \x1b[31m2021-07-22\x1b[0m | \x1b[31m2021-07-22\x1b[0m | 114 | | 20.04 LTS | Focal Fossa | 2020-04-23 | 20.04.4 | \x1b[32m2025-04-02\x1b[0m | \x1b[32m2030-04-01\x1b[0m | 115 | | 19.10 | Karmic Koala | 2019-10-17 | 19.10 | \x1b[31m2020-07-06\x1b[0m | \x1b[31m2020-07-06\x1b[0m | 116 | | [18.04 LTS](https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes) | Bionic Beaver | 2018-04-26 | 18.04.6 | \x1b[32m2023-04-02\x1b[0m | \x1b[32m2028-04-01\x1b[0m | 117 | | 16.04 LTS | Xenial Xerus | 2016-04-21 | 16.04.7 | \x1b[31m2021-04-02\x1b[0m | \x1b[32m2026-04-01\x1b[0m | 118 | | 14.04 LTS | Trusty Tahr | 2014-04-17 | 14.04.6 | \x1b[31m2019-04-02\x1b[0m | \x1b[32m2024-04-01\x1b[0m | 119 | """ # noqa: E501 120 | 121 | EXPECTED_PRETTY_REMAINDER = """ 122 | │ cycle │ codename │ release │ latest │ support │ eol │ 123 | ├───────────┼─────────────────┼────────────┼─────────┼────────────┼────────────┤ 124 | │ \x1b]8;;https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/\x1b\\22.04 LTS\x1b]8;;\x1b\\ │ Jammy Jellyfish │ 2022-04-21 │ 22.04 │ 2027-04-02 │ 2032-04-01 │ 125 | │ \x1b]8;;https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/\x1b\\21.10\x1b]8;;\x1b\\ │ Impish Indri │ 2021-10-14 │ 21.10 │ 2022-07-31 │ 2022-07-31 │ 126 | │ \x1b]8;;https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/\x1b\\21.04\x1b]8;;\x1b\\ │ Hirsute Hippo │ 2021-04-22 │ 21.04 │ 2022-01-20 │ 2022-01-20 │ 127 | │ 20.10 │ Groovy Gorilla │ 2020-10-22 │ 20.10 │ 2021-07-22 │ 2021-07-22 │ 128 | │ 20.04 LTS │ Focal Fossa │ 2020-04-23 │ 20.04.4 │ 2025-04-02 │ 2030-04-01 │ 129 | │ 19.10 │ Karmic Koala │ 2019-10-17 │ 19.10 │ 2020-07-06 │ 2020-07-06 │ 130 | │ \x1b]8;;https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes\x1b\\18.04 LTS\x1b]8;;\x1b\\ │ Bionic Beaver │ 2018-04-26 │ 18.04.6 │ 2023-04-02 │ 2028-04-01 │ 131 | │ 16.04 LTS │ Xenial Xerus │ 2016-04-21 │ 16.04.7 │ 2021-04-02 │ 2026-04-01 │ 132 | │ 14.04 LTS │ Trusty Tahr │ 2014-04-17 │ 14.04.6 │ 2019-04-02 │ 2024-04-01 │ 133 | └───────────┴─────────────────┴────────────┴─────────┴────────────┴────────────┘ 134 | """ # noqa: E501 135 | 136 | EXPECTED_PRETTY = ( 137 | """ 138 | ┌───────────┬─────────────────┬────────────┬─────────┬────────────┬────────────┐ 139 | """.rstrip() 140 | + EXPECTED_PRETTY_REMAINDER 141 | ) 142 | 143 | EXPECTED_PRETTY_WITH_TITLE = ( 144 | """ 145 | ┌──────────────────────────────────────────────────────────────────────────────┐ 146 | │ ubuntu │ 147 | ├───────────┬─────────────────┬────────────┬─────────┬────────────┬────────────┤ 148 | """.rstrip() 149 | + EXPECTED_PRETTY_REMAINDER 150 | ) 151 | 152 | EXPECTED_RST = """ 153 | .. table:: 154 | 155 | ====================================================================== ================= ============ ========= ============ ============ 156 | cycle codename release latest support eol 157 | ====================================================================== ================= ============ ========= ============ ============ 158 | `22.04 LTS `__ Jammy Jellyfish 2022-04-21 22.04 2027-04-02 2032-04-01 159 | `21.10 `__ Impish Indri 2021-10-14 21.10 2022-07-31 2022-07-31 160 | `21.04 `__ Hirsute Hippo 2021-04-22 21.04 2022-01-20 2022-01-20 161 | 20.10 Groovy Gorilla 2020-10-22 20.10 2021-07-22 2021-07-22 162 | 20.04 LTS Focal Fossa 2020-04-23 20.04.4 2025-04-02 2030-04-01 163 | 19.10 Karmic Koala 2019-10-17 19.10 2020-07-06 2020-07-06 164 | `18.04 LTS `__ Bionic Beaver 2018-04-26 18.04.6 2023-04-02 2028-04-01 165 | 16.04 LTS Xenial Xerus 2016-04-21 16.04.7 2021-04-02 2026-04-01 166 | 14.04 LTS Trusty Tahr 2014-04-17 14.04.6 2019-04-02 2024-04-01 167 | ====================================================================== ================= ============ ========= ============ ============ 168 | """ # noqa: E501 W291 169 | 170 | EXPECTED_CSV = """ 171 | "cycle","codename","release","latest","support","eol","link" 172 | "22.04 LTS","Jammy Jellyfish","2022-04-21","22.04","2027-04-02","2032-04-01","https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/" 173 | "21.10","Impish Indri","2021-10-14","21.10","2022-07-31","2022-07-31","https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/" 174 | "21.04","Hirsute Hippo","2021-04-22","21.04","2022-01-20","2022-01-20","https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/" 175 | "20.10","Groovy Gorilla","2020-10-22","20.10","2021-07-22","2021-07-22", 176 | "20.04 LTS","Focal Fossa","2020-04-23","20.04.4","2025-04-02","2030-04-01", 177 | "19.10","Karmic Koala","2019-10-17","19.10","2020-07-06","2020-07-06", 178 | "18.04 LTS","Bionic Beaver","2018-04-26","18.04.6","2023-04-02","2028-04-01","https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes" 179 | "16.04 LTS","Xenial Xerus","2016-04-21","16.04.7","2021-04-02","2026-04-01", 180 | "14.04 LTS","Trusty Tahr","2014-04-17","14.04.6","2019-04-02","2024-04-01", 181 | """ 182 | 183 | EXPECTED_TSV = """ 184 | "cycle"\t"codename"\t"release"\t"latest"\t"support"\t"eol"\t"link" 185 | "22.04 LTS"\t"Jammy Jellyfish"\t"2022-04-21"\t"22.04"\t"2027-04-02"\t"2032-04-01"\t"https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/" 186 | "21.10"\t"Impish Indri"\t"2021-10-14"\t"21.10"\t"2022-07-31"\t"2022-07-31"\t"https://wiki.ubuntu.com/ImpishIndri/ReleaseNotes/" 187 | "21.04"\t"Hirsute Hippo"\t"2021-04-22"\t"21.04"\t"2022-01-20"\t"2022-01-20"\t"https://wiki.ubuntu.com/HirsuteHippo/ReleaseNotes/" 188 | "20.10"\t"Groovy Gorilla"\t"2020-10-22"\t"20.10"\t"2021-07-22"\t"2021-07-22"\t 189 | "20.04 LTS"\t"Focal Fossa"\t"2020-04-23"\t"20.04.4"\t"2025-04-02"\t"2030-04-01"\t 190 | "19.10"\t"Karmic Koala"\t"2019-10-17"\t"19.10"\t"2020-07-06"\t"2020-07-06"\t 191 | "18.04 LTS"\t"Bionic Beaver"\t"2018-04-26"\t"18.04.6"\t"2023-04-02"\t"2028-04-01"\t"https://wiki.ubuntu.com/BionicBeaver/ReleaseNotes" 192 | "16.04 LTS"\t"Xenial Xerus"\t"2016-04-21"\t"16.04.7"\t"2021-04-02"\t"2026-04-01"\t 193 | "14.04 LTS"\t"Trusty Tahr"\t"2014-04-17"\t"14.04.6"\t"2019-04-02"\t"2024-04-01"\t 194 | """ 195 | 196 | EXPECTED_MD_LOG4J = """ 197 | | cycle | release | latest | eol | 198 | | :-----| :--------: | :------| :--------: | 199 | | 2 | 2014-07-12 | 2.17.2 | False | 200 | | 2.12 | 2019-06-23 | 2.12.4 | 2021-12-14 | 201 | | 2.3 | 2015-05-09 | 2.3.2 | 2015-09-20 | 202 | | 1 | 2001-01-08 | 1.2.17 | 2015-10-15 | 203 | """ 204 | 205 | EXPECTED_MD_PYTHON = """ 206 | | cycle | release | latest | latest release | eol | 207 | | :-----| :--------: | :------| :------------: | :--------: | 208 | | 3.10 | 2021-10-04 | 3.10.5 | 2022-06-06 | 2026-10-04 | 209 | | 3.9 | 2020-10-05 | 3.9.13 | 2022-05-17 | 2025-10-05 | 210 | | 3.8 | 2019-10-14 | 3.8.13 | 2022-03-16 | 2024-10-14 | 211 | | 3.7 | 2018-06-26 | 3.7.13 | 2022-03-16 | 2023-06-27 | 212 | | 3.6 | 2016-12-22 | 3.6.15 | 2021-09-03 | 2021-12-23 | 213 | | 3.5 | 2015-09-12 | 3.5.10 | 2020-09-05 | 2020-09-13 | 214 | | 3.4 | 2014-03-15 | 3.4.10 | 2019-03-18 | 2019-03-18 | 215 | | 3.3 | 2012-09-29 | 3.3.7 | 2017-09-19 | 2017-09-29 | 216 | | 2.7 | 2010-07-03 | 2.7.18 | 2020-04-19 | 2020-01-01 | 217 | """ 218 | 219 | EXPECTED_MD_RHEL = """ 220 | | cycle | release | latest | latest release | support | eol | extended support | 221 | | :-----| :--------: | :------| :------------: | :--------: | :--------: | :--------------: | 222 | | 9 LTS | 2022-05-17 | 9.3 | 2023-11-07 | 2027-05-31 | 2032-05-31 | 2035-05-31 | 223 | | 8 LTS | 2019-05-07 | 8.9 | 2023-11-14 | 2024-05-31 | 2029-05-31 | 2032-05-31 | 224 | | 7 LTS | 2013-12-11 | 7.9 | 2020-09-29 | 2019-08-06 | 2024-06-30 | 2028-06-30 | 225 | | 6 LTS | 2010-11-09 | 6.10 | 2018-06-19 | 2016-05-10 | 2020-11-30 | 2024-06-30 | 226 | | 5 LTS | 2007-03-15 | 5.11 | 2014-09-16 | 2013-01-08 | 2017-03-31 | 2020-11-30 | 227 | | 4 | 2005-02-15 | 4.9 | 2011-02-16 | 2009-03-31 | 2012-02-29 | 2017-03-31 | 228 | """ # noqa: E501 229 | -------------------------------------------------------------------------------- /tests/test_norwegianblue.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for norwegianblue 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import json 8 | import os 9 | from pathlib import Path 10 | from typing import Any 11 | from unittest import mock 12 | 13 | import pytest 14 | from freezegun import freeze_time 15 | from termcolor import termcolor 16 | 17 | import norwegianblue 18 | from norwegianblue import _cache 19 | 20 | from .data.expected_output import ( 21 | EXPECTED_CSV, 22 | EXPECTED_HTML, 23 | EXPECTED_MD, 24 | EXPECTED_MD_COLOUR, 25 | EXPECTED_MD_LOG4J, 26 | EXPECTED_MD_PYTHON, 27 | EXPECTED_MD_RHEL, 28 | EXPECTED_PRETTY, 29 | EXPECTED_PRETTY_WITH_TITLE, 30 | EXPECTED_RST, 31 | EXPECTED_TSV, 32 | ) 33 | from .data.sample_response import ( 34 | SAMPLE_RESPONSE_ALL_JSON, 35 | SAMPLE_RESPONSE_JSON_LOG4J, 36 | SAMPLE_RESPONSE_JSON_PYTHON, 37 | SAMPLE_RESPONSE_JSON_RHEL, 38 | SAMPLE_RESPONSE_JSON_UBUNTU, 39 | ) 40 | 41 | EXPECTED_HTML_WITH_TITLE = EXPECTED_HTML.replace( 42 | "", 43 | '
\n ', 44 | ) 45 | EXPECTED_RST_WITH_TITLE = EXPECTED_RST.replace(".. table::", ".. table:: ubuntu") 46 | EXPECTED_MD_WITH_TITLE = "## ubuntu\n" + EXPECTED_MD 47 | 48 | 49 | def stub__cache_filename(*args: Any) -> Path: 50 | return Path("/this/does/not/exist") 51 | 52 | 53 | def stub__save_cache(*args) -> None: 54 | pass 55 | 56 | 57 | def mock_urllib3_response(content: str, status: int = 200) -> mock.Mock: 58 | """Helper to create a mock urllib3 response.""" 59 | response = mock.Mock() 60 | response.status = status 61 | response.data = content.encode() 62 | return response 63 | 64 | 65 | def assert_called_with_url(mock_request: mock.Mock, url: str) -> None: 66 | """Assert that urllib3.request was called once with the given URL.""" 67 | mock_request.assert_called_once_with("GET", url, headers=mock.ANY, redirect=True) 68 | 69 | 70 | class TestNorwegianBlue: 71 | def setup_method(self) -> None: 72 | # Stub caching. Caches are tested in another class. 73 | self.original__cache_filename = _cache.filename 74 | self.original__save_cache = _cache.save 75 | _cache.filename = stub__cache_filename 76 | _cache.save = stub__save_cache 77 | 78 | def teardown_method(self) -> None: 79 | # Unstub caching 80 | _cache.filename = self.original__cache_filename 81 | _cache.save = self.original__save_cache 82 | termcolor.can_colorize.cache_clear() 83 | 84 | @freeze_time("2023-11-23") 85 | @mock.patch.dict(os.environ, {"NO_COLOR": "TRUE"}) 86 | @pytest.mark.parametrize( 87 | "test_format, test_show_title, expected", 88 | [ 89 | pytest.param("csv", False, EXPECTED_CSV, id="csv"), 90 | pytest.param("csv", True, EXPECTED_CSV, id="csv"), 91 | pytest.param("html", False, EXPECTED_HTML, id="html"), 92 | pytest.param("html", True, EXPECTED_HTML_WITH_TITLE, id="html"), 93 | pytest.param("markdown", False, EXPECTED_MD, id="markdown"), 94 | pytest.param("markdown", True, EXPECTED_MD_WITH_TITLE, id="markdown"), 95 | pytest.param("pretty", False, EXPECTED_PRETTY, id="pretty"), 96 | pytest.param("pretty", True, EXPECTED_PRETTY_WITH_TITLE, id="pretty"), 97 | pytest.param("rst", False, EXPECTED_RST, id="rst"), 98 | pytest.param("rst", True, EXPECTED_RST_WITH_TITLE, id="rst"), 99 | pytest.param("tsv", False, EXPECTED_TSV, id="tsv"), 100 | pytest.param("tsv", True, EXPECTED_TSV, id="tsv"), 101 | ], 102 | ) 103 | @mock.patch("urllib3.request") 104 | def test_norwegianblue_formats( 105 | self, mock_request, test_format: str, test_show_title: bool, expected: str 106 | ) -> None: 107 | # Arrange 108 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_JSON_UBUNTU) 109 | 110 | # Act 111 | output = norwegianblue.norwegianblue( 112 | product="ubuntu", format=test_format, show_title=test_show_title 113 | ) 114 | 115 | # Assert 116 | assert output.strip() == expected.strip() 117 | assert_called_with_url(mock_request, "https://endoflife.date/api/ubuntu.json") 118 | 119 | @freeze_time("2023-11-23") 120 | @mock.patch("urllib3.request") 121 | def test_norwegianblue_no_format(self, mock_request) -> None: 122 | # Arrange 123 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_JSON_UBUNTU) 124 | 125 | # Act 126 | output = norwegianblue.norwegianblue(product="ubuntu", format=None) 127 | 128 | # Assert 129 | assert output[0] == { 130 | "cycle": "22.04", 131 | "codename": "Jammy Jellyfish", 132 | "support": "2027-04-02", 133 | "eol": "2032-04-01", 134 | "lts": True, 135 | "latest": "22.04", 136 | "link": "https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/", 137 | "releaseDate": "2022-04-21", 138 | } 139 | assert_called_with_url(mock_request, "https://endoflife.date/api/ubuntu.json") 140 | 141 | @mock.patch.dict(os.environ, {"NO_COLOR": "TRUE"}) 142 | @pytest.mark.parametrize( 143 | "test_product, sample_response, expected", 144 | [ 145 | pytest.param( 146 | "log4j", SAMPLE_RESPONSE_JSON_LOG4J, EXPECTED_MD_LOG4J, id="log4j" 147 | ), 148 | pytest.param( 149 | "python", SAMPLE_RESPONSE_JSON_PYTHON, EXPECTED_MD_PYTHON, id="python" 150 | ), 151 | pytest.param( 152 | "rhel", SAMPLE_RESPONSE_JSON_RHEL, EXPECTED_MD_RHEL, id="rhel" 153 | ), 154 | ], 155 | ) 156 | @mock.patch("urllib3.request") 157 | def test_norwegianblue_products( 158 | self, mock_request, test_product: str, sample_response: str, expected: str 159 | ) -> None: 160 | """Test other headers not present in Ubuntu: 161 | * rename of releaseDate and latestReleaseDate headers (Python) 162 | * skip of cycleShortHand (Log4j)""" 163 | # Arrange 164 | mock_request.return_value = mock_urllib3_response(sample_response) 165 | 166 | # Act 167 | output = norwegianblue.norwegianblue(product=test_product, format="markdown") 168 | 169 | # Assert 170 | assert output.strip() == expected.strip() 171 | assert_called_with_url( 172 | mock_request, f"https://endoflife.date/api/{test_product}.json" 173 | ) 174 | 175 | @mock.patch.dict(os.environ, {"NO_COLOR": "TRUE"}) 176 | @mock.patch("urllib3.request") 177 | def test_norwegianblue_no_color(self, mock_request) -> None: 178 | # Arrange 179 | expected = EXPECTED_MD 180 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_JSON_UBUNTU) 181 | 182 | # Act 183 | output = norwegianblue.norwegianblue(product="ubuntu", format="markdown") 184 | 185 | # Assert 186 | assert output.strip() == expected.strip() 187 | assert_called_with_url(mock_request, "https://endoflife.date/api/ubuntu.json") 188 | 189 | @freeze_time("2021-09-13") 190 | @mock.patch.dict(os.environ, {"FORCE_COLOR": "TRUE"}) 191 | @mock.patch("urllib3.request") 192 | def test_norwegianblue_force_color(self, mock_request) -> None: 193 | # Arrange 194 | expected = EXPECTED_MD_COLOUR 195 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_JSON_UBUNTU) 196 | 197 | # Act 198 | output = norwegianblue.norwegianblue(product="ubuntu", format="md") 199 | 200 | # Assert 201 | assert output.strip() == expected.strip() 202 | assert_called_with_url(mock_request, "https://endoflife.date/api/ubuntu.json") 203 | 204 | @mock.patch("urllib3.request") 205 | def test_norwegianblue_json(self, mock_request) -> None: 206 | # Arrange 207 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_JSON_UBUNTU) 208 | 209 | # Act 210 | output = norwegianblue.norwegianblue(product="ubuntu", format="json") 211 | 212 | # Assert 213 | assert json.loads(output) == json.loads(SAMPLE_RESPONSE_JSON_UBUNTU) 214 | assert_called_with_url(mock_request, "https://endoflife.date/api/ubuntu.json") 215 | 216 | @freeze_time("2021-06-15") 217 | @mock.patch.dict(os.environ, {"FORCE_COLOR": "TRUE"}) 218 | def test__colourify(self) -> None: 219 | # Arrange 220 | data = [ 221 | { 222 | "cycle": "21.04 LTS", 223 | "release": "2021-04-22", 224 | "support": "2022-01-01", 225 | "eol": "2022-01-01", 226 | }, 227 | { 228 | "cycle": "20.10 LTS", 229 | "release": "2020-10-22", 230 | "support": "2021-07-07", 231 | "eol": "2021-07-07", 232 | }, 233 | { 234 | "cycle": "19.10", 235 | "release": "2019-10-17", 236 | "support": "2020-07-06", 237 | "eol": "2020-07-06", 238 | }, 239 | { 240 | "cycle": "18.04 LTS", 241 | "release": "2018-04-26", 242 | "support": "2020-09-30", 243 | "eol": "2023-04-02", 244 | }, 245 | ] 246 | expected = [ 247 | { 248 | "cycle": "21.04 LTS", 249 | "release": "2021-04-22", 250 | "support": "\x1b[32m2022-01-01\x1b[0m", # green 251 | "eol": "\x1b[32m2022-01-01\x1b[0m", # green 252 | }, 253 | { 254 | "cycle": "20.10 LTS", 255 | "release": "2020-10-22", 256 | "support": "\x1b[33m2021-07-07\x1b[0m", # yellow 257 | "eol": "\x1b[33m2021-07-07\x1b[0m", # yellow 258 | }, 259 | { 260 | "cycle": "19.10", 261 | "release": "2019-10-17", 262 | "support": "\x1b[31m2020-07-06\x1b[0m", # red 263 | "eol": "\x1b[31m2020-07-06\x1b[0m", # red 264 | }, 265 | { 266 | "cycle": "18.04 LTS", 267 | "release": "2018-04-26", 268 | "support": "\x1b[31m2020-09-30\x1b[0m", # red 269 | "eol": "\x1b[32m2023-04-02\x1b[0m", # green 270 | }, 271 | ] 272 | 273 | # Act 274 | output = norwegianblue._colourify(data) 275 | 276 | # Assert 277 | assert output == expected 278 | 279 | @freeze_time("2021-06-16") 280 | @mock.patch.dict(os.environ, {"FORCE_COLOR": "TRUE"}) 281 | def test__colourify_boolean_support(self) -> None: 282 | # Arrange 283 | data = [ 284 | { 285 | "cycle": "5.x", 286 | "eol": False, 287 | "support": True, 288 | }, 289 | { 290 | "cycle": "4.x", 291 | "eol": "2022-11-01", 292 | "support": False, 293 | }, 294 | { 295 | "cycle": "3.x", 296 | "eol": "2019-07-24", 297 | "support": False, 298 | }, 299 | ] 300 | expected = [ 301 | { 302 | "cycle": "5.x", 303 | "eol": "\x1b[32mFalse\x1b[0m", # green 304 | "support": "\x1b[32mTrue\x1b[0m", # green 305 | }, 306 | { 307 | "cycle": "4.x", 308 | "eol": "\x1b[32m2022-11-01\x1b[0m", # green 309 | "support": "\x1b[31mFalse\x1b[0m", # red 310 | }, 311 | { 312 | "cycle": "3.x", 313 | "eol": "\x1b[31m2019-07-24\x1b[0m", # red 314 | "support": "\x1b[31mFalse\x1b[0m", # red 315 | }, 316 | ] 317 | 318 | # Act 319 | output = norwegianblue._colourify(data) 320 | 321 | # Assert 322 | assert output == expected 323 | 324 | @mock.patch.dict(os.environ, {"FORCE_COLOR": "TRUE"}) 325 | def test__colourify_boolean_eol(self) -> None: 326 | # Arrange 327 | data = [ 328 | {"cycle": "1.15", "release": "2020-08-11", "eol": False}, 329 | {"cycle": "1.14", "release": "2020-02-25", "eol": True}, 330 | ] 331 | expected = [ 332 | # green 333 | {"cycle": "1.15", "release": "2020-08-11", "eol": "\x1b[32mFalse\x1b[0m"}, 334 | # red 335 | {"cycle": "1.14", "release": "2020-02-25", "eol": "\x1b[31mTrue\x1b[0m"}, 336 | ] 337 | 338 | # Act 339 | output = norwegianblue._colourify(data) 340 | 341 | # Assert 342 | assert output == expected 343 | 344 | @freeze_time("2023-11-23") 345 | @mock.patch.dict(os.environ, {"FORCE_COLOR": "TRUE"}) 346 | def test__colourify_boolean_discontinued(self) -> None: 347 | # Arrange 348 | data = [ 349 | { 350 | "cycle": "iPhone 5C", 351 | "discontinued": "2025-09-09", 352 | "eol": True, 353 | }, 354 | { 355 | "cycle": "iPhone 5S", 356 | "discontinued": "2016-03-21", 357 | "eol": True, 358 | }, 359 | { 360 | "cycle": "iPhone 6S / 6S Plus", 361 | "discontinued": "2018-09-12", 362 | "eol": False, 363 | }, 364 | { 365 | "cycle": "iPhone XR", 366 | "discontinued": False, 367 | "eol": False, 368 | }, 369 | { 370 | "cycle": "iPhone 11 Pro / 11 Pro Max", 371 | "discontinued": "2020-10-13", 372 | "eol": False, 373 | }, 374 | { 375 | "cycle": "iPhone 12 Mini / 12 Pro Max", 376 | "discontinued": True, 377 | "eol": False, 378 | }, 379 | ] 380 | 381 | expected = [ 382 | { 383 | "cycle": "iPhone 5C", 384 | "discontinued": "\x1b[32m2025-09-09\x1b[0m", # green 385 | "eol": "\x1b[31mTrue\x1b[0m", # red 386 | }, 387 | { 388 | "cycle": "iPhone 5S", 389 | "discontinued": "\x1b[31m2016-03-21\x1b[0m", # red 390 | "eol": "\x1b[31mTrue\x1b[0m", # red 391 | }, 392 | { 393 | "cycle": "iPhone 6S / 6S Plus", 394 | "discontinued": "\x1b[31m2018-09-12\x1b[0m", # red 395 | "eol": "\x1b[32mFalse\x1b[0m", # green 396 | }, 397 | { 398 | "cycle": "iPhone XR", 399 | "discontinued": "\x1b[32mFalse\x1b[0m", # red 400 | "eol": "\x1b[32mFalse\x1b[0m", # green 401 | }, 402 | { 403 | "cycle": "iPhone 11 Pro / 11 Pro Max", 404 | "discontinued": "\x1b[31m2020-10-13\x1b[0m", # red 405 | "eol": "\x1b[32mFalse\x1b[0m", # green 406 | }, 407 | { 408 | "cycle": "iPhone 12 Mini / 12 Pro Max", 409 | "discontinued": "\x1b[31mTrue\x1b[0m", # red 410 | "eol": "\x1b[32mFalse\x1b[0m", # green 411 | }, 412 | ] 413 | 414 | # Act 415 | output = norwegianblue._colourify(data) 416 | 417 | # Assert 418 | assert output == expected 419 | 420 | @mock.patch("urllib3.request") 421 | def test_all_products(self, mock_request) -> None: 422 | # Arrange 423 | expected = """alpine\namazon-linux\nandroid\nbootstrap\ncentos""" 424 | mock_request.return_value = mock_urllib3_response(SAMPLE_RESPONSE_ALL_JSON) 425 | 426 | # Act 427 | output = norwegianblue.norwegianblue(product="all") 428 | 429 | # Assert 430 | assert output == expected 431 | assert_called_with_url(mock_request, "https://endoflife.date/api/all.json") 432 | 433 | @pytest.mark.parametrize( 434 | "product, expected", 435 | [ 436 | ( 437 | "androd", 438 | r"Product 'androd' not found, run 'eol all' for list\. " 439 | r"Did you mean: 'android'?", 440 | ), 441 | ("julia", r"Product 'julia' not found, run 'eol all' for list\."), 442 | ], 443 | ) 444 | @mock.patch("urllib3.request") 445 | def test_404(self, mock_request, product, expected) -> None: 446 | # Arrange 447 | # First call returns 404 for the product, second call returns all products 448 | mock_response_404 = mock.Mock() 449 | mock_response_404.status = 404 450 | 451 | mock_response_all = mock.Mock() 452 | mock_response_all.status = 200 453 | mock_response_all.data = SAMPLE_RESPONSE_ALL_JSON.encode() 454 | 455 | mock_request.side_effect = [mock_response_404, mock_response_all] 456 | 457 | # Act / Assert 458 | with pytest.raises(ValueError, match=expected): 459 | norwegianblue.norwegianblue(product=product) 460 | 461 | # Verify both URLs were called 462 | assert mock_request.call_count == 2 463 | url1 = mock_request.call_args_list[0].args[1] 464 | url2 = mock_request.call_args_list[1].args[1] 465 | assert url1 == f"https://endoflife.date/api/{product}.json" 466 | assert url2 == "https://endoflife.date/api/all.json" 467 | 468 | def test_norwegianblue_norwegianblue(self) -> None: 469 | # Act 470 | output = norwegianblue.norwegianblue(product="norwegianblue") 471 | 472 | # Assert 473 | assert "Norwegian Blue" in output 474 | 475 | def test__ltsify(self) -> None: 476 | # Arrange 477 | data = [ 478 | {"cycle": "5.3", "eol": "2022-01-01", "lts": False}, 479 | {"cycle": "4.4", "eol": "2023-11-21", "lts": True}, 480 | {"cycle": "4.3", "eol": "2020-07-01", "lts": False}, 481 | {"cycle": "3.4", "eol": "2021-11-01", "lts": True}, 482 | ] 483 | expected = [ 484 | {"cycle": "5.3", "eol": "2022-01-01"}, 485 | {"cycle": "4.4 LTS", "eol": "2023-11-21"}, 486 | {"cycle": "4.3", "eol": "2020-07-01"}, 487 | {"cycle": "3.4 LTS", "eol": "2021-11-01"}, 488 | ] 489 | 490 | # Act 491 | output = norwegianblue._ltsify(data) 492 | 493 | # Assert 494 | assert output == expected 495 | --------------------------------------------------------------------------------
ubuntu