From da5d7fa855995326f68c95d2e205553705de9190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Pi=C3=A9dallu?= Date: Sat, 16 Nov 2024 15:51:47 +0100 Subject: [PATCH 1/4] Add typing to lib --- lib/lib_package_linter.py | 86 +++++++++++++++++++++------------------ lib/print.py | 6 ++- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/lib/lib_package_linter.py b/lib/lib_package_linter.py index 660d02c..7697de8 100644 --- a/lib/lib_package_linter.py +++ b/lib/lib_package_linter.py @@ -2,8 +2,9 @@ import os import time -import urllib +import urllib.request import jsonschema +from typing import Any, Callable, Generator, Tuple from lib.print import _print @@ -25,10 +26,13 @@ class c: class TestReport: - def __init__(self, message): + style: str + test_name: str + + def __init__(self, message: str) -> None: self.message = message - def display(self, prefix=""): + def display(self, prefix: str = "") -> None: _print(prefix + self.style % self.message) @@ -52,38 +56,38 @@ class Critical(TestReport): style = c.FAIL + " ✘✘✘ %s" + c.END -def report_warning_not_reliable(str): - _print(c.MAYBE_FAIL + "?", str, c.END) +def report_warning_not_reliable(message: str) -> None: + _print(c.MAYBE_FAIL + "?", message, c.END) -def print_happy(str): - _print(c.OKGREEN + " ☺ ", str, "♥") +def print_happy(message: str) -> None: + _print(c.OKGREEN + " ☺ ", message, "♥") -def urlopen(url): +def urlopen(url: str) -> tuple[int, str]: try: conn = urllib.request.urlopen(url) except urllib.error.HTTPError as e: - return {"content": "", "code": e.code} + return e.code, "" except urllib.error.URLError as e: _print("Could not fetch %s : %s" % (url, e)) - return {"content": "", "code": 0} - return {"content": conn.read().decode("UTF8"), "code": 200} + return 0, "" + return 200, conn.read().decode("UTF8") -def file_exists(file_path): +def file_exists(file_path: str) -> bool: return os.path.isfile(file_path) and os.stat(file_path).st_size > 0 -def cache_file(cachefile: str, ttl_s: int): - def cache_is_fresh(): +def cache_file(cachefile: str, ttl_s: int) -> Callable[[Callable[..., str]], Callable[..., str]]: + def cache_is_fresh() -> bool: return ( os.path.exists(cachefile) and time.time() - os.path.getmtime(cachefile) < ttl_s ) - def decorator(function): - def wrapper(*args, **kwargs): + def decorator(function: Callable[..., str]) -> Callable[..., str]: + def wrapper(*args: Any, **kwargs: Any) -> str: if not cache_is_fresh(): with open(cachefile, "w+") as outfile: outfile.write(function(*args, **kwargs)) @@ -95,38 +99,35 @@ def wrapper(*args, **kwargs): @cache_file(".spdx_licenses", 3600) -def spdx_licenses(): - return urlopen("https://spdx.org/licenses/")["content"] +def spdx_licenses() -> str: + return urlopen("https://spdx.org/licenses/")[1] @cache_file(".manifest.v2.schema.json", 3600) -def manifest_v2_schema(): - return urlopen( - "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/manifest.v2.schema.json" - )["content"] +def manifest_v2_schema() -> str: + url = "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/manifest.v2.schema.json" + return urlopen(url)[1] @cache_file(".tests.v1.schema.json", 3600) -def tests_v1_schema(): - return urlopen( - "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/tests.v1.schema.json" - )["content"] +def tests_v1_schema() -> str: + url = "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/tests.v1.schema.json" + return urlopen(url)[1] @cache_file(".config_panel.v1.schema.json", 3600) -def config_panel_v1_schema(): - return urlopen( - "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/config_panel.v1.schema.json" - )["content"] +def config_panel_v1_schema() -> str: + url = "https://raw.githubusercontent.com/YunoHost/apps/master/schemas/config_panel.v1.schema.json" + return urlopen(url)[1] -def validate_schema(name: str, schema, data): +def validate_schema(name: str, schema: dict[str, Any], data: dict[str, Any]) -> Generator[Info, None, None]: v = jsonschema.Draft7Validator(schema) for error in v.iter_errors(data): try: error_path = " > ".join(error.path) - except: + except TypeError: error_path = str(error.path) yield Info( @@ -134,8 +135,10 @@ def validate_schema(name: str, schema, data): ) -tests = {} -tests_reports = { +TestFn = Callable[["TestSuite"], Generator[TestReport, None, None]] + +tests: dict[str, list[tuple[TestFn, Any]]] = {} +tests_reports: dict[str, list[Any]] = { "success": [], "info": [], "warning": [], @@ -144,8 +147,8 @@ def validate_schema(name: str, schema, data): } -def test(**kwargs): - def decorator(f): +def test(**kwargs: Any) -> Callable[[TestFn], TestFn]: + def decorator(f: TestFn) -> TestFn: clsname = f.__qualname__.split(".")[0] if clsname not in tests: tests[clsname] = [] @@ -156,7 +159,10 @@ def decorator(f): class TestSuite: - def run_tests(self): + name: str + test_suite_name: str + + def run_tests(self) -> None: reports = [] @@ -174,7 +180,7 @@ def run_tests(self): # Display part - def report_type(report): + def report_type(report: TestReport) -> str: return report.__class__.__name__.lower() if any(report_type(r) in ["warning", "error", "critical"] for r in reports): @@ -198,11 +204,11 @@ def report_type(report): for report in reports: tests_reports[report_type(report)].append((report.test_name, report)) - def run_single_test(self, test): + def run_single_test(self, test: TestFn) -> None: reports = list(test(self)) - def report_type(report): + def report_type(report: TestReport) -> str: return report.__class__.__name__.lower() for report in reports: diff --git a/lib/print.py b/lib/print.py index afff1f4..af736ca 100644 --- a/lib/print.py +++ b/lib/print.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 +from typing import Any + output = "plain" -def _print(*args, **kwargs): +def _print(*args: Any, **kwargs: Any) -> None: if not is_json_output(): print(*args, **kwargs) -def set_output_json(): +def set_output_json() -> None: global output output = "json" From 0b606f3eea378bddb08ac115092e2e3ae00b673e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Pi=C3=A9dallu?= Date: Sat, 16 Nov 2024 15:52:00 +0100 Subject: [PATCH 2/4] we changed the behaviour of urlopen() for easier typing --- tests/test_catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 452359b..55b5053 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -131,10 +131,10 @@ def is_in_github_org(self): else: def is_in_github_org(): - return urlopen(repo_org)["code"] != 404 + return urlopen(repo_org)[0] != 404 def is_in_brique_org(): - return urlopen(repo_brique)["code"] != 404 + return urlopen(repo_brique)[0] != 404 if not is_in_github_org() and not is_in_brique_org(): yield Info( From 026ac87ff68377ebbbc2dcf3885dad326de4908a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Pi=C3=A9dallu?= Date: Sat, 16 Nov 2024 16:50:32 +0100 Subject: [PATCH 3/4] Fix most typing issues --- lib/lib_package_linter.py | 9 ++- package_linter.py | 1 - tests/test_app.py | 90 ++++++++++++-------------- tests/test_catalog.py | 45 ++++++------- tests/test_configurations.py | 53 +++++++--------- tests/test_manifest.py | 62 ++++++++---------- tests/test_scripts.py | 118 +++++++++++++++++------------------ 7 files changed, 174 insertions(+), 204 deletions(-) diff --git a/lib/lib_package_linter.py b/lib/lib_package_linter.py index 7697de8..61647fe 100644 --- a/lib/lib_package_linter.py +++ b/lib/lib_package_linter.py @@ -3,8 +3,9 @@ import os import time import urllib.request +from typing import Any, Callable, Generator, TypeVar + import jsonschema -from typing import Any, Callable, Generator, Tuple from lib.print import _print @@ -135,7 +136,8 @@ def validate_schema(name: str, schema: dict[str, Any], data: dict[str, Any]) -> ) -TestFn = Callable[["TestSuite"], Generator[TestReport, None, None]] +TestResult = Generator[TestReport, None, None] +TestFn = Callable[[Any], TestResult] tests: dict[str, list[tuple[TestFn, Any]]] = {} tests_reports: dict[str, list[Any]] = { @@ -147,6 +149,7 @@ def validate_schema(name: str, schema: dict[str, Any], data: dict[str, Any]) -> } + def test(**kwargs: Any) -> Callable[[TestFn], TestFn]: def decorator(f: TestFn) -> TestFn: clsname = f.__qualname__.split(".")[0] @@ -158,7 +161,7 @@ def decorator(f: TestFn) -> TestFn: return decorator -class TestSuite: +class TestSuite(): name: str test_suite_name: str diff --git a/package_linter.py b/package_linter.py index cc1397f..4d01f1b 100755 --- a/package_linter.py +++ b/package_linter.py @@ -6,7 +6,6 @@ from lib.lib_package_linter import c from lib.print import _print, set_output_json - from tests.test_app import App diff --git a/tests/test_app.py b/tests/test_app.py index 721e025..59cc5d4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,22 +5,14 @@ import os import subprocess import sys - import tomllib -from lib.lib_package_linter import ( - Error, - Info, - Success, - TestSuite, - Warning, - config_panel_v1_schema, - file_exists, - test, - tests_reports, - validate_schema, -) -from lib.print import _print, is_json_output +from typing import Generator +from lib.lib_package_linter import (Error, Info, Success, TestResult, + TestSuite, Warning, config_panel_v1_schema, + file_exists, test, tests_reports, + validate_schema) +from lib.print import _print, is_json_output from tests.test_catalog import AppCatalog from tests.test_configurations import Configurations from tests.test_manifest import Manifest @@ -236,14 +228,14 @@ class App(TestSuite): - def __init__(self, path): + def __init__(self, path: str) -> None: _print(" Analyzing app %s ..." % path) self.path = path self.manifest_ = Manifest(self.path) self.manifest = self.manifest_.manifest self.scripts = { - f: Script(self.path, f, self.manifest.get("id")) for f in scriptnames + f: Script(self.path, f, self.manifest.get("id", "")) for f in scriptnames } self.configurations = Configurations(self) self.app_catalog = AppCatalog(self.manifest["id"]) @@ -252,7 +244,7 @@ def __init__(self, path): _print() - def analyze(self): + def analyze(self) -> None: self.manifest_.run_tests() @@ -265,7 +257,7 @@ def analyze(self): self.report() - def report(self): + def report(self) -> None: _print(" =======") @@ -293,7 +285,7 @@ def report(self): if tests_reports["error"] or tests_reports["critical"]: sys.exit(1) - def qualify_for_level_7(self): + def qualify_for_level_7(self) -> Generator[Success, None, None]: if tests_reports["critical"]: _print(" There are some critical issues in this app :(") @@ -310,7 +302,7 @@ def qualify_for_level_7(self): "Not even a warning! Congratz and thank you for keeping this package up to date with good practices! This app qualifies for level 7!" ) - def qualify_for_level_8(self): + def qualify_for_level_8(self) -> Generator[Success, None, None]: successes = [test.split(".")[1] for test, _ in tests_reports["success"]] @@ -338,7 +330,7 @@ def qualify_for_level_8(self): "The app is maintained and long-term good quality, and therefore qualifies for level 8!" ) - def qualify_for_level_9(self): + def qualify_for_level_9(self) -> Generator[Success, None, None]: if self.app_catalog.catalog_infos.get("high_quality", False): yield Success("The app is flagged as high-quality in the app catalog") @@ -354,7 +346,7 @@ def qualify_for_level_9(self): ######################################### @test() - def mandatory_scripts(app): + def mandatory_scripts(app) -> TestResult: filenames = ( "LICENSE", "README.md", @@ -375,7 +367,7 @@ def mandatory_scripts(app): yield Error("You should put an actual license in LICENSE...") @test() - def doc_dir(app): + def doc_dir(app) -> TestResult: if not os.path.exists(app.path + "/doc"): yield Error( @@ -416,7 +408,7 @@ def doc_dir(app): break @test() - def doc_dir_v2(app): + def doc_dir_v2(app) -> TestResult: if os.path.exists(app.path + "/doc") and not os.path.exists( app.path + "/doc/DESCRIPTION.md" @@ -458,7 +450,7 @@ def doc_dir_v2(app): ) @test() - def admin_has_to_finish_install(app): + def admin_has_to_finish_install(app) -> TestResult: # Mywebapp has a legit use case for this if app.manifest.get("id") == "my_webapp": @@ -471,7 +463,7 @@ def admin_has_to_finish_install(app): ) @test() - def disclaimer_wording_or_placeholder(app): + def disclaimer_wording_or_placeholder(app) -> TestResult: if os.path.exists(app.path + "/doc"): if ( os.system( @@ -495,7 +487,7 @@ def disclaimer_wording_or_placeholder(app): ) @test() - def custom_python_version(app): + def custom_python_version(app) -> TestResult: cmd = f"grep -q -IhEr '^[^#]*install_python' '{app.path}/scripts/'" if os.system(cmd) == 0: @@ -504,7 +496,7 @@ def custom_python_version(app): ) @test() - def change_url_script(app): + def change_url_script(app) -> TestResult: keyandargs = copy.deepcopy(app.manifest["install"]) for key, infos in keyandargs.items(): @@ -519,7 +511,7 @@ def change_url_script(app): ) @test() - def config_panel(app): + def config_panel(app) -> TestResult: if file_exists(app.path + "/config_panel.json"): yield Error( @@ -568,7 +560,7 @@ def config_panel(app): ) @test() - def badges_in_readme(app): + def badges_in_readme(app) -> TestResult: id_ = app.manifest["id"] @@ -587,12 +579,12 @@ def badges_in_readme(app): ) @test() - def remaining_replacebyyourapp(self): + def remaining_replacebyyourapp(self) -> TestResult: if os.system("grep -I -qr 'REPLACEBYYOURAPP' %s 2>/dev/null" % self.path) == 0: yield Error("You should replace all occurences of REPLACEBYYOURAPP.") @test() - def supervisor_usage(self): + def supervisor_usage(self) -> TestResult: if ( os.system(r"grep -I -qr '^\s*supervisorctl' %s 2>/dev/null" % self.path) == 0 @@ -602,7 +594,7 @@ def supervisor_usage(self): ) @test() - def bad_encoding(self): + def bad_encoding(self) -> TestResult: cmd = ( "file --mime-encoding $(find %s/ -type f) | grep 'iso-8859-1\\|unknown-8bit' || true" @@ -632,7 +624,7 @@ def bad_encoding(self): ####################################### @test() - def helpers_now_official(app): + def helpers_now_official(app) -> TestResult: cmd = "grep -IhEro 'ynh_\\w+ *\\( *\\)' '%s/scripts' | tr -d '() '" % app.path custom_helpers = ( @@ -648,7 +640,7 @@ def helpers_now_official(app): ) @test() - def git_clone_usage(app): + def git_clone_usage(app) -> TestResult: cmd = ( f"grep -I 'git clone' '{app.path}'/scripts/install '{app.path}'/scripts/_common.sh 2>/dev/null" r" | grep -qv 'xxenv\|rbenv\|oracledb'" @@ -659,7 +651,7 @@ def git_clone_usage(app): ) @test() - def helpers_version_requirement(app): + def helpers_version_requirement(app) -> TestResult: cmd = "grep -IhEro 'ynh_\\w+ *\\( *\\)' '%s/scripts' | tr -d '() '" % app.path custom_helpers = ( @@ -679,14 +671,14 @@ def helpers_version_requirement(app): manifest_req = [int(i) for i in yunohost_version_req.split(".")] + [0, 0, 0] - def validate_version_requirement(helper_req): + def validate_version_requirement(helper_req: str) -> bool: if helper_req == "": return True - helper_req = [int(i) for i in helper_req.split(".")] - for i in range(0, len(helper_req)): - if helper_req[i] == manifest_req[i]: + helper_req_list = [int(i) for i in helper_req.split(".")] + for i in range(0, len(helper_req_list)): + if helper_req_list[i] == manifest_req[i]: continue - return helper_req[i] <= manifest_req[i] + return helper_req_list[i] <= manifest_req[i] return True for helper in [h for h in helpers_used if h in official_helpers.keys()]: @@ -702,7 +694,7 @@ def validate_version_requirement(helper_req): yield Error(message) if major_diff else Warning(message) @test() - def helpers_deprecated_in_v2(app): + def helpers_deprecated_in_v2(app) -> TestResult: cmd = f"grep -IhEro 'ynh_\\w+' '{app.path}/scripts/install' '{app.path}/scripts/remove' '{app.path}/scripts/upgrade' '{app.path}/scripts/backup' '{app.path}/scripts/restore' || true" helpers_used = ( @@ -720,7 +712,7 @@ def helpers_deprecated_in_v2(app): ) @test() - def helper_consistency_apt_deps(app): + def helper_consistency_apt_deps(app) -> TestResult: """ Check if ynh_install_app_dependencies is present in install/upgrade/restore so dependencies are up to date after restoration or upgrade @@ -747,7 +739,7 @@ def helper_consistency_apt_deps(app): ) @test() - def helper_consistency_service_add(app): + def helper_consistency_service_add(app) -> TestResult: occurences = { "install": ( @@ -796,10 +788,10 @@ def helper_consistency_service_add(app): ) for script in occurences.keys() ] - details = "\n".join(details) + details_str = "\n".join(details) yield Warning( "Some inconsistencies were found in the 'yunohost service add' commands between install, upgrade and restore:\n%s" - % details + % details_str ) if found_legacy_logtype_option: @@ -816,7 +808,7 @@ def helper_consistency_service_add(app): ) @test() - def references_to_superold_stuff(app): + def references_to_superold_stuff(app) -> TestResult: if any( script.contains("jessie") for script in app.scripts.values() @@ -851,7 +843,7 @@ def references_to_superold_stuff(app): ) @test() - def conf_json_persistent_tweaking(self): + def conf_json_persistent_tweaking(self) -> TestResult: if ( os.system( "grep -nr '/etc/ssowat/conf.json.persistent' %s | grep -vq '^%s/doc' 2>/dev/null" @@ -862,7 +854,7 @@ def conf_json_persistent_tweaking(self): yield Error("Don't do black magic with /etc/ssowat/conf.json.persistent!") @test() - def app_data_in_unofficial_dir(self): + def app_data_in_unofficial_dir(self) -> TestResult: allowed_locations = [ "/home/yunohost.app", diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 55b5053..79dac27 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -5,19 +5,13 @@ import subprocess import sys import time +import tomllib from datetime import datetime +from types import ModuleType +from typing import Any, Generator -import tomllib -from lib.lib_package_linter import ( - Critical, - Error, - Info, - Success, - TestSuite, - Warning, - test, - urlopen, -) +from lib.lib_package_linter import (Critical, Error, Info, Success, TestResult, + TestSuite, Warning, test, urlopen) from lib.print import _print ######################################## @@ -34,7 +28,7 @@ class AppCatalog(TestSuite): - def __init__(self, app_id): + def __init__(self, app_id: str) -> None: self.app_id = app_id self.test_suite_name = "Catalog infos" @@ -49,7 +43,7 @@ def __init__(self, app_id): self.catalog_infos = self.app_list.get(app_id, {}) - def _fetch_app_repo(self): + def _fetch_app_repo(self) -> None: flagfile = "./.apps_git_clone_cache" if ( @@ -78,13 +72,13 @@ def _fetch_app_repo(self): open(flagfile, "w").write("") @test() - def is_in_catalog(self): + def is_in_catalog(self) -> TestResult: if not self.catalog_infos: yield Critical("This app is not in YunoHost's application catalog") @test() - def revision_is_HEAD(self): + def revision_is_HEAD(self) -> TestResult: if self.catalog_infos and self.catalog_infos.get("revision", "HEAD") != "HEAD": yield Error( @@ -92,7 +86,7 @@ def revision_is_HEAD(self): ) @test() - def state_is_working(self): + def state_is_working(self) -> TestResult: if ( self.catalog_infos @@ -103,14 +97,14 @@ def state_is_working(self): ) @test() - def has_category(self): + def has_category(self) -> TestResult: if self.catalog_infos and not self.catalog_infos.get("category"): yield Warning( "The application has no associated category in YunoHost's apps catalog" ) @test() - def is_in_github_org(self): + def is_in_github_org(self) -> TestResult: repo_org = "https://github.com/YunoHost-Apps/%s_ynh" % (self.app_id) repo_brique = "https://github.com/labriqueinternet/%s_ynh" % (self.app_id) @@ -130,10 +124,10 @@ def is_in_github_org(self): else: - def is_in_github_org(): + def is_in_github_org() -> bool: return urlopen(repo_org)[0] != 404 - def is_in_brique_org(): + def is_in_brique_org() -> bool: return urlopen(repo_brique)[0] != 404 if not is_in_github_org() and not is_in_brique_org(): @@ -142,7 +136,7 @@ def is_in_brique_org(): ) @test() - def is_long_term_good_quality(self): + def is_long_term_good_quality(self) -> TestResult: # # This analyzes the (git) history of apps.json in the past year and @@ -150,14 +144,14 @@ def is_long_term_good_quality(self): # known + flagged working + level >= 5 # - def git(cmd): + def git(cmd: list[str]) -> str: return ( subprocess.check_output(["git", "-C", "./.apps"] + cmd) .decode("utf-8") .strip() ) - def _time_points_until_today(): + def _time_points_until_today() -> Generator[datetime, None, None]: # Prior to April 4th, 2019, we still had official.json and community.json # Nowadays we only have apps.json @@ -181,9 +175,10 @@ def _time_points_until_today(): date = datetime(year, month, day) - def get_history(N): + def get_history(N: int) -> Generator[tuple[datetime, dict[str, Any]], None, None]: for t in list(_time_points_until_today())[(-1 * N) :]: + loader: ModuleType # Fetch apps.json content at this date commit = git( @@ -229,7 +224,7 @@ def get_history(N): # + flagged as working # + level > 5 # for the past 6 months - def good_quality(infos): + def good_quality(infos: dict[str, Any]) -> bool: return ( bool(infos) and isinstance(infos, dict) diff --git a/tests/test_configurations.py b/tests/test_configurations.py index fa5e510..81cb137 100644 --- a/tests/test_configurations.py +++ b/tests/test_configurations.py @@ -4,23 +4,17 @@ import os import re import subprocess - import tomllib -from lib.lib_package_linter import ( - Error, - Info, - TestSuite, - Warning, - file_exists, - test, - tests_v1_schema, - validate_schema, -) +from typing import Any, Generator + +from lib.lib_package_linter import (Error, Info, TestReport, TestResult, + TestSuite, Warning, file_exists, test, + tests_v1_schema, validate_schema) from lib.print import _print class Configurations(TestSuite): - def __init__(self, app): + def __init__(self, app) -> None: self.app = app self.test_suite_name = "Configuration files" @@ -36,7 +30,7 @@ def __init__(self, app): ############################ @test() - def tests_toml(self): + def tests_toml(self) -> TestResult: app = self.app @@ -53,7 +47,7 @@ def tests_toml(self): ) @test() - def encourage_extra_php_conf(self): + def encourage_extra_php_conf(self) -> TestResult: app = self.app @@ -69,7 +63,7 @@ def encourage_extra_php_conf(self): ) @test() - def misc_source_management(self): + def misc_source_management(self) -> TestResult: app = self.app @@ -94,7 +88,7 @@ def misc_source_management(self): ) @test() - def systemd_config_specific_user(self): + def systemd_config_specific_user(self) -> TestResult: app = self.app for filename in ( @@ -121,6 +115,7 @@ def systemd_config_specific_user(self): if "[Unit]" not in content: continue + Level: type[TestReport] if re.findall(r"^ *Type=oneshot", content, flags=re.MULTILINE): Level = Info else: @@ -139,7 +134,7 @@ def systemd_config_specific_user(self): ) @test() - def systemd_config_harden_security(self): + def systemd_config_harden_security(self) -> TestResult: app = self.app for filename in ( @@ -169,7 +164,7 @@ def systemd_config_harden_security(self): ) @test() - def php_config_specific_user(self): + def php_config_specific_user(self) -> TestResult: app = self.app for filename in ( @@ -205,7 +200,7 @@ def php_config_specific_user(self): ) @test() - def nginx_http_host(self): + def nginx_http_host(self) -> TestResult: app = self.app @@ -217,7 +212,7 @@ def nginx_http_host(self): ) @test() - def nginx_https_redirect(self): + def nginx_https_redirect(self) -> TestResult: app = self.app @@ -240,7 +235,7 @@ def nginx_https_redirect(self): ) @test() - def misc_nginx_add_header(self): + def misc_nginx_add_header(self) -> TestResult: app = self.app @@ -269,7 +264,7 @@ def misc_nginx_add_header(self): ) @test() - def misc_nginx_more_set_headers(self): + def misc_nginx_more_set_headers(self) -> TestResult: app = self.app @@ -291,7 +286,7 @@ def misc_nginx_more_set_headers(self): zzz for zzz in lines if "more_set_headers" in zzz ] - def right_syntax(line): + def right_syntax(line: str) -> re.Match[str] | None: return re.search( r"more_set_headers +[\"\'][\w-]+\s?: .*[\"\'];", line ) @@ -312,7 +307,7 @@ def right_syntax(line): ) @test() - def misc_nginx_check_regex_in_location(self): + def misc_nginx_check_regex_in_location(self) -> TestResult: app = self.app for filename in ( os.listdir(app.path + "/conf") if os.path.exists(app.path + "/conf") else [] @@ -334,7 +329,7 @@ def misc_nginx_check_regex_in_location(self): ) @test() - def misc_nginx_path_traversal(self): + def misc_nginx_path_traversal(self) -> TestResult: app = self.app for filename in ( @@ -350,7 +345,7 @@ def misc_nginx_path_traversal(self): # # Path traversal issues # - def find_location_with_alias(locationblock): + def find_location_with_alias(locationblock: Any) -> Generator[tuple[str, str], None, None]: if locationblock[0][0] != "location": return @@ -369,7 +364,7 @@ def find_location_with_alias(locationblock): else: continue - def find_path_traversal_issue(nginxconf): + def find_path_traversal_issue(nginxconf: list[Any]) -> Generator[str, None, None]: for block in nginxconf: for location, alias in find_location_with_alias(block): @@ -428,7 +423,7 @@ def find_path_traversal_issue(nginxconf): from lib.nginxparser import nginxparser try: - nginxconf = nginxparser.load(open(app.path + "/conf/" + filename)) + nginxconf: list[Any] = nginxparser.load(open(app.path + "/conf/" + filename)) except Exception as e: _print("Could not parse NGINX conf...: " + str(e)) nginxconf = [] @@ -444,7 +439,7 @@ def find_path_traversal_issue(nginxconf): ) @test() - def bind_public_ip(self): + def bind_public_ip(self) -> TestResult: app = self.app for path, subdirs, files in ( os.walk(app.path + "/conf") if os.path.exists(app.path + "/conf") else [] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index c570726..1000e3b 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -5,20 +5,12 @@ import os import re import sys - import tomllib -from lib.lib_package_linter import ( - Critical, - Error, - Info, - TestSuite, - Warning, - c, - manifest_v2_schema, - spdx_licenses, - test, - validate_schema, -) +from typing import Any, Callable + +from lib.lib_package_linter import (Critical, Error, Info, TestResult, + TestSuite, Warning, c, manifest_v2_schema, + spdx_licenses, test, validate_schema) # Only packaging v2 is supported on the linter now ... But someday™ according The Prophecy™, packaging v3 will be a thing app_packaging_format = 2 @@ -70,7 +62,7 @@ class Manifest(TestSuite): - def __init__(self, path): + def __init__(self, path: str) -> None: self.path = path self.test_suite_name = "manifest" @@ -78,7 +70,7 @@ def __init__(self, path): manifest_path = os.path.join(path, "manifest.toml") # Taken from https://stackoverflow.com/a/49518779 - def check_for_duplicate_keys(ordered_pairs): + def check_for_duplicate_keys(ordered_pairs: list[tuple[str, Any]]) -> dict[str, Any]: dict_out = {} for key, val in ordered_pairs: if key in dict_out: @@ -98,7 +90,7 @@ def check_for_duplicate_keys(ordered_pairs): sys.exit(1) @test() - def mandatory_fields(self): + def mandatory_fields(self) -> TestResult: fields = [ "packaging_format", @@ -122,11 +114,11 @@ def mandatory_fields(self): "The following mandatory fields are missing: %s" % missing_fields ) - if "license" not in self.manifest.get("upstream"): + if "license" not in self.manifest.get("upstream", ""): yield Error("The license key in the upstream section is missing") @test() - def maintainer_sensible_values(self): + def maintainer_sensible_values(self) -> TestResult: if "maintainers" in self.manifest.keys(): for value in self.manifest["maintainers"]: if not value.strip(): @@ -137,7 +129,7 @@ def maintainer_sensible_values(self): ) @test() - def upstream_fields(self): + def upstream_fields(self) -> TestResult: if "upstream" not in self.manifest.keys(): yield Warning( """READMEs are to be automatically generated using https://github.com/YunoHost/apps/tree/master/tools/README-generator. @@ -145,7 +137,7 @@ def upstream_fields(self): ) @test() - def upstream_placeholders(self): + def upstream_placeholders(self) -> TestResult: if "upstream" in self.manifest.keys(): if "yunohost.org" in self.manifest["upstream"].get("admindoc", ""): yield Error( @@ -175,12 +167,12 @@ def upstream_placeholders(self): ) @test() - def FIXMEs(self): + def FIXMEs(self) -> TestResult: if "FIXME" in self.raw_manifest: yield Warning("There are still some FIXMEs remaining in the manifest") @test() - def yunohost_version_requirement_superold(app): + def yunohost_version_requirement_superold(app) -> TestResult: yunohost_version_req = ( app.manifest.get("integration", {}).get("yunohost", "").strip(">= ") @@ -195,18 +187,18 @@ def yunohost_version_requirement_superold(app): ) @test() - def basic_fields_format(self): + def basic_fields_format(self) -> TestResult: if self.manifest.get("packaging_format") != app_packaging_format: yield Error(f"packaging_format should be {app_packaging_format}") - if not re.match("^[a-z0-9]((_|-)?[a-z0-9])+$", self.manifest.get("id")): + if not re.match("^[a-z0-9]((_|-)?[a-z0-9])+$", self.manifest.get("id", "")): yield Error("The app id is not a valid app id") - elif self.manifest.get("id").endswith("_ynh"): + elif self.manifest.get("id", "").endswith("_ynh"): yield Warning("The app id is not supposed to end with _ynh :| ...") if len(self.manifest["name"]) > 22: yield Error("The app name is too long") - keys = { + keys: dict[str, tuple[Callable[..., bool | re.Match[str] | None], str]] = { "yunohost": ( lambda v: isinstance(v, str) and re.fullmatch(r"^>=\s*[\d\.]+\d$", v), "Expected something like '>= 4.5.6'", @@ -254,7 +246,7 @@ def basic_fields_format(self): yield Error("Missing 'license' key in the upstream section") @test() - def license(self): + def license(self) -> TestResult: # Turns out there may be multiple licenses... (c.f. Seafile) licenses = self.manifest.get("upstream", {}).get("license", "").split(",") @@ -279,7 +271,7 @@ def license(self): return @test() - def description(self): + def description(self) -> TestResult: descr = self.manifest.get("description", "") id = self.manifest["id"].lower() @@ -306,7 +298,7 @@ def description(self): ) @test() - def version_format(self): + def version_format(self) -> TestResult: if not re.match( r"^" + VERSION_PATTERN + r"~ynh[0-9]+$", self.manifest.get("version", ""), @@ -321,7 +313,7 @@ def version_format(self): ) @test() - def custom_install_dir(self): + def custom_install_dir(self) -> TestResult: custom_install_dir = ( self.manifest.get("resources", {}).get("install_dir", {}).get("dir") ) @@ -334,7 +326,7 @@ def custom_install_dir(self): ) @test() - def install_args(self): + def install_args(self) -> TestResult: recognized_types = ( "string", @@ -413,7 +405,7 @@ def install_args(self): ) @test() - def obsolete_or_missing_ask_strings(self): + def obsolete_or_missing_ask_strings(self) -> TestResult: ask_string_managed_by_the_core = [ ("domain", "domain"), @@ -452,7 +444,7 @@ def obsolete_or_missing_ask_strings(self): ) @test() - def old_php_version(self): + def old_php_version(self) -> TestResult: resources = self.manifest["resources"] @@ -472,7 +464,7 @@ def old_php_version(self): ) @test() - def resource_consistency(self): + def resource_consistency(self) -> TestResult: resources = self.manifest["resources"] @@ -517,7 +509,7 @@ def resource_consistency(self): ) @test() - def manifest_schema(self): + def manifest_schema(self: "Manifest") -> TestResult: yield from validate_schema( "manifest", json.loads(manifest_v2_schema()), self.manifest ) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 7d24dd5..f2304fb 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -5,17 +5,11 @@ import shlex import statistics import subprocess +from typing import Generator -from lib.lib_package_linter import ( - Critical, - Error, - Info, - TestSuite, - Warning, - file_exists, - report_warning_not_reliable, - test, -) +from lib.lib_package_linter import (Critical, Error, Info, TestResult, + TestSuite, Warning, file_exists, + report_warning_not_reliable, test) from lib.print import _print @@ -30,7 +24,7 @@ # |_| # ################################## class Script(TestSuite): - def __init__(self, app_path, name, app_id): + def __init__(self, app_path: str, name: str, app_id: str) -> None: self.name = name self.app_path = app_path self.app_id = app_id @@ -41,7 +35,7 @@ def __init__(self, app_path, name, app_id): self.lines = list(self.read_file()) self.test_suite_name = "scripts/" + self.name - def read_file(self): + def read_file(self) -> Generator[list[str], None, None]: with open(self.path) as f: lines = f.readlines() @@ -57,8 +51,8 @@ def read_file(self): for line in lines: try: - line = shlex.split(line, True) - yield line + splitted_line = shlex.split(line, True) + yield splitted_line except Exception as e: ignore_pattern = [ @@ -86,12 +80,12 @@ def read_file(self): report_warning_not_reliable("%s : %s" % (e, line)) - def occurences(self, command): + def occurences(self, command: str) -> list[str]: return [ line for line in [" ".join(line) for line in self.lines] if command in line ] - def contains(self, command): + def contains(self, command: str) -> bool: """ Iterate on lines to check if command is contained in line @@ -99,7 +93,7 @@ def contains(self, command): """ return any(command in line for line in [" ".join(line) for line in self.lines]) - def containsregex(self, regex): + def containsregex(self, regex: str) -> bool: """ Iterate on lines to check if command is contained in line @@ -110,7 +104,7 @@ def containsregex(self, regex): ) @test() - def error_handling(self): + def error_handling(self) -> TestResult: if ( self.contains("ynh_abort_if_errors") @@ -147,7 +141,7 @@ def error_handling(self): # Skip this in common.sh, sometimes custom not-yet-official helpers need this @test(ignore=["_common.sh"]) - def raw_apt_commands(self): + def raw_apt_commands(self) -> TestResult: if ( self.contains("ynh_package_install") @@ -170,7 +164,7 @@ def raw_apt_commands(self): ) @test() - def obsolete_helpers(self): + def obsolete_helpers(self) -> TestResult: if self.contains("yunohost app setting"): yield Critical( "Do not use 'yunohost app setting' directly. Please use 'ynh_app_setting_(set,get,delete)' instead." @@ -181,7 +175,7 @@ def obsolete_helpers(self): ) @test(only=["install", "upgrade"]) - def deprecated_replace_string(self): + def deprecated_replace_string(self) -> TestResult: cmd1 = "grep -Ec 'ynh_replace_string' '%s' || true" % self.path cmd2 = "grep -Ec 'ynh_replace_string.*__\\w+__' '%s' || true" % self.path @@ -194,7 +188,7 @@ def deprecated_replace_string(self): ) @test() - def bad_ynh_exec_syntax(self): + def bad_ynh_exec_syntax(self) -> TestResult: cmd = ( 'grep -q -IhEro "ynh_exec_(err|warn|warn_less|quiet|fully_quiet) (\\"|\').*(\\"|\')$" %s' % self.path @@ -205,7 +199,7 @@ def bad_ynh_exec_syntax(self): ) @test() - def ynh_setup_source_keep_with_absolute_path(self): + def ynh_setup_source_keep_with_absolute_path(self) -> TestResult: cmd = 'grep -q -IhEro "ynh_setup_source.*keep.*install_dir" %s' % self.path if os.system(cmd) == 0: yield Info( @@ -213,21 +207,21 @@ def ynh_setup_source_keep_with_absolute_path(self): ) @test() - def ynh_npm_global(self): + def ynh_npm_global(self) -> TestResult: if self.containsregex(r"ynh_npm.*install.*global"): yield Warning( "Please don't install stuff on global scope with npm install --global é_è" ) @test() - def ynh_add_fpm_config_deprecated_package_option(self): + def ynh_add_fpm_config_deprecated_package_option(self) -> TestResult: if self.containsregex(r"ynh_add_fpm_config .*package=.*"): yield Error( "(Requires Yunohost 4.3) Option --package for ynh_add_fpm_config is deprecated : please use 'ynh_install_app_dependencies' with **all** your apt dependencies instead (no need to define a special 'extra_php_dependencies'). YunoHost will automatically install any phpX.Y-fpm / phpX.Y-common if needed." ) @test() - def set_is_public_setting(self): + def set_is_public_setting(self) -> TestResult: if self.containsregex(r"ynh_app_setting_set .*is_public.*"): if self.name == "upgrade": yield Error( @@ -239,21 +233,21 @@ def set_is_public_setting(self): ) @test(only=["_common.sh"]) - def default_php_version_in_common(self): + def default_php_version_in_common(self) -> TestResult: if self.contains("YNH_DEFAULT_PHP_VERSION"): yield Warning( "Do not use YNH_DEFAULT_PHP_VERSION in _common.sh ... _common.sh is usually sourced *before* the helpers, which define the version of YNH_DEFAULT_PHP_VERSION (hence it gets replaced with empty string). Instead, please explicitly state the PHP version in the package, e.g. dependencies='php8.2-cli php8.2-imagemagick'" ) @test(ignore=["install", "_common.sh"]) - def get_is_public_setting(self): + def get_is_public_setting(self) -> TestResult: if self.contains("is_public=") or self.contains("$is_public"): yield Warning( "permission system: there should be no need to fetch or use $is_public ... is_public should only be used during installation to initialize the permission. The admin is likely to manually tweak the permission using YunoHost's interface later." ) @test(only=["upgrade"]) - def temporarily_enable_visitors_during_upgrade(self): + def temporarily_enable_visitors_during_upgrade(self) -> TestResult: if self.containsregex( "ynh_permission_update.*add.*visitors" ) and self.containsregex("ynh_permission_update.*remove.*visitors"): @@ -262,7 +256,7 @@ def temporarily_enable_visitors_during_upgrade(self): ) @test() - def set_legacy_permissions(self): + def set_legacy_permissions(self) -> TestResult: if self.containsregex( r"ynh_app_setting_set .*protected_uris" ) or self.containsregex(r"ynh_app_setting_set .*skipped_uris"): @@ -278,14 +272,14 @@ def set_legacy_permissions(self): ) @test() - def normalize_url_path(self): + def normalize_url_path(self) -> TestResult: if self.contains("ynh_normalize_url_path"): yield Warning( "You probably don't need to call 'ynh_normalize_url_path'... this is only relevant for upgrades from super-old versions (like 3 years ago or so...)" ) @test() - def safe_rm(self): + def safe_rm(self) -> TestResult: if ( self.contains("rm -r") or self.contains("rm -R") @@ -297,7 +291,7 @@ def safe_rm(self): ) @test() - def FIXMEs(self): + def FIXMEs(self) -> TestResult: removeme = f"grep -q '#REMOVEME?' '{self.path}'" fixme = f"grep -q '# FIXMEhelpers2.1' '{self.path}'" @@ -309,7 +303,7 @@ def FIXMEs(self): ) @test() - def nginx_restart(self): + def nginx_restart(self) -> TestResult: if self.contains("systemctl restart nginx") or self.contains( "service nginx restart" ): @@ -319,14 +313,14 @@ def nginx_restart(self): ) @test() - def raw_systemctl_start(self): + def raw_systemctl_start(self) -> TestResult: if self.containsregex(r"systemctl start \"?[^. ]+(\.service)?\"?\s"): yield Warning( "Please do not use 'systemctl start' to start services. Instead, you should use 'ynh_systemd_action' which will display the service log in case it fails to start. You can also use '--line_match' to wait until some specific word appear in the log, signaling the service indeed fully started." ) @test() - def bad_line_match(self): + def bad_line_match(self) -> TestResult: if self.containsregex(r"--line_match=Started$") or self.containsregex( r"--line_match=Stopped$" @@ -336,7 +330,7 @@ def bad_line_match(self): ) @test() - def quiet_systemctl_enable(self): + def quiet_systemctl_enable(self) -> TestResult: systemctl_enable = [ line @@ -349,7 +343,7 @@ def quiet_systemctl_enable(self): yield Warning(message) @test() - def quiet_wget(self): + def quiet_wget(self) -> TestResult: wget_cmds = [ line @@ -365,7 +359,7 @@ def quiet_wget(self): yield Warning(message) @test(only=["install"]) - def argument_fetching(self): + def argument_fetching(self) -> TestResult: if self.containsregex(r"^\w+\=\$\{?[0-9]"): yield Critical( @@ -374,7 +368,7 @@ def argument_fetching(self): ) @test(only=["install"]) - def sources_list_tweaking(self): + def sources_list_tweaking(self) -> TestResult: if self.contains("/etc/apt/sources.list") or ( os.path.exists(self.app_path + "/scripts/_common.sh") and "/etc/apt/sources.list" @@ -388,7 +382,7 @@ def sources_list_tweaking(self): ) @test() - def firewall_consistency(self): + def firewall_consistency(self) -> TestResult: if self.contains("yunohost firewall allow") and not self.contains( "--needs_exposed_ports" ): @@ -404,7 +398,7 @@ def firewall_consistency(self): ) @test() - def exit_ynhdie(self): + def exit_ynhdie(self) -> TestResult: if self.contains(r"\bexit\b"): yield Error( @@ -412,14 +406,14 @@ def exit_ynhdie(self): ) @test() - def old_regenconf(self): + def old_regenconf(self) -> TestResult: if self.contains("yunohost service regen-conf"): yield Error( "'yunohost service regen-conf' has been replaced by 'yunohost tools regen-conf'." ) @test() - def ssowatconf_or_nginx_reload(self): + def ssowatconf_or_nginx_reload(self) -> TestResult: # Dirty hack to check only the 10 last lines for ssowatconf # (the "bad" practice being using this at the very end of the script, but some apps legitimately need this in the middle of the script) oldlines = list(self.lines) @@ -438,7 +432,7 @@ def ssowatconf_or_nginx_reload(self): self.lines = oldlines @test() - def sed(self): + def sed(self) -> TestResult: if self.containsregex( r"sed\s+(-i|--in-place)\s+(-r\s+)?s" ) or self.containsregex(r"sed\s+s\S*\s+(-i|--in-place)"): @@ -447,7 +441,7 @@ def sed(self): ) @test() - def sudo(self): + def sudo(self) -> TestResult: if self.containsregex( r"sudo \w" ): # \w is here to not match sudo -u, legit use because ynh_exec_as not official yet... @@ -457,7 +451,7 @@ def sudo(self): ) @test() - def chownroot(self): + def chownroot(self) -> TestResult: # Mywebapp has a legit use case for this >_> if self.app_id == "my_webapp": @@ -472,21 +466,21 @@ def chownroot(self): ) @test() - def chmod777(self): + def chmod777(self) -> TestResult: if self.containsregex(r"chmod .*777") or self.containsregex(r"chmod .*o\+w"): yield Warning( "DO NOT use chmod 777 or chmod o+w that gives write permission to every users on the system!!! If you have permission issues, just make sure that the owner and/or group owner is right..." ) @test() - def random(self): + def random(self) -> TestResult: if self.contains("dd if=/dev/urandom") or self.contains("openssl rand"): yield Error( "Instead of 'dd if=/dev/urandom' or 'openssl rand', you should use 'ynh_string_random'" ) @test(only=["install"]) - def progression(self): + def progression(self) -> TestResult: if not self.contains("ynh_script_progression"): yield Warning( "Please add a few messages for the user using 'ynh_script_progression' " @@ -495,7 +489,7 @@ def progression(self): ) @test(only=["backup"]) - def progression_in_backup(self): + def progression_in_backup(self) -> TestResult: if self.contains("ynh_script_progression"): yield Warning( "We recommend to *not* use 'ynh_script_progression' in backup " @@ -506,7 +500,7 @@ def progression_in_backup(self): ) @test() - def progression_time(self): + def progression_time(self) -> TestResult: # Usage of ynh_script_progression with --time or --weight=1 all over the place... if self.containsregex(r"ynh_script_progression.*--time"): @@ -515,8 +509,8 @@ def progression_time(self): ) @test(ignore=["_common.sh", "backup"]) - def progression_meaningful_weights(self): - def weight(line): + def progression_meaningful_weights(self) -> TestResult: + def weight(line: list[str]) -> int: match = re.search( r"ynh_script_progression.*--weight=([0-9]+)", " ".join(line) ) @@ -542,7 +536,7 @@ def weight(line): ) @test(only=["install", "_common.sh"]) - def php_deps(self): + def php_deps(self) -> TestResult: if self.containsregex("dependencies.*php-"): # (Stupid hack because some apps like roundcube depend on php-pear or php-php-gettext and there's no phpx.y-pear phpx.y-php-gettext>_> ... if not self.contains("php-pear") or not self.contains("php-php-gettext"): @@ -551,14 +545,14 @@ def php_deps(self): ) @test(only=["backup"]) - def systemd_during_backup(self): + def systemd_during_backup(self) -> TestResult: if self.containsregex("^ynh_systemd_action"): yield Warning( "Unless you really have a good reason to do so, starting/stopping services during backup has no benefit and leads to unecessary service interruptions when creating backups... As a 'reminder': apart from possibly database dumps (which usually do not require the service to be stopped) or other super-specific action, running the backup script is only a *declaration* of what needs to be backed up. The real copy and archive creation happens *after* the backup script is ran." ) @test() - def helpers_sourcing_after_official(self): + def helpers_sourcing_after_official(self) -> TestResult: helpers_after_official = subprocess.check_output( "head -n 30 '%s' | grep -A 10 '^ *source */usr/share/yunohost/helpers' | grep '^ *source ' | tail -n +2" % self.path, @@ -568,28 +562,28 @@ def helpers_sourcing_after_official(self): helpers_after_official.replace("source", "").replace(" ", "").strip() ) if helpers_after_official: - helpers_after_official = helpers_after_official.split("\n") + helpers_after_official_list = helpers_after_official.split("\n") yield Warning( "Please avoid sourcing additional helpers after the official helpers (in this case file %s)" - % ", ".join(helpers_after_official) + % ", ".join(helpers_after_official_list) ) @test(only=["backup", "restore"]) - def helpers_sourcing_backuprestore(self): + def helpers_sourcing_backuprestore(self) -> TestResult: if self.contains("source _common.sh") or self.contains("source ./_common.sh"): yield Error( 'In the context of backup and restore scripts, you should load _common.sh with "source ../settings/scripts/_common.sh"' ) @test(only=["_common.sh"]) - def no_progress_in_common(self): + def no_progress_in_common(self) -> TestResult: if self.contains("ynh_script_progression"): yield Warning( "You should not use `ynh_script_progression` in _common.sh because it will produce warnings when trying to install the application." ) @test(only=["remove"]) - def no_log_remove(self): + def no_log_remove(self) -> TestResult: if self.containsregex(r"(ynh_secure_remove|ynh_safe_rm|rm).*(\/var\/log\/)"): yield Warning( "Do not delete logs on app removal, else they will be erased if the app upgrade fails. This is handled by the core." From c3a304e1f4d43840531be00759c2fcae2c07b7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Pi=C3=A9dallu?= Date: Sat, 16 Nov 2024 16:50:44 +0100 Subject: [PATCH 4/4] nginxparser: ignore its typing --- lib/nginxparser/nginxparser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/nginxparser/nginxparser.py b/lib/nginxparser/nginxparser.py index 71854d1..3cddae2 100644 --- a/lib/nginxparser/nginxparser.py +++ b/lib/nginxparser/nginxparser.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 +# type: ignore """Very low-level nginx config parser based on pyparsing.""" # Taken from https://github.com/certbot/certbot (Apache licensed)