├── 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 | [](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 | [](https://pypi.org/project/norwegianblue/) 4 | [](https://pypi.org/project/norwegianblue/) 5 | [](https://pypistats.org/packages/norwegianblue) 6 | [](https://github.com/hugovk/norwegianblue/actions) 7 | [](https://codecov.io/gh/hugovk/norwegianblue) 8 | [](LICENSE.txt) 9 | [](https://github.com/psf/black) 10 | 11 |

| cycle | 8 |codename | 9 |release | 10 |latest | 11 |support | 12 |eol | 13 |
|---|---|---|---|---|---|
| 22.04 LTS | 18 |Jammy Jellyfish | 19 |2022-04-21 | 20 |22.04 | 21 |2027-04-02 | 22 |2032-04-01 | 23 |
| 21.10 | 26 |Impish Indri | 27 |2021-10-14 | 28 |21.10 | 29 |2022-07-31 | 30 |2022-07-31 | 31 |
| 21.04 | 34 |Hirsute Hippo | 35 |2021-04-22 | 36 |21.04 | 37 |2022-01-20 | 38 |2022-01-20 | 39 |
| 20.10 | 42 |Groovy Gorilla | 43 |2020-10-22 | 44 |20.10 | 45 |2021-07-22 | 46 |2021-07-22 | 47 |
| 20.04 LTS | 50 |Focal Fossa | 51 |2020-04-23 | 52 |20.04.4 | 53 |2025-04-02 | 54 |2030-04-01 | 55 |
| 19.10 | 58 |Karmic Koala | 59 |2019-10-17 | 60 |19.10 | 61 |2020-07-06 | 62 |2020-07-06 | 63 |
| 18.04 LTS | 66 |Bionic Beaver | 67 |2018-04-26 | 68 |18.04.6 | 69 |2023-04-02 | 70 |2028-04-01 | 71 |
| 16.04 LTS | 74 |Xenial Xerus | 75 |2016-04-21 | 76 |16.04.7 | 77 |2021-04-02 | 78 |2026-04-01 | 79 |
| 14.04 LTS | 82 |Trusty Tahr | 83 |2014-04-17 | 84 |14.04.6 | 85 |2019-04-02 | 86 |2024-04-01 | 87 |