From 91e11ac66d8eaef05a77190e224d4df5e18e8673 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Mon, 25 May 2026 15:47:20 -0500 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20make=20fetch?= =?UTF-8?q?=20tags=20non-fatal=20in=20generate=20changelog=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ generate_changelog/main.py | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a82aaf9..387ab21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [1.1.19] - 2026-03-03 + +### Features + +- **core**: add inline release creation to version controller (`patch candidate`) + +### Bug Fixes + +- **core**: add pull-requests write permission to version controller (`patch candidate`) + +### Documentation + +- **core**: update changelog + ## [1.1.17] - 2026-03-03 ### Bug Fixes diff --git a/generate_changelog/main.py b/generate_changelog/main.py index 2f14c2e..9923b84 100644 --- a/generate_changelog/main.py +++ b/generate_changelog/main.py @@ -115,8 +115,9 @@ def fetch_tags() -> None: subprocess.check_output(["git", "fetch", "--tags"]) logger.info("Successfully fetched Git tags.") except subprocess.CalledProcessError as error: - logger.error(f"Error fetching Git tags: {error}") - raise + logger.warning( + f"Could not fetch Git tags from remote (continuing with local tags): {error}" + ) def parse_version(version_str: str) -> Tuple[int, int, int]: From fae2cfdf9ee76f92a1cec0a7f9a6a810178f2132 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Sat, 30 May 2026 13:23:19 -0500 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20use=20docker?= =?UTF-8?q?=20compose=20v2=20plugin=20instead=20of=20deprecated=20docker-c?= =?UTF-8?q?ompose=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- validate_docker_compose/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/validate_docker_compose/main.py b/validate_docker_compose/main.py index 0cacdbb..e875e7c 100644 --- a/validate_docker_compose/main.py +++ b/validate_docker_compose/main.py @@ -40,7 +40,7 @@ def validate_docker_compose(file_path): """ try: result = subprocess.run( - ["docker-compose", "-f", file_path, "config"], + ["docker", "compose", "-f", file_path, "config"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -54,7 +54,7 @@ def validate_docker_compose(file_path): print(e.stderr) sys.exit(1) except FileNotFoundError: - print("[ERROR] docker-compose not found.") + print("[ERROR] docker compose not found.") sys.exit(1) From 177206d7e15e8edfd4b08ff959c0a9aea7a5d1a7 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Sat, 30 May 2026 14:07:41 -0500 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8=20feat(core):=20add=20validate?= =?UTF-8?q?=20container=20names=20script=20for=20cloudformation=20template?= =?UTF-8?q?s=20[patch=20candidate]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- validate_container_names/main.py | 80 ++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 validate_container_names/main.py diff --git a/validate_container_names/main.py b/validate_container_names/main.py new file mode 100644 index 0000000..d3934aa --- /dev/null +++ b/validate_container_names/main.py @@ -0,0 +1,80 @@ +# scripts/validate_container_names/main.py + +import re +import sys +import os + +# Container template naming convention: Container{SUITE}{APP}{ENV}.yml +# Rules: +# - Must start with literal "Container" +# - Followed by SUITE+APP in uppercase alphanumeric (min 2 chars) +# - Must end with exactly one environment suffix: PROD | TEST | DEV +# - Extension must be .yml +VALID_ENVS = ("PROD", "TEST", "DEV") +CONTAINER_NAME_REGEX = re.compile(r"^Container[A-Z0-9]{2,}(?:PROD|TEST|DEV)\.yml$") + + +def validate_filename(file_path): + """ + Validate that a CloudFormation template filename follows the convention: + Container{SUITE}{APP}{ENV}.yml + + Args: + :param file_path: Path to the CloudFormation template file + :returns: True if valid, False otherwise + """ + basename = os.path.basename(file_path) + + if not CONTAINER_NAME_REGEX.match(basename): + env_hint = " | ".join(VALID_ENVS) + print( + f"[ERROR] '{basename}' does not follow the naming convention.\n" + f" Expected: Container{{SUITE}}{{APP}}{{{env_hint}}}.yml\n" + f" Rules:\n" + f" - Must start with 'Container'\n" + f" - SUITE and APP must be uppercase alphanumeric (no hyphens, underscores or spaces)\n" + f" - Must end with one of: {env_hint}\n" + f" - Extension must be .yml\n" + f" Example: ContainerGESTIONATENCIONBACKPROD.yml" + ) + return False + + # Extract the env suffix for informational output + name_no_ext = basename[: -len(".yml")] + env = next(e for e in VALID_ENVS if name_no_ext.endswith(e)) + suite_app = name_no_ext[len("Container") : -len(env)] + print(f"[OK] '{basename}' — suite+app: '{suite_app}', env: '{env}'") + return True + + +def main(): + """Main function for validating Container CloudFormation template filenames.""" + + if len(sys.argv) < 2: + print("[ERROR] Incorrect usage. Must specify at least one file.") + sys.exit(1) + + files = sys.argv[1:] + errors = [] + + for file in files: + if not validate_filename(file): + errors.append(os.path.basename(file)) + + if errors: + print(f"\n[FAIL] {len(errors)} file(s) with invalid names: {', '.join(errors)}") + sys.exit(1) + + print(f"[OK] All {len(files)} file(s) passed naming validation.") + sys.exit(0) + + +if __name__ == "__main__": + if sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except AttributeError: + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + main() From e671779ac168ac07baa60d02b628790b08cc9efd Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Sat, 30 May 2026 14:09:11 -0500 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=201.1.19?= =?UTF-8?q?=20=E2=86=92=201.1.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 68536e2..86a4f23 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.19 +current_version = 1.1.20 commit = True tag = False diff --git a/pyproject.toml b/pyproject.toml index 6368976..12c8c76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "1.1.19" +version = "1.1.20" description = "CICD Core Scripts" authors = ["B "] license = "GLPv3" From 3c09a3c4d752a0a8484107258268442b669d5796 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:17:21 -0500 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=90=9B=20fix(deps):=20update=20pyte?= =?UTF-8?q?st=20requirement=20from=20^8.3.1=20to=20^9.0.3=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
Release notes

Sourced from pytest's releases.

9.0.3

pytest 9.0.3 (2026-04-07)

Bug fixes

  • #12444: Fixed pytest.approx which now correctly takes into account ~collections.abc.Mapping keys order to compare them.

  • #13634: Blocking a conftest.py file using the -p no: option is now explicitly disallowed.

    Previously this resulted in an internal assertion failure during plugin loading.

    Pytest now raises a clear UsageError explaining that conftest files are not plugins and cannot be disabled via -p.

  • #13734: Fixed crash when a test raises an exceptiongroup with __tracebackhide__ = True.

  • #14195: Fixed an issue where non-string messages passed to unittest.TestCase.subTest() were not printed.

  • #14343: Fixed use of insecure temporary directory (CVE-2025-71176).

Improved documentation

  • #13388: Clarified documentation for -p vs PYTEST_PLUGINS plugin loading and fixed an incorrect -p example.
  • #13731: Clarified that capture fixtures (e.g. capsys and capfd) take precedence over the -s / --capture=no command-line options in Accessing captured output from a test function <accessing-captured-output>.
  • #14088: Clarified that the default pytest_collection hook sets session.items before it calls pytest_collection_finish, not after.
  • #14255: TOML integer log levels must be quoted: Updating reference documentation.

Contributor-facing changes

  • #12689: The test reports are now published to Codecov from GitHub Actions. The test statistics is visible on the web interface.

    -- by aleguy02

Commits

You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 12c8c76..5ca2dae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ yamllint="^1.35.0" isort = "^6.0.1" toml = "^0.10.0" black = "^26.1.0" -pytest = "^8.3.1" +pytest = "^9.0.3" httpx = { version = ">=0.24.0", optional = true } pytest-cov = "^6.0.0" coverage = "^7.2.5" From db1fa0a144042ce854deceee3857b91e2206b697 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:17:25 -0500 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=90=9B=20fix(deps):=20update=20bump?= =?UTF-8?q?2version=20requirement=20from=20^1.0.0=20to=20^1.0.1=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [bump2version](https://github.com/c4urself/bump2version) to permit the latest version.
Changelog

Sourced from bump2version's changelog.

unreleased v1.0.2-dev

  • Declare bump2version as unmaintained
  • Housekeeping: migrated from travis+appveyor to GitHub Actions for CI, thanks @​clbarnes

v1.0.1

  • Added: enable special characters in search/replace, thanks @​mckelvin
  • Added: allow globbing a pattern to match multiple files, thanks @​balrok
  • Added: way to only bump a specified file via --no-configured-files, thanks @​balrok
  • Fixed: dry-run now correctly outputs, thanks @​fmigneault
  • Housekeeping: documentation for lightweight tags improved, thanks @​GreatBahram
  • Housekeeping: added related tools document, thanks @​florisla
  • Fixed: no more falling back to default search, thanks @​florisla

v1.0.0

v0.5.11

  • Housekeeping, also publish an sdist
  • Housekeeping, fix appveyor builds
  • Housekeeping, make lint now lints with pylint
  • Drop support for Python3.4, thanks @​hugovk #79
  • Enhance missing VCS command detection (errno 13), thanks @​lowell80 #75
  • Add environment variables for other scripts to use, thanks @​mauvilsa #70
  • Refactor, cli.main is now much more readable, thanks @​florisla #68
  • Fix, retain file newlines for Windows, thanks @​hesstobi #59
  • Add support (tests) for Pythno3.7, thanks @​florisla #49
  • Allow any part to be configured in configurable strings such as tag_name etc., thanks @​florisla #41

v0.5.10

  • Housekeeping, use twine

v0.5.9

v0.5.8

  • Updated the readme to markdown for easier maintainability
  • Fixed travis testing, thanks: @​sharksforarms #15

... (truncated)

Commits
  • 14fa603 Bump version: 1.0.1-dev → 1.0.1
  • 2051c96 update changelog
  • 8e6db23 Merge pull request #135 from mckelvin/handle-special-char
  • 78c4f4b Merge pull request #149 from balrok/cli-option-no-configured-files
  • 4fe9277 Merge pull request #158 from mbarkhau/patch-1
  • c684747 Add PyCalVer to RELATED.md
  • 0a38097 Merge pull request #150 from balrok/glob-keyword-in-configfile
  • dc7cead DOC: Update note about bumpversion
  • 0bd8d83 also allow recursive glob with **
  • a96ebf4 support glob keyword in configfile
  • Additional commits viewable in compare view

You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5ca2dae..e47ca33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ python = "^3.12" setuptools = "^82.0.0" idna = "^3.0" certifi = "^2026.1.4" -bump2version = "^1.0.0" +bump2version = "^1.0.1" python-dotenv = "^1.0.0" cryptography = "^46.0.5" requests = "^2.32.3" From 4d50bee7d8e4190e6b6794dca767992d6c77e294 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:17:28 -0500 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=90=9B=20fix(deps):=20update=20requ?= =?UTF-8?q?ests=20requirement=20from=20^2.32.3=20to=20^2.34.2=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version.
Release notes

Sourced from requests's releases.

v2.34.2

2.34.2 (2026-05-14)

  • Moved headers input type back to Mapping to avoid invariance issues with MutableMapping and inferred dict types. Users calling Request.headers.update() may need to narrow typing in their code. (#7441)

Full Changelog: https://github.com/psf/requests/blob/main/HISTORY.md#2342-2026-05-14

Changelog

Sourced from requests's changelog.

2.34.2 (2026-05-14)

  • Moved headers input type back to Mapping to avoid invariance issues with MutableMapping and inferred dict types. Users calling Request.headers.update() may need to narrow typing in their code. (#7441)

2.34.1 (2026-05-13)

Bugfixes

  • Widened json input type from dict and list to Mapping and Sequence. (#7436)
  • Changed headers input type to MutableMapping and removed None from Request.headers typing to improve handling for users. (#7431)
  • Response.reason moved from str | None to str to improve handling for users. (#7437)
  • Fixed a bug where some bodies with custom __getattr__ implementations weren't being properly detected as Iterables. (#7433)

2.34.0 (2026-05-11)

Announcements

  • Requests 2.34.0 introduces inline types, replacing those provided by typeshed. Public API types should be fully compatible with mypy, pyright, and ty. We believe types are comprehensive but if you find issues, please report them to the pinned tracking issue.

    Special thanks to @​bastimeyer, @​cthoyt, @​edgarrmondragon, and @​srittau for helping review and test the types ahead of the release. (#7272)

Improvements

  • Digest Auth hashing algorithms have added usedforsecurity=False to clarify security considerations. (#7310)
  • Requests added support for Python 3.15 based on beta1. Downstream projects should be able to start testing prior to its release in October. (#7422)
  • Requests added support for Python 3.14t. (#7419)

Bugfixes

  • Response.history no longer contains a reference to itself, preventing accidental looping when traversing the history list. (#7328)
  • Requests no longer performs greedy matching on no_proxy domains. The proxy_bypass implementation has been updated with CPython's fix from bpo-39057. (#7427)
  • Requests no longer incorrectly strips duplicate leading slashes in URI paths. This should address user issues with specific presigned URLs. Note the full fix requires urllib3 2.7.0+. (#7315)

... (truncated)

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e47ca33..0a3b269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ certifi = "^2026.1.4" bump2version = "^1.0.1" python-dotenv = "^1.0.0" cryptography = "^46.0.5" -requests = "^2.32.3" +requests = "^2.34.2" wheel = ">=0.36.2" [tool.poetry.group.dev.dependencies] From 25b4aa32db7b5caa740355b7767797980f1af4d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:17:31 -0500 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=90=9B=20fix(deps):=20update=20setu?= =?UTF-8?q?ptools=20requirement=20from=20^82.0.0=20to=20^82.0.1=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version.
Changelog

Sourced from setuptools's changelog.

v82.0.1

Bugfixes

  • Fix the loading of launcher manifest.xml file. (#5047)
  • Replaced deprecated json.__version__ with fixture in tests. (#5186)

Improved Documentation

  • Add advice about how to improve predictability when installing sdists. (#5168)

Misc

v82.0.0

Deprecations and Removals

  • pkg_resources has been removed from Setuptools. Most common uses of pkg_resources have been superseded by the importlib.resources <https://docs.python.org/3/library/importlib.resources.html>_ and importlib.metadata <https://docs.python.org/3/library/importlib.metadata.html>_ projects. Projects and environments relying on pkg_resources for namespace packages or other behavior should depend on older versions of setuptools. (#3085)

v81.0.0

Deprecations and Removals

  • Removed support for the --dry-run parameter to setup.py. This one feature by its nature threads through lots of core and ancillary functionality, adding complexity and friction. Removal of this parameter will help decouple the compiler functionality from distutils and thus the eventual full integration of distutils. These changes do affect some class and function signatures, so any derivative functionality may require some compatibility shims to support their expected interface. Please report any issues to the Setuptools project for investigation. (#4872)

v80.10.2

Bugfixes

  • Update vendored dependencies. (#5159)

Misc

... (truncated)

Commits
  • 5a13876 Bump version: 82.0.0 → 82.0.1
  • 51ab8f1 Avoid using (deprecated) 'json.version' in tests (#5194)
  • f9c37b2 Docs/CI: Fix intersphinx references (#5195)
  • 8173db2 Docs: Fix intersphinx references
  • 09bafbc Fix past tense on newsfragment
  • 461ea56 Add news fragment
  • c4ffe53 Avoid using (deprecated) 'json.version' in tests
  • 749258b Cleanup pkg_resources dependencies and configuration (#5175)
  • 2019c16 Parse ext-module.define-macros from pyproject.toml as list of tuples (#5169)
  • b809c86 Sync setuptools schema with validate-pyproject (#5157)
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0a3b269..badc6d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.12" -setuptools = "^82.0.0" +setuptools = "^82.0.1" idna = "^3.0" certifi = "^2026.1.4" bump2version = "^1.0.1" From a6a31f8898f525ae834536290ea159097842e851 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Sat, 30 May 2026 14:29:19 -0500 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20handle=20exis?= =?UTF-8?q?ting=20tag=20gracefully=20in=20version-controller=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/version-controller.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/version-controller.yml b/.github/workflows/version-controller.yml index 816b1d6..41d917b 100644 --- a/.github/workflows/version-controller.yml +++ b/.github/workflows/version-controller.yml @@ -97,14 +97,17 @@ jobs: VERSION=${{ steps.get_version.outputs.current_version }} BRANCH_NAME=${{ steps.determine_branch.outputs.current_branch }} TAG_NAME="v${VERSION}-${BRANCH_NAME}" - git tag "$TAG_NAME" + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists — skipping creation" + else + git tag "$TAG_NAME" + fi echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - continue-on-error: true - name: Push Tag id: push_tag - if: steps.check_arrow.outputs.contains_arrow == 'true' + if: steps.check_arrow.outputs.contains_arrow == 'true' && steps.create_tag.outputs.tag_name != '' run: | - git push origin "${{ steps.create_tag.outputs.tag_name }}" + git push origin "${{ steps.create_tag.outputs.tag_name }}" || echo "Tag already on remote — skipping" - name: Ensure on Current Branch id: ensure_branch if: steps.check_arrow.outputs.contains_arrow == 'true' From ed57c00d34db3b2914f2687cd08e378a6d3cd446 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Thu, 11 Jun 2026 17:14:44 -0500 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20resolve=20key?= =?UTF-8?q?=20pair=20year=20rollover,=20formatter=20hook=20bugs=20and=20en?= =?UTF-8?q?force=2096=20coverage=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pylintrc | 3 +- CHANGELOG.md | 6 + crypto_controller/main.py | 18 +- crypto_controller/requirements.txt | 1 + format_yaml/main.py | 3 +- format_yml/main.py | 13 +- pyproject.toml | 26 +- tests/test_bump_year.py | 185 +++++++++ tests/test_commit_msg_version_bump.py | 219 ++++++++++ tests/test_control_commit.py | 182 +++++++++ tests/test_crypto_controller.py | 531 ++++++++++++++++++++++++- tests/test_format_yaml.py | 120 ++++++ tests/test_format_yml.py | 109 +++++ tests/test_generate_changelog.py | 383 ++++++++++++++++++ tests/test_init_security_config.py | 195 +++++++++ tests/test_validate_container_names.py | 83 ++++ tests/test_validate_docker_compose.py | 105 +++++ 17 files changed, 2152 insertions(+), 30 deletions(-) create mode 100644 tests/test_bump_year.py create mode 100644 tests/test_commit_msg_version_bump.py create mode 100644 tests/test_control_commit.py create mode 100644 tests/test_format_yaml.py create mode 100644 tests/test_format_yml.py create mode 100644 tests/test_generate_changelog.py create mode 100644 tests/test_init_security_config.py create mode 100644 tests/test_validate_container_names.py create mode 100644 tests/test_validate_docker_compose.py diff --git a/.pylintrc b/.pylintrc index 337f65b..70bb4d7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -46,7 +46,7 @@ fail-under=8 #from-stdin= # Files or directories to be skipped. They should be base names, not paths. -ignore=venv,node_modules,scripts +ignore=venv,.venv,env,node_modules,scripts,.git,__pycache__,build,dist,.eggs,.mypy_cache,.pytest_cache,.ruff_cache,.tox,htmlcov,coverage,.next # Add files or directories matching the regular expressions patterns to the # ignore-list. The regex matches against paths and can be in Posix or Windows @@ -106,7 +106,6 @@ source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 387ab21..71f32c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.1.20] - 2026-05-30 + +### Documentation + +- **core**: update changelog + ## [1.1.19] - 2026-03-03 ### Features diff --git a/crypto_controller/main.py b/crypto_controller/main.py index c0ff3f4..06dd951 100644 --- a/crypto_controller/main.py +++ b/crypto_controller/main.py @@ -13,7 +13,8 @@ import warnings import smtplib from email.mime.text import MIMEText -import json # Added import for JSON handling +import json +import re from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, padding @@ -536,6 +537,19 @@ def get_status(self) -> None: print("Failed to retrieve status. Check logs for more details.") +def _resolve_key_pair_name(cert_location: str) -> str: + """Returns the active key pair name whose year range contains the current year.""" + current_year = datetime.now().year + if os.path.isdir(cert_location): + for fname in os.listdir(cert_location): + match = re.match(r"^Crypto-Key-Pair-(\d{4})-(\d{4})\.kp$", fname) + if match: + start, end = int(match.group(1)), int(match.group(2)) + if start <= current_year <= end: + return fname[:-3] + return f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + def parse_arguments() -> argparse.Namespace: """ Parses command-line arguments. @@ -561,7 +575,7 @@ def parse_arguments() -> argparse.Namespace: ) parser.add_argument( "--key-pair-name", - default=f"Crypto-Key-Pair-{datetime.now().year}", + default=_resolve_key_pair_name(os.path.join(os.getcwd(), "certs")), help="Name of the key pair. Defaults to 'Crypto-Key-Pair-'.", ) parser.add_argument( diff --git a/crypto_controller/requirements.txt b/crypto_controller/requirements.txt index 7c6a8fe..15d05e9 100644 --- a/crypto_controller/requirements.txt +++ b/crypto_controller/requirements.txt @@ -4,3 +4,4 @@ python-dotenv>=0.19.0 wheel>=0.36.2 pytest>=7.0.0 pytest-cov>=4.0.0 +pytest-mock>=3.11.0 diff --git a/format_yaml/main.py b/format_yaml/main.py index 5860645..bbfbedd 100644 --- a/format_yaml/main.py +++ b/format_yaml/main.py @@ -61,13 +61,12 @@ def format_yaml_file(file_path): new_lines.append(line) - formatted_content = "\n".join(lines) + "\n" + formatted_content = "\n".join(new_lines) + "\n" with open(file_path, "w", newline="\n", encoding="utf-8") as f: f.write(formatted_content) print(f"[FORMAT] Formatted {file_path} with LF line endings.") - sys.exit(0) def main(): diff --git a/format_yml/main.py b/format_yml/main.py index 417d169..4dc14af 100644 --- a/format_yml/main.py +++ b/format_yml/main.py @@ -38,20 +38,16 @@ def format_yml_file(file_path): while len(line) > 120: split_pos = line.rfind(" ", 0, 120) - if split_pos != -1: + if split_pos > current_indent: split_line1 = line[:split_pos] + " \\" - split_line2 = " " * current_indent + " " + line[split_pos + 1 :].lstrip() + line = " " * current_indent + " " + line[split_pos + 1 :].lstrip() new_lines.append(split_line1) - new_lines.append(split_line2) print(f"[FORMAT] Split long line at line {i} in {file_path}.") - continue - if not split_pos != -1: + else: split_line1 = line[:120] + " \\" - split_line2 = " " * current_indent + " " + line[120 + 1 :].lstrip() + line = " " * current_indent + " " + line[120:].lstrip() new_lines.append(split_line1) - new_lines.append(split_line2) print(f"[FORMAT] Force split long line at line {i} in {file_path}.") - continue new_lines.append(line) @@ -61,7 +57,6 @@ def format_yml_file(file_path): f.write(formatted_content) print(f"[FORMAT] Formatted {file_path} with LF line endings.") - sys.exit(0) def main(): diff --git a/pyproject.toml b/pyproject.toml index badc6d0..002e904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,12 +56,36 @@ exclude = ''' [tool.pytest.ini_options] minversion = "6.0" -addopts = "--cov=app --cov-report=xml:coverage.xml --cov-report=term" +addopts = "--cov --cov-report=xml:coverage.xml --cov-report=term" testpaths = ["tests"] pythonpath = [ ".", "crypto_controller" ] +[tool.coverage.run] +source = [ + "bump_year", + "commit_msg_version_bump", + "control_commit", + "crypto_controller", + "format_yaml", + "format_yml", + "generate_changelog", + "init_security_config", + "init_template", + "validate_container_names", + "validate_docker_compose", +] + +[tool.coverage.report] +fail_under = 96 +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise EnvironmentError", + "except KeyError as e_key_error_fetch_password:", +] + [tool.isort] profile = "black" line_length = 100 diff --git a/tests/test_bump_year.py b/tests/test_bump_year.py new file mode 100644 index 0000000..775d013 --- /dev/null +++ b/tests/test_bump_year.py @@ -0,0 +1,185 @@ +# tests/test_bump_year.py + +import argparse +import datetime +import logging +import sys + +import pytest + +import bump_year.main as bump_year_main +from bump_year.main import collect_markdown_files, configure_logger, update_markdown_footers + +CURRENT_YEAR = datetime.datetime.now().year + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments returns the default markdown files and INFO level.""" + monkeypatch.setattr(sys, "argv", ["bump_year"]) + args = bump_year_main.parse_arguments() + assert "README.md" in args.md_files + assert "LICENSE" in args.md_files + assert args.md_dir is None + assert args.log_level == "INFO" + + +def test_parse_arguments_custom_values(monkeypatch): + """parse_arguments honors explicit --md-files, --md-dir and --log-level.""" + monkeypatch.setattr( + sys, + "argv", + ["bump_year", "--md-files", "A.md", "B.md", "--md-dir", "docs", "--log-level", "DEBUG"], + ) + args = bump_year_main.parse_arguments() + assert args.md_files == ["A.md", "B.md"] + assert args.md_dir == "docs" + assert args.log_level == "DEBUG" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("VERBOSE") + + +def test_configure_logger_sets_handlers(tmp_path, monkeypatch): + """A valid level configures file and console handlers.""" + monkeypatch.chdir(tmp_path) + configure_logger("DEBUG") + assert bump_year_main.logger.level == logging.DEBUG + assert len(bump_year_main.logger.handlers) == 2 + + +def test_update_footer_replaces_previous_year(tmp_path): + """The footer containing last year is bumped to the current year.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"# Title\n\nSome text.\n\n© {CURRENT_YEAR - 1} Quipux\n", encoding="utf-8") + update_markdown_footers([str(md_file)], CURRENT_YEAR) + content = md_file.read_text(encoding="utf-8") + assert f"© {CURRENT_YEAR} Quipux" in content + assert str(CURRENT_YEAR - 1) not in content + + +def test_update_footer_only_touches_last_matching_line(tmp_path): + """Only the footer (last line with the previous year) is updated.""" + md_file = tmp_path / "README.md" + md_file.write_text( + f"Copyright {CURRENT_YEAR - 1} header\n\nbody\n\n© {CURRENT_YEAR - 1} Footer\n", + encoding="utf-8", + ) + update_markdown_footers([str(md_file)], CURRENT_YEAR) + lines = md_file.read_text(encoding="utf-8").splitlines() + assert lines[0] == f"Copyright {CURRENT_YEAR - 1} header" + assert lines[-1] == f"© {CURRENT_YEAR} Footer" + + +def test_update_footer_skips_file_already_at_current_year(tmp_path, caplog): + """A footer already at the current year is left untouched.""" + md_file = tmp_path / "README.md" + original = f"© {CURRENT_YEAR} Quipux\n" + md_file.write_text(original, encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + assert md_file.read_text(encoding="utf-8") == original + assert "No footer with a year to update" in caplog.text + + +def test_update_footer_skips_file_without_years(tmp_path): + """A file without any year is skipped.""" + md_file = tmp_path / "README.md" + original = "no dates here\n" + md_file.write_text(original, encoding="utf-8") + update_markdown_footers([str(md_file)], CURRENT_YEAR) + assert md_file.read_text(encoding="utf-8") == original + + +def test_update_footer_warns_on_missing_file(tmp_path, caplog): + """A nonexistent file logs a warning and is skipped.""" + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + update_markdown_footers([str(tmp_path / "missing.md")], CURRENT_YEAR) + assert "does not exist. Skipping." in caplog.text + + +def test_update_footer_logs_read_errors(tmp_path, caplog): + """An unreadable file logs an error and does not raise.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1}\n", encoding="utf-8") + md_file.chmod(0o000) + try: + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + finally: + md_file.chmod(0o644) + assert "Error reading" in caplog.text + + +def test_update_footer_logs_write_errors(tmp_path, caplog): + """A read-only file logs a write error and does not raise.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1}\n", encoding="utf-8") + md_file.chmod(0o444) + try: + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + finally: + md_file.chmod(0o644) + assert "Error writing to" in caplog.text + + +def test_collect_markdown_files_without_directory(): + """Without md_dir the provided file list is returned as is.""" + collected = collect_markdown_files(["README.md", "CONTRIBUTING.md"]) + assert set(collected) == {"README.md", "CONTRIBUTING.md"} + + +def test_collect_markdown_files_walks_directory(tmp_path): + """md_dir is walked recursively collecting only markdown files.""" + (tmp_path / "x.md").write_text("x", encoding="utf-8") + (tmp_path / "UPPER.MD").write_text("u", encoding="utf-8") + (tmp_path / "z.txt").write_text("z", encoding="utf-8") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "y.md").write_text("y", encoding="utf-8") + + collected = collect_markdown_files(["base.md"], str(tmp_path)) + assert "base.md" in collected + assert str(tmp_path / "x.md") in collected + assert str(tmp_path / "UPPER.MD") in collected + assert str(sub / "y.md") in collected + assert str(tmp_path / "z.txt") not in collected + + +def test_collect_markdown_files_warns_on_missing_directory(tmp_path, caplog): + """A nonexistent md_dir logs a warning and only files are returned.""" + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + collected = collect_markdown_files(["base.md"], str(tmp_path / "missing")) + assert collected == ["base.md"] + assert "does not exist. Skipping." in caplog.text + + +def test_main_updates_collected_files(tmp_path, monkeypatch): + """main() bumps the footer of the files referenced by the module-level args.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1} Quipux\n", encoding="utf-8") + monkeypatch.setattr( + bump_year_main, + "args", + argparse.Namespace(md_files=[str(md_file)], md_dir=None), + raising=False, + ) + bump_year_main.main() + assert f"© {CURRENT_YEAR} Quipux" in md_file.read_text(encoding="utf-8") + + +def test_main_logs_error_when_no_files(monkeypatch, caplog): + """main() logs an error when no markdown files are collected.""" + monkeypatch.setattr( + bump_year_main, + "args", + argparse.Namespace(md_files=[], md_dir=None), + raising=False, + ) + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + bump_year_main.main() + assert "No Markdown files specified" in caplog.text diff --git a/tests/test_commit_msg_version_bump.py b/tests/test_commit_msg_version_bump.py new file mode 100644 index 0000000..ba6d830 --- /dev/null +++ b/tests/test_commit_msg_version_bump.py @@ -0,0 +1,219 @@ +# tests/test_commit_msg_version_bump.py + +import subprocess +import sys +from unittest import mock + +import pytest + +import commit_msg_version_bump.main as cmvb +from commit_msg_version_bump.main import ( + add_icon_and_prepare_commit_message, + amend_commit, + bump_version, + configure_logger, + determine_version_bump, + get_current_version, + get_latest_commit_message, + get_new_version, + parse_arguments, + stage_changes, +) + + +def _write_pyproject(tmp_path, version='version = "1.2.3"'): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(f"[tool.poetry]\nname = 'x'\n{version}\n", encoding="utf-8") + return str(pyproject) + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("feat: add feature [minor candidate]", "minor"), + ("fix: solve bug [patch candidate]", "patch"), + ("refactor: rework core [major candidate]", "major"), + ("fix: solve bug [Patch Candidate]", "patch"), # case-insensitive + ("fix: [patch candidate] not at the end", None), + ("fix: solve bug", None), + ], +) +def test_determine_version_bump(message, expected): + """The candidate keyword is only honored at the end of the message.""" + assert determine_version_bump(message) == expected + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments defaults to INFO log level.""" + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + assert parse_arguments().log_level == "INFO" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("LOUD") + + +def test_get_latest_commit_message(mocker): + """The latest commit subject is returned stripped.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + return_value=mock.Mock(stdout="✨ feat: add feature\n"), + ) + assert get_latest_commit_message() == "✨ feat: add feature" + + +def test_get_latest_commit_message_failure_exits_1(mocker): + """A git failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git", stderr="boom"), + ) + with pytest.raises(SystemExit) as exc_info: + get_latest_commit_message() + assert exc_info.value.code == 1 + + +def test_get_current_version_reads_pyproject(tmp_path): + """The poetry version is read from pyproject.toml.""" + assert get_current_version(_write_pyproject(tmp_path)) == "1.2.3" + + +def test_get_current_version_missing_file_exits_1(tmp_path): + """A missing pyproject.toml exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(tmp_path / "missing.toml")) + assert exc_info.value.code == 1 + + +def test_get_current_version_invalid_toml_exits_1(tmp_path): + """An unparsable pyproject.toml exits with code 1.""" + bad = tmp_path / "pyproject.toml" + bad.write_text(":::: not toml ::::", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(bad)) + assert exc_info.value.code == 1 + + +def test_get_current_version_missing_key_exits_1(tmp_path): + """A pyproject.toml without tool.poetry.version exits with code 1.""" + bad = tmp_path / "pyproject.toml" + bad.write_text("[tool.other]\nname = 'x'\n", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(bad)) + assert exc_info.value.code == 1 + + +def test_get_new_version_reads_pyproject(tmp_path): + """The bumped version is read back from pyproject.toml.""" + assert get_new_version(_write_pyproject(tmp_path, 'version = "1.3.0"')) == "1.3.0" + + +def test_get_new_version_missing_file_exits_1(tmp_path): + """A missing pyproject.toml exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + get_new_version(str(tmp_path / "missing.toml")) + assert exc_info.value.code == 1 + + +def test_add_icon_and_prepare_commit_message(): + """The bump commit message carries the bookmark icon and both versions.""" + assert add_icon_and_prepare_commit_message("1.0.0", "1.1.0") == "🔖 Bump version: 1.0.0 → 1.1.0" + + +def test_bump_version_invokes_bump2version(mocker): + """bump2version is invoked with the requested part.""" + mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + bump_version("patch") + mock_run.assert_called_once_with(["bump2version", "patch"], check=True, encoding="utf-8") + + +def test_bump_version_failure_exits_1(mocker): + """A bump2version failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "bump2version"), + ) + with pytest.raises(SystemExit) as exc_info: + bump_version("patch") + assert exc_info.value.code == 1 + + +def test_stage_changes_invokes_git_add(mocker): + """pyproject.toml is staged for the amended commit.""" + mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + stage_changes() + mock_run.assert_called_once_with(["git", "add", "pyproject.toml"], check=True, encoding="utf-8") + + +def test_stage_changes_failure_exits_1(mocker): + """A git add failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with pytest.raises(SystemExit) as exc_info: + stage_changes() + assert exc_info.value.code == 1 + + +def test_amend_commit_invokes_git(mocker): + """The commit is amended with the bump message.""" + mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + mock_run.assert_called_once_with( + ["git", "commit", "--amend", "-m", "🔖 Bump version: 1.0.0 → 1.0.1"], + check=True, + encoding="utf-8", + ) + + +def test_amend_commit_failure_exits_1(mocker): + """A git amend failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with pytest.raises(SystemExit) as exc_info: + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + assert exc_info.value.code == 1 + + +def test_main_with_candidate_bumps_and_exits_1(tmp_path, monkeypatch, mocker): + """A candidate keyword triggers bump, stage and amend, then aborts the push.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + mocker.patch( + "commit_msg_version_bump.main.get_latest_commit_message", + return_value="fix: solve bug [patch candidate]", + ) + mocker.patch("commit_msg_version_bump.main.get_current_version", return_value="1.0.0") + mock_bump = mocker.patch("commit_msg_version_bump.main.bump_version") + mocker.patch("commit_msg_version_bump.main.get_new_version", return_value="1.0.1") + mock_stage = mocker.patch("commit_msg_version_bump.main.stage_changes") + mock_amend = mocker.patch("commit_msg_version_bump.main.amend_commit") + + with pytest.raises(SystemExit) as exc_info: + cmvb.main() + + assert exc_info.value.code == 1 + mock_bump.assert_called_once_with("patch") + mock_stage.assert_called_once() + mock_amend.assert_called_once_with("🔖 Bump version: 1.0.0 → 1.0.1") + + +def test_main_without_candidate_does_nothing(tmp_path, monkeypatch, mocker): + """Without a candidate keyword no bump happens and main returns normally.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + mocker.patch( + "commit_msg_version_bump.main.get_latest_commit_message", + return_value="fix: solve bug", + ) + mock_bump = mocker.patch("commit_msg_version_bump.main.bump_version") + + cmvb.main() + + mock_bump.assert_not_called() diff --git a/tests/test_control_commit.py b/tests/test_control_commit.py new file mode 100644 index 0000000..0514f54 --- /dev/null +++ b/tests/test_control_commit.py @@ -0,0 +1,182 @@ +# tests/test_control_commit.py + +import subprocess +import sys + +import pytest + +from control_commit.main import ( + add_icon_to_commit_message, + amend_commit, + configure_logger, + has_square_brackets, + main, + read_commit_message, + validate_commit_message, +) + + +@pytest.fixture +def commit_msg_repo(tmp_path, monkeypatch): + """Creates a fake repo layout with .git/COMMIT_EDITMSG and chdirs into it.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + + def write(message): + (tmp_path / ".git" / "COMMIT_EDITMSG").write_text(message, encoding="utf-8") + + return write + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("TRACE") + + +@pytest.mark.parametrize( + "message", + [ + "feat: add feature", + "fix(core): handle pagination", + "chore(api-v2): bump deps", + "refactor: extract provider pattern", + "docs: update readme", + ], +) +def test_validate_commit_message_valid(message, tmp_path, monkeypatch): + """Well-formed conventional commit messages are valid.""" + monkeypatch.chdir(tmp_path) + assert validate_commit_message(message) is True + + +@pytest.mark.parametrize( + "message", + [ + "feat: Add feature", # description must start lowercase + "feature: add x", # unknown type + "feat:missing space", # missing space after colon + "feat(Core): add x", # scope must be lowercase + "random text", # no structure at all + ], +) +def test_validate_commit_message_invalid(message, tmp_path, monkeypatch): + """Malformed commit messages are rejected.""" + monkeypatch.chdir(tmp_path) + assert validate_commit_message(message) is False + + +def test_validate_commit_message_allows_bump_version(): + """Version bump commits bypass the conventional structure.""" + assert validate_commit_message("Bump version: 1.0.0 → 1.0.1") is True + + +@pytest.mark.parametrize( + ("commit_type", "icon"), + [("feat", "✨"), ("fix", "🐛"), ("docs", "📝"), ("chore", "🔧"), ("test", "✅")], +) +def test_add_icon_to_commit_message(commit_type, icon): + """The icon matching the commit type is prepended.""" + message = f"{commit_type}: do something" + assert add_icon_to_commit_message(commit_type, message) == f"{icon} {message}" + + +def test_add_icon_keeps_existing_icon(): + """A message that already starts with its icon is not changed.""" + message = "✨ feat: do something" + assert add_icon_to_commit_message("feat", message) == message + + +def test_add_icon_unknown_type_returns_message(): + """An unknown commit type has no icon mapping and leaves the message as is.""" + assert add_icon_to_commit_message("wip", "wip: stuff") == "wip: stuff" + + +def test_has_square_brackets(): + """Square brackets detection for candidate markers.""" + assert has_square_brackets("feat: add x [patch candidate]") is True + assert has_square_brackets("feat: add x") is False + + +def test_read_commit_message_strips_content(tmp_path): + """The commit message file content is returned stripped.""" + msg_file = tmp_path / "COMMIT_EDITMSG" + msg_file.write_text(" feat: add feature \n", encoding="utf-8") + assert read_commit_message(str(msg_file)) == "feat: add feature" + + +def test_read_commit_message_missing_file_exits_1(tmp_path): + """A missing commit message file exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + read_commit_message(str(tmp_path / "missing")) + assert exc_info.value.code == 1 + + +def test_amend_commit_invokes_git(mocker): + """The commit is amended via git with the new message.""" + mock_run = mocker.patch("control_commit.main.subprocess.run") + amend_commit("✨ feat: add feature") + mock_run.assert_called_once_with( + ["git", "commit", "--amend", "-m", "✨ feat: add feature"], check=True + ) + + +def test_amend_commit_failure_exits_1(mocker): + """A git failure while amending exits with code 1.""" + mocker.patch( + "control_commit.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with pytest.raises(SystemExit) as exc_info: + amend_commit("✨ feat: add feature") + assert exc_info.value.code == 1 + + +def test_main_bump_version_message_exits_0(commit_msg_repo, monkeypatch): + """A version bump commit message is accepted right away.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("🔖 Bump version: 1.1.19 → 1.1.20") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + +def test_main_iconed_valid_message_exits_0(commit_msg_repo, monkeypatch): + """A message that already carries its icon and is valid exits 0.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("✨ feat: add feature") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + +def test_main_iconed_invalid_message_exits_1(commit_msg_repo, monkeypatch): + """A message with icon but invalid structure aborts the commit.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("✨ feat: Broken Description") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_main_valid_message_without_icon_amends_and_exits_1(commit_msg_repo, monkeypatch, mocker): + """A valid message without icon gets amended with the icon and aborts for review.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + mock_amend = mocker.patch("control_commit.main.amend_commit") + commit_msg_repo("feat: add feature") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_amend.assert_called_once_with("✨ feat: add feature") + + +def test_main_invalid_message_without_icon_exits_1(commit_msg_repo, monkeypatch, mocker): + """An invalid message without icon aborts without amending.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + mock_amend = mocker.patch("control_commit.main.amend_commit") + commit_msg_repo("random text") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_amend.assert_not_called() diff --git a/tests/test_crypto_controller.py b/tests/test_crypto_controller.py index e4fb69e..94d1c45 100644 --- a/tests/test_crypto_controller.py +++ b/tests/test_crypto_controller.py @@ -1,10 +1,13 @@ # tests/test_crypto_controller.py +import base64 import os import json +import sys from unittest import mock import pytest +import requests import shutil import tempfile import logging @@ -13,7 +16,18 @@ from datetime import datetime, timedelta # Import CryptoController and related functions from main.py -from crypto_controller.main import CryptoController, get_key_footprint, Footprint +import crypto_controller.main as cc_main +from crypto_controller.main import ( + CryptoController, + get_key_footprint, + Footprint, + CERT_EXPIRATION_YEARS, + fetch_private_key_password, + send_expiration_alert, + _resolve_key_pair_name, + configure_logger, + parse_arguments, +) # Configure logger for the test module @@ -174,13 +188,13 @@ def crypto_controller_fixture(temp_cert_vault: str) -> CryptoController: def test_env_variables_loaded(mock_load_dotenv, crypto_controller_fixture): """ - Test that environment variables are loaded correctly using load_dotenv. + Test that CryptoController initializes correctly with expected attributes. + load_dotenv is called at module import time so it cannot be asserted via mock here. """ logger.debug("Testing environment variable loading.") - mock_load_dotenv.assert_called_once() assert crypto_controller_fixture.cert_location is not None assert crypto_controller_fixture.key_pair_name == "test_key_pair" - logger.debug("Environment variables loaded and CryptoController initialized correctly.") + logger.debug("CryptoController initialized correctly.") def test_create_cert_vault(crypto_controller_fixture, temp_cert_vault): @@ -294,10 +308,11 @@ def test_verify_success(crypto_controller_fixture: CryptoController, temp_cert_v } logger.debug(f"Created key pair data: {key_pair_data}") - # Mock the encrypt method to return JSON string + # Mock encrypt and decrypt so verify() can round-trip without real RSA keys mocker.patch.object( crypto_controller_fixture, "encrypt", return_value=json.dumps(key_pair_data) ) + mocker.patch.object(crypto_controller_fixture, "decrypt", side_effect=lambda x: x) # Mock get_key_footprint to return matching footprints mocker.patch( "crypto_controller.main.get_key_footprint", @@ -343,10 +358,11 @@ def test_verify_missing_fields( } logger.debug(f"Created incomplete key pair data: {key_pair_data}") - # Mock the encrypt method to return JSON string + # Mock encrypt and decrypt so verify() can round-trip without real RSA keys mocker.patch.object( crypto_controller_fixture, "encrypt", return_value=json.dumps(key_pair_data) ) + mocker.patch.object(crypto_controller_fixture, "decrypt", side_effect=lambda x: x) # Write incomplete key pair data to key pair file with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: @@ -428,17 +444,17 @@ def test_get_status(crypto_controller_fixture: CryptoController, mocker, capsys) assert "Public Key Exists: Yes" in captured.out assert "Private Key Exists: Yes" in captured.out assert "Key Pair File Exists: Yes" in captured.out - assert "Key Verification: True" in captured.out + assert "Key Verification: Yes" in captured.out assert "Expiration: 2025-12-31T23:59:59" in captured.out logger.debug("Verified that get_status output is correct.") def test_fetch_private_key_password(mock_requests_get, crypto_controller_fixture: CryptoController): """ - Test fetching the private key password from a secure API endpoint. + Test fetching the private key password from the module-level function. """ - logger.debug("Testing fetch_private_key_password method.") - password = crypto_controller_fixture.fetch_private_key_password() + logger.debug("Testing fetch_private_key_password function.") + password = fetch_private_key_password() logger.debug(f"Fetched private key password: {password}") assert password == "secure_pass" mock_requests_get.assert_called_once_with( @@ -447,18 +463,18 @@ def test_fetch_private_key_password(mock_requests_get, crypto_controller_fixture "content-type": "application/json", "token_security": "mocked_secure_token", }, - timeout=5, # Changed from "5" to 5 (integer) + timeout=5, ) logger.debug("Verified that fetch_private_key_password fetched the correct password.") def test_send_expiration_alert(mock_smtp, crypto_controller_fixture: CryptoController): """ - Test sending an expiration alert email. + Test sending an expiration alert email via the module-level function. """ - logger.debug("Testing send_expiration_alert method.") + logger.debug("Testing send_expiration_alert function.") expiration_date = datetime.now() + timedelta(days=30) - crypto_controller_fixture.send_expiration_alert(expiration_date) + send_expiration_alert(expiration_date) logger.debug("Called send_expiration_alert method.") # Verify SMTP interactions @@ -513,6 +529,39 @@ def test_renew_keys_confirm_no(crypto_controller_fixture: CryptoController, mock ) +def test_resolve_key_pair_name_finds_valid_range(tmp_path): + """Finds existing .kp whose year range contains current year.""" + current_year = datetime.now().year + kp_name = f"Crypto-Key-Pair-{current_year - 1}-{current_year + 5}.kp" + (tmp_path / kp_name).write_text("dummy") + result = _resolve_key_pair_name(str(tmp_path)) + assert result == kp_name[:-3] + + +def test_resolve_key_pair_name_no_certs_fallback(tmp_path): + """Falls back to new range-based name when no valid .kp exists.""" + current_year = datetime.now().year + result = _resolve_key_pair_name(str(tmp_path)) + assert result == f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + +def test_resolve_key_pair_name_expired_range_ignored(tmp_path): + """Ignores .kp file whose range has already expired.""" + (tmp_path / "Crypto-Key-Pair-2020-2021.kp").write_text("dummy") + current_year = datetime.now().year + result = _resolve_key_pair_name(str(tmp_path)) + assert result == f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + +def test_resolve_key_pair_name_simulates_2027(tmp_path): + """In 2027, finds Crypto-Key-Pair-2026-2032 since 2026 <= 2027 <= 2032.""" + (tmp_path / "Crypto-Key-Pair-2026-2032.kp").write_text("dummy") + with patch("crypto_controller.main.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2027, 1, 1) + result = _resolve_key_pair_name(str(tmp_path)) + assert result == "Crypto-Key-Pair-2026-2032" + + def test_renew_keys_invalid_confirmation(crypto_controller_fixture: CryptoController, mocker): """ Test renewing keys with invalid user input. @@ -535,3 +584,457 @@ def test_renew_keys_invalid_confirmation(crypto_controller_fixture: CryptoContro logger.debug( "Verified that clean_cert_vault and create_keys were not called and sys.exit was called with 1." ) + + +# Extended coverage: logger configuration, footprints, vault management, +# hybrid encryption error paths, verify branches and the CLI entry point. + + +def test_configure_logger_invalid_level_raises(): + """An unknown log level raises ValueError before touching any handler.""" + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("WHISPER") + + +def test_configure_logger_sets_handlers(tmp_path, monkeypatch): + """A valid level configures rotating file and console handlers.""" + monkeypatch.chdir(tmp_path) + configure_logger("DEBUG") + assert cc_main.logger.level == logging.DEBUG + assert len(cc_main.logger.handlers) == 2 + + +def _write_valid_pem(path: str, key_type: str) -> None: + """Writes a PEM file whose body is valid base64 so footprints can be computed.""" + der_body = base64.encodebytes(b"dummy der payload for footprint tests") + if key_type == "public": + begin, end = b"-----BEGIN PUBLIC KEY-----\n", b"-----END PUBLIC KEY-----\n" + else: + begin, end = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n", + b"-----END ENCRYPTED PRIVATE KEY-----\n", + ) + with open(path, "wb") as pem_file: + pem_file.write(begin + der_body + end) + + +def test_get_key_footprint_public_and_private(temp_cert_vault): + """Footprints are generated for valid public and private PEM files.""" + public_path = os.path.join(temp_cert_vault, "key.pub") + private_path = os.path.join(temp_cert_vault, "key.key") + _write_valid_pem(public_path, "public") + _write_valid_pem(private_path, "private") + + public_fp = get_key_footprint(public_path, "public") + private_fp = get_key_footprint(private_path, "private") + + for footprint in (public_fp, private_fp): + assert len(footprint.sha1) == 40 + assert len(footprint.sha256) == 64 + + +def test_get_key_footprint_invalid_type_raises(temp_cert_vault): + """An unknown key type raises ValueError.""" + path = os.path.join(temp_cert_vault, "key.pub") + create_dummy_pem(path, "public") + with pytest.raises(ValueError, match="Invalid key type"): + get_key_footprint(path, "ssh") + + +def test_get_key_footprint_invalid_pem_raises(temp_cert_vault): + """A file without PEM markers raises IOError.""" + path = os.path.join(temp_cert_vault, "not_a_key.txt") + with open(path, "w") as plain_file: + plain_file.write("plain text, no markers") + with pytest.raises(IOError, match="Not a valid PEM file"): + get_key_footprint(path, "public") + + +def test_check_cert_vault_exists(crypto_controller_fixture, temp_cert_vault): + """The vault existence check reflects the filesystem state.""" + assert crypto_controller_fixture.check_cert_vault_exists() is True + shutil.rmtree(temp_cert_vault) + assert crypto_controller_fixture.check_cert_vault_exists() is False + crypto_controller_fixture.create_cert_vault() # restore for fixture teardown + + +def test_create_cert_vault_failure_raises(crypto_controller_fixture, mocker): + """A filesystem error while creating the vault is re-raised.""" + mocker.patch("crypto_controller.main.os.makedirs", side_effect=PermissionError("denied")) + with pytest.raises(PermissionError): + crypto_controller_fixture.create_cert_vault() + + +def test_clean_cert_vault_recreates_empty_vault(crypto_controller_fixture, temp_cert_vault): + """Cleaning removes vault contents and recreates the directory.""" + leftover = os.path.join(temp_cert_vault, "old.key") + with open(leftover, "w") as old_file: + old_file.write("old") + crypto_controller_fixture.clean_cert_vault() + assert os.path.exists(temp_cert_vault) + assert os.listdir(temp_cert_vault) == [] + + +def test_clean_cert_vault_failure_raises(crypto_controller_fixture, mocker): + """A filesystem error while cleaning the vault is re-raised.""" + mocker.patch("crypto_controller.main.shutil.rmtree", side_effect=OSError("busy")) + with pytest.raises(OSError): + crypto_controller_fixture.clean_cert_vault() + + +def test_encrypt_hybrid_failure_raises(crypto_controller_fixture, mocker): + """A key loading failure during encryption is re-raised.""" + mocker.patch.object(crypto_controller_fixture, "load_keys", side_effect=RuntimeError("no keys")) + with pytest.raises(RuntimeError): + crypto_controller_fixture.encrypt_hybrid("secret") + + +def test_decrypt_hybrid_bad_format_exits_1(crypto_controller_fixture, mocker): + """Encrypted data without the three-part format exits with code 1.""" + mocker.patch.object( + crypto_controller_fixture, "load_keys", return_value=(mock.Mock(), mock.Mock()) + ) + with pytest.raises(SystemExit) as exc_info: + crypto_controller_fixture.decrypt_hybrid("not-three-parts") + assert exc_info.value.code == 1 + + +def test_decrypt_reraises_unexpected_errors(crypto_controller_fixture, mocker): + """decrypt re-raises when decrypt_hybrid fails without exiting.""" + mocker.patch.object(crypto_controller_fixture, "decrypt_hybrid", side_effect=ValueError("boom")) + with pytest.raises(ValueError): + crypto_controller_fixture.decrypt("anything") + + +def _prepare_verify_data(controller, mocker, **overrides): + """Writes a key pair file with identity-decrypted JSON data for verify tests.""" + create_dummy_pem(controller.public_key_file, "public") + create_dummy_pem(controller.private_key_file, "private") + key_pair_data = { + "public_key_file": controller.public_key_file, + "public_fp_sha1": "sha1", + "public_fp_sha256": "sha256", + "private_key_file": controller.private_key_file, + "private_fp_sha1": "sha1", + "private_fp_sha256": "sha256", + "key_pair_file": controller.key_pair_file, + "creation_date": datetime.now().strftime("%d%m%Y%H%M%S"), + "expiration_date": (datetime.now() + timedelta(days=365)).strftime("%d%m%Y%H%M%S"), + } + key_pair_data.update(overrides) + mocker.patch.object(controller, "decrypt", side_effect=lambda x: x) + with open(controller.key_pair_file, "w") as kp_file: + kp_file.write(json.dumps(key_pair_data)) + return key_pair_data + + +def test_verify_missing_public_key_file(crypto_controller_fixture, mocker): + """verify fails when the referenced public key file does not exist.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + public_key_file=crypto_controller_fixture.public_key_file + ".missing", + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_missing_private_key_file(crypto_controller_fixture, mocker): + """verify fails when the referenced private key file does not exist.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + private_key_file=crypto_controller_fixture.private_key_file + ".missing", + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_public_fingerprint_mismatch(crypto_controller_fixture, mocker): + """verify fails when the public key fingerprints do not match.""" + _prepare_verify_data(crypto_controller_fixture, mocker) + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("other", "other") + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_private_fingerprint_mismatch(crypto_controller_fixture, mocker): + """verify fails when the private key fingerprints do not match.""" + _prepare_verify_data(crypto_controller_fixture, mocker) + mocker.patch( + "crypto_controller.main.get_key_footprint", + side_effect=[Footprint("sha1", "sha256"), Footprint("other", "other")], + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_key_pair_path_mismatch(crypto_controller_fixture, mocker): + """verify fails when the key pair file path differs from the expected one.""" + _prepare_verify_data(crypto_controller_fixture, mocker, key_pair_file="/elsewhere/kp.kp") + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("sha1", "sha256") + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_expired_key_pair(crypto_controller_fixture, mocker): + """verify fails when the key pair expiration date is in the past.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + expiration_date=(datetime.now() - timedelta(days=1)).strftime("%d%m%Y%H%M%S"), + ) + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("sha1", "sha256") + ) + assert crypto_controller_fixture.verify() is False + + +def test_get_expiration_returns_iso_date(crypto_controller_fixture, mocker): + """The expiration date is returned in ISO format.""" + with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: + kp_file.write("encrypted") + mocker.patch.object( + crypto_controller_fixture, + "decrypt", + return_value=json.dumps({"expiration_date": "31122026235959"}), + ) + assert crypto_controller_fixture.get_expiration() == "2026-12-31T23:59:59" + + +def test_get_expiration_missing_field_returns_unknown(crypto_controller_fixture, mocker): + """A key pair payload without expiration date returns 'Unknown'.""" + with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: + kp_file.write("encrypted") + mocker.patch.object(crypto_controller_fixture, "decrypt", return_value="{}") + assert crypto_controller_fixture.get_expiration() == "Unknown" + + +def test_get_expiration_failure_returns_unknown(crypto_controller_fixture): + """A missing key pair file returns 'Unknown'.""" + assert crypto_controller_fixture.get_expiration() == "Unknown" + + +def test_create_keys_skips_when_verification_passes(crypto_controller_fixture, mocker): + """create_keys is a no-op when the existing keys verify correctly.""" + mocker.patch.object(crypto_controller_fixture, "verify", return_value=True) + mock_generate = mocker.patch("crypto_controller.main.rsa.generate_private_key") + crypto_controller_fixture.create_keys() + mock_generate.assert_not_called() + + +def test_create_keys_failure_raises(crypto_controller_fixture, mocker): + """A key generation failure is re-raised.""" + mocker.patch.object(crypto_controller_fixture, "verify", return_value=False) + mocker.patch( + "crypto_controller.main.rsa.generate_private_key", side_effect=RuntimeError("rsa boom") + ) + with pytest.raises(RuntimeError): + crypto_controller_fixture.create_keys() + + +def test_renew_keys_failure_exits_1(crypto_controller_fixture, mocker): + """A failure while renewing keys exits with code 1.""" + mocker.patch("crypto_controller.main.input", return_value="yes") + mocker.patch.object( + crypto_controller_fixture, "clean_cert_vault", side_effect=RuntimeError("disk") + ) + with pytest.raises(SystemExit) as exc_info: + crypto_controller_fixture.renew_keys() + assert exc_info.value.code == 1 + + +def test_load_keys_auto_creates_missing_keys(crypto_controller_fixture): + """load_keys regenerates real key material when the vault is empty.""" + public_key, private_key = crypto_controller_fixture.load_keys() + assert public_key is not None + assert private_key is not None + # A second call now loads the freshly created keys directly + public_key_again, private_key_again = crypto_controller_fixture.load_keys() + assert public_key_again is not None + assert private_key_again is not None + + +def test_get_status_failure_prints_fallback(crypto_controller_fixture, mocker, capsys): + """A status failure prints the fallback message instead of raising.""" + mocker.patch.object( + crypto_controller_fixture, "check_cert_vault_exists", side_effect=RuntimeError("boom") + ) + crypto_controller_fixture.get_status() + assert "Failed to retrieve status" in capsys.readouterr().out + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments resolves defaults for vault location and key pair name.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", "status"]) + args = parse_arguments() + assert args.operation == "status" + assert args.value is None + assert args.cert_location.endswith("certs") + assert args.key_pair_name.startswith("Crypto-Key-Pair-") + assert args.log_level == "INFO" + + +def test_parse_arguments_custom_values(monkeypatch): + """parse_arguments honors explicit values for every option.""" + monkeypatch.setattr( + sys, + "argv", + [ + "crypto_controller", + "encrypt", + "secret", + "--cert-location", + "/x", + "--key-pair-name", + "kp", + "--log-level", + "DEBUG", + ], + ) + args = parse_arguments() + assert args.operation == "encrypt" + assert args.value == "secret" + assert args.cert_location == "/x" + assert args.key_pair_name == "kp" + assert args.log_level == "DEBUG" + + +def test_parse_arguments_invalid_operation_exits_2(monkeypatch): + """An unknown operation makes argparse exit with code 2.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", "explode"]) + with pytest.raises(SystemExit) as exc_info: + parse_arguments() + assert exc_info.value.code == 2 + + +def test_fetch_private_key_password_falls_back_to_env(mocker, monkeypatch): + """API failures fall back to the KP_PASSWORD environment variable.""" + mocker.patch( + "crypto_controller.main.requests.get", + side_effect=requests.exceptions.RequestException("api down"), + ) + monkeypatch.setenv("KP_PASSWORD", "env_pass") + assert fetch_private_key_password() == "env_pass" + + +def test_send_expiration_alert_incomplete_config_skips_send(mock_smtp, monkeypatch): + """Without full SMTP configuration no email is attempted.""" + monkeypatch.delenv("SMTP_SERVER") + send_expiration_alert(datetime.now() + timedelta(days=5)) + mock_smtp.assert_not_called() + + +def test_send_expiration_alert_smtp_failure_does_not_raise(mock_smtp): + """SMTP failures are logged without raising.""" + mock_smtp.side_effect = RuntimeError("smtp down") + send_expiration_alert(datetime.now() + timedelta(days=5)) + + +# CLI main() coverage + + +@pytest.fixture +def cli_controller(tmp_path, monkeypatch, mocker): + """Mocks the controller and password fetch for CLI main() tests.""" + monkeypatch.chdir(tmp_path) + mocker.patch("crypto_controller.main.fetch_private_key_password", return_value="pwd") + mock_controller = mock.Mock() + mocker.patch("crypto_controller.main.CryptoController", return_value=mock_controller) + return mock_controller + + +def _run_cli(monkeypatch, *argv): + """Runs the module CLI with the given arguments.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", *argv]) + cc_main.main() + + +def test_main_init_with_existing_vault(cli_controller, monkeypatch): + """init with an existing vault only creates new keys.""" + cli_controller.check_cert_vault_exists.return_value = True + _run_cli(monkeypatch, "init") + cli_controller.create_keys.assert_called_once() + cli_controller.create_cert_vault.assert_not_called() + + +def test_main_init_creates_vault(cli_controller, monkeypatch): + """init without a vault creates the vault and the keys.""" + cli_controller.check_cert_vault_exists.return_value = False + _run_cli(monkeypatch, "init") + cli_controller.create_cert_vault.assert_called_once() + cli_controller.create_keys.assert_called_once() + + +def test_main_renew_with_existing_vault(cli_controller, monkeypatch): + """renew with an existing vault delegates to renew_keys.""" + cli_controller.check_cert_vault_exists.return_value = True + _run_cli(monkeypatch, "renew") + cli_controller.renew_keys.assert_called_once() + + +def test_main_renew_without_vault_creates_keys(cli_controller, monkeypatch): + """renew without a vault creates the vault and the keys.""" + cli_controller.check_cert_vault_exists.return_value = False + _run_cli(monkeypatch, "renew") + cli_controller.create_cert_vault.assert_called_once() + cli_controller.create_keys.assert_called_once() + + +def test_main_encrypt_without_value_exits_1(cli_controller, monkeypatch): + """encrypt without a value exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "encrypt") + assert exc_info.value.code == 1 + + +def test_main_encrypt_prints_result(cli_controller, monkeypatch, capsys): + """encrypt prints the encrypted value when verification passes.""" + cli_controller.verify.return_value = True + cli_controller.encrypt.return_value = "ENCRYPTED" + _run_cli(monkeypatch, "encrypt", "secret") + assert "ENCRYPTED" in capsys.readouterr().out + + +def test_main_encrypt_with_failed_verification_exits_1(cli_controller, monkeypatch): + """encrypt aborts with code 1 when verification fails.""" + cli_controller.verify.return_value = False + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "encrypt", "secret") + assert exc_info.value.code == 1 + + +def test_main_decrypt_without_value_exits_1(cli_controller, monkeypatch): + """decrypt without a value exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "decrypt") + assert exc_info.value.code == 1 + + +def test_main_decrypt_prints_result(cli_controller, monkeypatch, capsys): + """decrypt prints the plain value when verification passes.""" + cli_controller.verify.return_value = True + cli_controller.decrypt.return_value = "PLAIN" + _run_cli(monkeypatch, "decrypt", "payload") + assert "PLAIN" in capsys.readouterr().out + + +def test_main_decrypt_with_failed_verification_exits_1(cli_controller, monkeypatch): + """decrypt aborts with code 1 when verification fails.""" + cli_controller.verify.return_value = False + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "decrypt", "payload") + assert exc_info.value.code == 1 + + +def test_main_status_invokes_get_status(cli_controller, monkeypatch): + """status delegates to get_status.""" + _run_cli(monkeypatch, "status") + cli_controller.get_status.assert_called_once() + + +def test_main_operation_errors_are_logged_not_raised(cli_controller, monkeypatch, caplog): + """Operation failures are logged and main() returns normally.""" + cli_controller.check_cert_vault_exists.side_effect = RuntimeError("boom") + with caplog.at_level(logging.ERROR, logger="__main__"): + _run_cli(monkeypatch, "init") + assert "Operation 'init' failed" in caplog.text diff --git a/tests/test_format_yaml.py b/tests/test_format_yaml.py new file mode 100644 index 0000000..1a5c49a --- /dev/null +++ b/tests/test_format_yaml.py @@ -0,0 +1,120 @@ +# tests/test_format_yaml.py + +import sys + +import pytest + +from format_yaml.main import format_yaml_file, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_adds_document_start_marker(tmp_path, capsys): + """A yaml file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "workflow.yaml", "key: value\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "Added '---' at the beginning" in capsys.readouterr().out + + +def test_keeps_existing_marker(tmp_path, capsys): + """A yaml file that already starts with '---' is not modified.""" + file_path = _write(tmp_path, "workflow.yaml", "---\nkey: value\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content == "---\nkey: value\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_removes_extra_spaces_inside_brackets(tmp_path, capsys): + """Spaces right after '[' / '(' and before ']' / ')' are removed.""" + file_path = _write(tmp_path, "workflow.yaml", "---\nlist: [ a, b ]\ncmd: ( x )\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert "list: [a, b]" in content + assert "cmd: (x)" in content + assert "Removed extra spaces inside brackets" in capsys.readouterr().out + + +def test_splits_long_line_inside_run_block(tmp_path, capsys): + """Long lines inside a 'run: |' block are split and the split reaches the output file.""" + long_line = " " + ("word " * 30).strip() # 153 chars, indented + content = f"---\nrun: |\n{long_line}\ndone: true\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + lines = result.splitlines() + assert len(lines) == 5 # ---, run: |, two split parts, done: true + assert lines[2].startswith(" word") + assert lines[3].startswith(" word") + assert result.count("word") == 30 # no content lost in the split + assert "Split long line in 'run' block" in capsys.readouterr().out + + +def test_force_splits_run_block_line_without_spaces(tmp_path, capsys): + """A run-block line with no spaces is hard-split at column 120.""" + long_line = "\t" + "a" * 130 # tab keeps it inside the run block + content = f"---\nrun: |\n{long_line}\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + lines = result.splitlines() + assert lines[2] == long_line[:120] + assert lines[3] == " " + long_line[120:] + assert "Split long line in 'run' block" in capsys.readouterr().out + + +def test_long_line_outside_run_block_is_untouched(tmp_path): + """Lines longer than 120 chars outside run blocks are not split.""" + long_line = "key: " + "a" * 130 + file_path = _write(tmp_path, "workflow.yaml", f"---\n{long_line}\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content == f"---\n{long_line}\n" + + +def test_non_indented_line_exits_run_block(tmp_path): + """A non-indented line closes the run block, so later long lines are untouched.""" + long_line = " " + ("word " * 30).strip() + content = f"---\nrun: |\n short\ndone: true\n{long_line}\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert long_line in result.splitlines() # still one single line + + +def test_main_processes_all_files(monkeypatch, tmp_path, capsys): + """main() formats every file passed, not only the first one.""" + file_a = _write(tmp_path, "a.yaml", "key: a\n") + file_b = _write(tmp_path, "b.yaml", "key: b\n") + monkeypatch.setattr(sys, "argv", ["format_yaml", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert (tmp_path / "a.yaml").read_text(encoding="utf-8").startswith("---\n") + assert (tmp_path / "b.yaml").read_text(encoding="utf-8").startswith("---\n") + assert "All checks passed successfully." in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["format_yaml"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yaml") + monkeypatch.setattr(sys, "argv", ["format_yaml", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out diff --git a/tests/test_format_yml.py b/tests/test_format_yml.py new file mode 100644 index 0000000..b4de539 --- /dev/null +++ b/tests/test_format_yml.py @@ -0,0 +1,109 @@ +# tests/test_format_yml.py + +import sys + +import pytest + +from format_yml.main import format_yml_file, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_adds_document_start_marker(tmp_path, capsys): + """A yml file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "config.yml", "key: value\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "Added '---' at the beginning" in capsys.readouterr().out + + +def test_keeps_existing_marker(tmp_path, capsys): + """A yml file that already starts with '---' is not modified.""" + file_path = _write(tmp_path, "config.yml", "---\nkey: value\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content == "---\nkey: value\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_removes_extra_spaces_inside_brackets(tmp_path, capsys): + """Spaces right after '[' / '(' and before ']' / ')' are removed.""" + file_path = _write(tmp_path, "config.yml", "---\nlist: [ a, b ]\ncmd: ( x )\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert "list: [a, b]" in content + assert "cmd: (x)" in content + assert "Removed extra spaces inside brackets" in capsys.readouterr().out + + +def test_splits_long_line_and_terminates(tmp_path, capsys): + """Lines longer than 120 chars are split into ' \\' continuations and the loop ends.""" + long_line = "key: " + ("abc " * 40).strip() # 164 chars with plenty of spaces + file_path = _write(tmp_path, "config.yml", f"---\n{long_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + lines = content.splitlines() + assert len(lines) >= 3 # marker + at least two split parts + assert lines[1].endswith(" \\") + assert all(len(line) <= 122 for line in lines) + assert content.count("abc") == 40 # no content lost in the split + assert "Split long line" in capsys.readouterr().out + + +def test_force_splits_long_line_without_spaces(tmp_path, capsys): + """A long line without spaces is hard-split at column 120.""" + long_line = "key" + "a" * 130 + file_path = _write(tmp_path, "config.yml", f"---\n{long_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + lines = content.splitlines() + assert lines[1] == long_line[:120] + " \\" + assert lines[2].lstrip() == long_line[120:] + assert "Force split long line" in capsys.readouterr().out + + +def test_line_at_exactly_120_chars_is_untouched(tmp_path): + """A line of exactly 120 chars is not split.""" + exact_line = "k: " + "a" * 117 + assert len(exact_line) == 120 + file_path = _write(tmp_path, "config.yml", f"---\n{exact_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content == f"---\n{exact_line}\n" + + +def test_main_processes_all_files(monkeypatch, tmp_path, capsys): + """main() formats every file passed, not only the first one.""" + file_a = _write(tmp_path, "a.yml", "key: a\n") + file_b = _write(tmp_path, "b.yml", "key: b\n") + monkeypatch.setattr(sys, "argv", ["format_yml", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert (tmp_path / "a.yml").read_text(encoding="utf-8").startswith("---\n") + assert (tmp_path / "b.yml").read_text(encoding="utf-8").startswith("---\n") + assert "All checks passed successfully." in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["format_yml"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yml") + monkeypatch.setattr(sys, "argv", ["format_yml", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out diff --git a/tests/test_generate_changelog.py b/tests/test_generate_changelog.py new file mode 100644 index 0000000..8f7c7eb --- /dev/null +++ b/tests/test_generate_changelog.py @@ -0,0 +1,383 @@ +# tests/test_generate_changelog.py + +import logging +import subprocess +import sys +from collections import OrderedDict +from datetime import datetime, timezone + +import pytest + +import generate_changelog.main as gcl +from generate_changelog.main import ( + compare_versions, + fetch_tags, + generate_changelog_entry, + generate_full_changelog, + get_all_commits, + get_commits_between_tags, + get_commits_since_last_tag, + get_sorted_tags, + get_tag_date, + get_tag_dates, + is_noise_commit, + parse_arguments, + parse_commits, + parse_version, + update_changelog, +) + +TODAY_UTC = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments defaults to INFO log level.""" + monkeypatch.setattr(sys, "argv", ["generate_changelog"]) + assert parse_arguments().log_level == "INFO" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + gcl.configure_logger("CHATTY") + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("v1.2.3", (1, 2, 3)), + ("1.0.8-test", (1, 0, 8)), + ("v10.20.30", (10, 20, 30)), + ("garbage", (0, 0, 0)), + ("1.2", (0, 0, 0)), + ], +) +def test_parse_version(version, expected): + """Version strings are parsed into numeric tuples, invalid ones become zeros.""" + assert parse_version(version) == expected + + +@pytest.mark.parametrize( + ("v1", "v2", "expected"), + [ + ("v2.0.0", "v1.9.9", 1), + ("v1.0.0", "v1.0.1", -1), + ("1.2.3", "1.2.3", 0), + ("v1.10.0", "v1.9.0", 1), # numeric, not lexicographic + ("v1.0.10", "v1.0.9", 1), + ("v1.1.0", "v1.2.0", -1), + ], +) +def test_compare_versions(v1, v2, expected): + """Semantic comparison across major, minor and patch.""" + assert compare_versions(v1, v2) == expected + + +@pytest.mark.parametrize( + ("commit", "expected"), + [ + ("🔖 Bump version: 1.0.0 → 1.0.1", True), + ("Merge branch 'dev' into testing", True), + ("Merge pull request #12 from fork/dev", True), + ("feat: add feature", False), + ], +) +def test_is_noise_commit(commit, expected): + """Bump and merge commits are noise; regular commits are not.""" + assert is_noise_commit(commit) is expected + + +def test_parse_commits_categorizes_by_type(): + """Commits are categorized, emojis stripped, noise skipped, extras collected.""" + commits = [ + "✨ feat(core): add support [minor candidate]", + "fix: solve bug", + "random words", + "Merge branch 'dev'", + ] + changelog, non_conforming = parse_commits(commits) + assert changelog["### Features"] == ["- **core**: add support (`minor candidate`)"] + assert changelog["### Bug Fixes"] == ["- solve bug"] + assert "### Chores" not in changelog # empty sections are dropped + assert non_conforming == ["random words"] + + +def test_parse_commits_empty_input(): + """No commits produce an empty changelog and no leftovers.""" + changelog, non_conforming = parse_commits([]) + assert changelog == {} + assert non_conforming == [] + + +def test_generate_changelog_entry_with_date(): + """The entry renders header, sections and other changes.""" + entry = generate_changelog_entry( + "1.1.0", + {"### Features": ["- **core**: add support"]}, + ["mystery commit"], + "2026-01-05", + ) + assert entry.startswith("## [1.1.0] - 2026-01-05\n") + assert "### Features\n- **core**: add support\n" in entry + assert "### Other Changes\n- mystery commit\n" in entry + + +def test_generate_changelog_entry_defaults_to_today(): + """Without an explicit date the entry is stamped with today (UTC).""" + entry = generate_changelog_entry("1.0.0", {}, []) + assert entry.startswith(f"## [1.0.0] - {TODAY_UTC}") + + +def test_generate_full_changelog_orders_latest_first_and_skips_empty(): + """Versions are rendered newest first; tagless versions without commits are skipped.""" + commits_dict = OrderedDict( + [("1.0.0", ["feat: first"]), ("1.1.0", ["fix: second"]), ("2.0.0", [])] + ) + tag_dates = {"1.0.0": "2026-01-01", "1.1.0": "2026-02-01"} + content = generate_full_changelog(commits_dict, tag_dates) + assert "## [2.0.0]" not in content + assert content.index("## [1.1.0] - 2026-02-01") < content.index("## [1.0.0] - 2026-01-01") + + +def test_fetch_tags_invokes_git(mocker): + """Tags are fetched from the remote.""" + mock_output = mocker.patch("generate_changelog.main.subprocess.check_output") + fetch_tags() + mock_output.assert_called_once_with(["git", "fetch", "--tags"]) + + +def test_fetch_tags_failure_only_warns(mocker, caplog): + """A fetch failure logs a warning and does not raise.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with caplog.at_level(logging.WARNING, logger="generate_changelog.main"): + fetch_tags() + assert "Could not fetch Git tags" in caplog.text + + +def test_get_tag_date(mocker): + """The tag date is extracted from the git log output.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value="2026-01-02 10:00:00 -0500\n", + ) + assert get_tag_date("v1.0.0") == "2026-01-02" + + +def test_get_tag_date_failure_falls_back_to_today(mocker): + """A git failure falls back to today's date.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_tag_date("v1.0.0") == TODAY_UTC + + +def test_get_sorted_tags_filters_and_sorts_semantically(mocker): + """Only vX.Y.Z tags are kept, sorted numerically ascending.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value="v1.0.10\nv1.0.2\nv2.0.0\nfoo\nv1.0.2-test\n", + ) + assert get_sorted_tags() == ["v1.0.2", "v1.0.10", "v2.0.0"] + + +def test_get_sorted_tags_without_semantic_tags_returns_empty(mocker, caplog): + """Without semantic tags an empty list is returned with a warning.""" + mocker.patch("generate_changelog.main.subprocess.check_output", return_value="foo\nbar\n") + with caplog.at_level(logging.WARNING, logger="generate_changelog.main"): + assert get_sorted_tags() == [] + assert "No semantic Git tags found" in caplog.text + + +def test_get_sorted_tags_failure_returns_empty(mocker): + """A git failure returns an empty list.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_sorted_tags() == [] + + +def test_get_commits_between_tags(mocker): + """Commits between two tags are listed using the tag range.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\nfix: b\n", + ) + assert get_commits_between_tags("v1.0.0", "v1.1.0") == ["feat: a", "fix: b"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0..v1.1.0", "--pretty=format:%s"]) + + +def test_get_commits_between_tags_without_old_tag(mocker): + """Without an old tag, every commit up to the new tag is listed.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\n", + ) + assert get_commits_between_tags("", "v1.0.0") == ["feat: a"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0", "--pretty=format:%s"]) + + +def test_get_commits_between_tags_failure_returns_empty(mocker): + """A git failure returns an empty list.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_commits_between_tags("v1.0.0", "v1.1.0") == [] + + +def test_get_commits_since_last_tag_without_tags(mocker): + """Without tags, all repository commits are returned.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\nfix: b\n", + ) + assert get_commits_since_last_tag([]) == ["feat: a", "fix: b"] + mock_output.assert_called_once_with(["git", "log", "--pretty=format:%s"]) + + +def test_get_commits_since_last_tag_uses_latest_tag(mocker): + """With tags, only commits after the latest tag are returned.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"fix: pending\n", + ) + assert get_commits_since_last_tag(["v0.9.0", "v1.0.0"]) == ["fix: pending"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0..HEAD", "--pretty=format:%s"]) + + +def test_get_commits_since_last_tag_failures_return_empty(mocker): + """Git failures return an empty list on both paths.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_commits_since_last_tag([]) == [] + assert get_commits_since_last_tag(["v1.0.0"]) == [] + + +def test_get_all_commits_chains_tag_ranges(mocker): + """Each tag collects the commits since the previous one, keyed without 'v'.""" + mock_between = mocker.patch( + "generate_changelog.main.get_commits_between_tags", + side_effect=[["feat: a"], ["fix: b"]], + ) + commits_dict = get_all_commits(["v1.0.0", "v1.1.0"]) + assert list(commits_dict.keys()) == ["1.0.0", "1.1.0"] + assert commits_dict["1.0.0"] == ["feat: a"] + assert commits_dict["1.1.0"] == ["fix: b"] + assert mock_between.call_args_list[0].args == ("", "v1.0.0") + assert mock_between.call_args_list[1].args == ("v1.0.0", "v1.1.0") + + +def test_get_tag_dates_includes_unreleased(mocker): + """Tag dates are mapped without the 'v' prefix plus an Unreleased entry.""" + mocker.patch("generate_changelog.main.get_tag_date", return_value="2026-01-01") + dates = get_tag_dates(["v1.0.0"]) + assert dates["1.0.0"] == "2026-01-01" + assert dates["Unreleased"] == TODAY_UTC + + +def test_update_changelog_creates_missing_file(tmp_path, monkeypatch): + """A missing CHANGELOG.md is created with the new content.""" + monkeypatch.chdir(tmp_path) + assert update_changelog("## [1.0.0] - 2026-01-01\n") is True + assert (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") == "## [1.0.0] - 2026-01-01\n" + + +def test_update_changelog_skips_identical_content(tmp_path, monkeypatch): + """Whitespace-only differences do not rewrite the changelog.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## [1.0.0] - 2026-01-01\n\n- a\n", encoding="utf-8") + assert update_changelog("## [1.0.0] - 2026-01-01\n- a") is False + + +def test_update_changelog_overwrites_on_changes(tmp_path, monkeypatch): + """Real content changes overwrite the existing changelog.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## [1.0.0] - 2026-01-01\n", encoding="utf-8") + assert update_changelog("## [1.1.0] - 2026-02-01\n") is True + assert "1.1.0" in (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") + + +def test_update_changelog_read_error_returns_false(tmp_path, monkeypatch): + """An unreadable CHANGELOG.md aborts the update returning False.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").mkdir() # a directory cannot be read as a file + assert update_changelog("content") is False + + +def test_update_changelog_write_error_returns_false(tmp_path, monkeypatch): + """An unwritable CHANGELOG.md aborts the update returning False.""" + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("old content\n", encoding="utf-8") + changelog.chmod(0o444) + try: + assert update_changelog("new content\n") is False + finally: + changelog.chmod(0o644) + + +def test_main_generates_and_updates(mocker): + """main() builds the changelog from tags and hands it to update_changelog.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) + mocker.patch( + "generate_changelog.main.get_tag_dates", + return_value={"1.0.0": "2026-01-01", "Unreleased": TODAY_UTC}, + ) + mocker.patch( + "generate_changelog.main.get_all_commits", + return_value=OrderedDict([("1.0.0", ["feat: first"])]), + ) + mock_update = mocker.patch("generate_changelog.main.update_changelog", return_value=True) + + gcl.main() + + mock_update.assert_called_once() + content = mock_update.call_args.args[0] + assert "## [1.0.0] - 2026-01-01" in content + assert "- first" in content + + +def test_main_without_commits_does_not_update(mocker, caplog): + """main() skips the update when there is nothing to include.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) + mocker.patch("generate_changelog.main.get_tag_dates", return_value={"Unreleased": TODAY_UTC}) + mocker.patch( + "generate_changelog.main.get_all_commits", return_value=OrderedDict([("1.0.0", [])]) + ) + mock_update = mocker.patch("generate_changelog.main.update_changelog") + + with caplog.at_level(logging.INFO, logger="generate_changelog.main"): + gcl.main() + + mock_update.assert_not_called() + assert "No commits found" in caplog.text + + +def test_main_logs_when_no_update_needed(mocker, caplog): + """main() reports when the changelog content did not change.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) + mocker.patch( + "generate_changelog.main.get_tag_dates", + return_value={"1.0.0": "2026-01-01", "Unreleased": TODAY_UTC}, + ) + mocker.patch( + "generate_changelog.main.get_all_commits", + return_value=OrderedDict([("1.0.0", ["feat: first"])]), + ) + mocker.patch("generate_changelog.main.update_changelog", return_value=False) + + with caplog.at_level(logging.INFO, logger="generate_changelog.main"): + gcl.main() + + assert "Changelog was not updated" in caplog.text diff --git a/tests/test_init_security_config.py b/tests/test_init_security_config.py new file mode 100644 index 0000000..0844f68 --- /dev/null +++ b/tests/test_init_security_config.py @@ -0,0 +1,195 @@ +# tests/test_init_security_config.py + +import logging +import string +import sys + +import pytest + +import init_security_config.main as isc +from init_security_config.main import ArgumentParser, LoggerConfigurator, VariableReplacer + +ALNUM = string.ascii_letters + string.digits +SPECIALS = ALNUM + "!#%*-_=+;,." + + +@pytest.fixture +def replacer(): + """VariableReplacer wired to a quiet test logger.""" + return VariableReplacer(logging.getLogger("test_init_security_config")) + + +def test_generate_random_string_chars(replacer): + """'Chars' produces alphanumeric strings of the requested length.""" + value = replacer.generate_random_string(16, "Chars") + assert len(value) == 16 + assert all(char in ALNUM for char in value) + + +def test_generate_random_string_with_specials(replacer): + """'Chars-with-specials' draws from the extended character set.""" + value = replacer.generate_random_string(24, "Chars-with-specials") + assert len(value) == 24 + assert all(char in SPECIALS for char in value) + + +def test_generate_random_string_unknown_type_defaults_to_chars(replacer): + """An unknown chars type falls back to alphanumeric.""" + value = replacer.generate_random_string(8, "Unknown") + assert len(value) == 8 + assert all(char in ALNUM for char in value) + + +def test_replace_placeholders_random(replacer): + """ placeholders are replaced by generated strings.""" + line = replacer.replace_placeholders("TOKEN=<8 (Chars)>") + assert "<" not in line + assert len(line) == len("TOKEN=") + 8 + + +def test_replace_placeholders_defined_variable(replacer): + """ placeholders resolve against collected variables.""" + replacer.variables["FOO"] = "bar" + assert replacer.replace_placeholders("value: ") == "value: bar" + + +def test_replace_placeholders_undefined_variable_left_as_is(replacer): + """Undefined placeholders stay untouched.""" + assert replacer.replace_placeholders("value: ") == "value: " + + +def test_remove_inline_comments_keeps_full_line_comments(replacer): + """Full-line comments are preserved.""" + assert replacer.remove_inline_comments("# full comment") == "# full comment" + + +def test_remove_inline_comments_strips_trailing_comment(replacer): + """Inline comments after values are removed.""" + assert replacer.remove_inline_comments("KEY=value # note") == "KEY=value" + + +def test_remove_inline_comments_respects_quotes(replacer): + """Hashes inside quotes are not treated as comments.""" + assert replacer.remove_inline_comments("KEY='a # b'") == "KEY='a # b'" + assert replacer.remove_inline_comments('KEY="a # b" # real') == 'KEY="a # b"' + + +def test_clean_spaces(replacer): + """Surrounding and repeated whitespace is collapsed.""" + assert replacer.clean_spaces(" a b ") == "a b" + + +def test_collect_variables_quoted_definition(replacer): + """A quoted variable definition is collected and reconstructed.""" + line, is_def = replacer.collect_variables('TOKEN="abc"') + assert is_def is True + assert line == 'TOKEN="abc"' + assert replacer.variables["TOKEN"] == "abc" + + +def test_collect_variables_unquoted_definition(replacer): + """An unquoted variable definition is collected.""" + line, is_def = replacer.collect_variables("TOKEN=abc") + assert is_def is True + assert line == "TOKEN=abc" + assert replacer.variables["TOKEN"] == "abc" + + +def test_collect_variables_resolves_placeholders_in_value(replacer): + """Placeholders inside the defined value are resolved before storing.""" + line, is_def = replacer.collect_variables('SECRET="<6 (Chars)>"') + assert is_def is True + assert len(replacer.variables["SECRET"]) == 6 + assert line == f'SECRET="{replacer.variables["SECRET"]}"' + + +def test_collect_variables_ignores_non_definitions(replacer): + """Lines that are not variable definitions pass through.""" + line, is_def = replacer.collect_variables("just some text") + assert is_def is False + assert line == "just some text" + + +def test_process_file_end_to_end(replacer, tmp_path): + """A template file is fully resolved: comments, placeholders and blank lines.""" + env_file = tmp_path / ".env.example" + env_file.write_text( + "# Database settings\n" + 'TOKEN="<8 (Chars)>"\n' + "URL=https://example.com # endpoint\n" + "REF=\n" + "\n", + encoding="utf-8", + ) + replacer.process_file(str(env_file)) + lines = env_file.read_text(encoding="utf-8").splitlines() + + assert lines[0] == "# Database settings" + token_value = lines[1].split("=", 1)[1].strip('"') + assert len(token_value) == 8 + assert lines[2] == "URL=https://example.com" + assert lines[3] == f"REF={token_value}" + assert len(lines) == 4 # the blank line was dropped + + +def test_process_file_missing_file_logs_error(replacer, tmp_path, caplog): + """A nonexistent file logs an error and does not raise.""" + with caplog.at_level(logging.ERROR, logger="test_init_security_config"): + replacer.process_file(str(tmp_path / "missing.env")) + assert "does not exist" in caplog.text + + +def test_process_file_read_error_logs_error(replacer, tmp_path, caplog): + """An unreadable file logs a read failure and does not raise.""" + env_file = tmp_path / ".env.example" + env_file.write_text("KEY=value\n", encoding="utf-8") + env_file.chmod(0o000) + try: + with caplog.at_level(logging.ERROR, logger="test_init_security_config"): + replacer.process_file(str(env_file)) + finally: + env_file.chmod(0o644) + assert "Failed to read" in caplog.text + + +def test_logger_configurator_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + LoggerConfigurator(log_level="NOISY") + + +def test_logger_configurator_sets_handlers(tmp_path, monkeypatch): + """A valid level configures file and console handlers.""" + monkeypatch.chdir(tmp_path) + configurator = LoggerConfigurator(log_level="DEBUG") + assert len(configurator.logger.handlers) == 2 + + +def test_argument_parser_parses_files(monkeypatch): + """--files collects every path passed.""" + monkeypatch.setattr(sys, "argv", ["init_security_config", "--files", "a.env", "b.ini"]) + args = ArgumentParser().parse() + assert args.files == ["a.env", "b.ini"] + assert args.log_level == "INFO" + + +def test_argument_parser_requires_files(monkeypatch): + """Missing --files makes argparse exit with code 2.""" + monkeypatch.setattr(sys, "argv", ["init_security_config"]) + with pytest.raises(SystemExit) as exc_info: + ArgumentParser().parse() + assert exc_info.value.code == 2 + + +def test_main_processes_files(tmp_path, monkeypatch): + """main() resolves placeholders of every file passed via --files.""" + monkeypatch.chdir(tmp_path) + env_file = tmp_path / ".env.example" + env_file.write_text('SECRET="<10 (Chars)>"\n', encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["init_security_config", "--files", str(env_file)]) + isc.main() + content = env_file.read_text(encoding="utf-8") + assert "<10 (Chars)>" not in content + secret_value = content.strip().split("=", 1)[1].strip('"') + assert len(secret_value) == 10 diff --git a/tests/test_validate_container_names.py b/tests/test_validate_container_names.py new file mode 100644 index 0000000..5084867 --- /dev/null +++ b/tests/test_validate_container_names.py @@ -0,0 +1,83 @@ +# tests/test_validate_container_names.py + +import sys + +import pytest + +from validate_container_names.main import validate_filename, main + + +@pytest.mark.parametrize( + "filename", + [ + "ContainerGESTIONATENCIONBACKPROD.yml", + "ContainerABPROD.yml", + "ContainerAB12TEST.yml", + "ContainerX9DEV.yml", + ], +) +def test_validate_filename_valid(filename, capsys): + """Valid container template names pass validation and print an [OK] line.""" + assert validate_filename(filename) is True + captured = capsys.readouterr() + assert "[OK]" in captured.out + + +@pytest.mark.parametrize( + "filename", + [ + "Containerab12PROD.yml", # lowercase suite/app + "ContainerABCD.yml", # missing env suffix + "ContainerAPROD.yml", # suite+app shorter than 2 chars + "ContainerAB-CDPROD.yml", # hyphen not allowed + "ContainerABPROD.yaml", # wrong extension + "containerABPROD.yml", # lowercase prefix + "ABPROD.yml", # missing Container prefix + ], +) +def test_validate_filename_invalid(filename, capsys): + """Invalid names fail validation and print the [ERROR] explanation.""" + assert validate_filename(filename) is False + captured = capsys.readouterr() + assert "[ERROR]" in captured.out + assert "does not follow the naming convention" in captured.out + + +def test_validate_filename_uses_basename(capsys): + """Validation applies to the basename, ignoring directories in the path.""" + assert validate_filename("templates/clients/ContainerABTEST.yml") is True + captured = capsys.readouterr() + assert "suite+app: 'AB'" in captured.out + assert "env: 'TEST'" in captured.out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["validate_container_names"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_all_valid_exits_0(monkeypatch, capsys): + """main() exits with code 0 when every file passes validation.""" + monkeypatch.setattr( + sys, "argv", ["validate_container_names", "ContainerABPROD.yml", "ContainerCDTEST.yml"] + ) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert "All 2 file(s) passed naming validation" in capsys.readouterr().out + + +def test_main_with_invalid_file_exits_1(monkeypatch, capsys): + """main() exits with code 1 and lists the offending files.""" + monkeypatch.setattr( + sys, "argv", ["validate_container_names", "ContainerABPROD.yml", "badname.yml"] + ) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "[FAIL] 1 file(s) with invalid names: badname.yml" in captured.out diff --git a/tests/test_validate_docker_compose.py b/tests/test_validate_docker_compose.py new file mode 100644 index 0000000..c42e1a0 --- /dev/null +++ b/tests/test_validate_docker_compose.py @@ -0,0 +1,105 @@ +# tests/test_validate_docker_compose.py + +import subprocess +import sys + +import pytest + +from validate_docker_compose.main import format_docker_compose, validate_docker_compose, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_format_adds_document_start_marker(tmp_path, capsys): + """A compose file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "docker-compose.yml", "services:\n app:\n image: x\n") + format_docker_compose(file_path) + content = (tmp_path / "docker-compose.yml").read_text(encoding="utf-8") + assert content.startswith("---\n") + captured = capsys.readouterr() + assert "Added '---' at the beginning" in captured.out + + +def test_format_keeps_existing_marker(tmp_path, capsys): + """A compose file that already starts with '---' is left as is.""" + file_path = _write(tmp_path, "docker-compose.yml", "---\nservices: {}\n") + format_docker_compose(file_path) + content = (tmp_path / "docker-compose.yml").read_text(encoding="utf-8") + assert content == "---\nservices: {}\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_format_normalizes_line_endings_to_lf(tmp_path): + """CRLF line endings are rewritten as LF.""" + file_path = tmp_path / "docker-compose.yml" + file_path.write_bytes(b"---\r\nservices: {}\r\n") + format_docker_compose(str(file_path)) + assert b"\r" not in file_path.read_bytes() + + +def test_validate_success(mocker, capsys): + """A valid compose file prints [OK].""" + mock_run = mocker.patch("validate_docker_compose.main.subprocess.run") + validate_docker_compose("docker-compose.yml") + mock_run.assert_called_once() + assert "[OK] docker-compose.yml is valid." in capsys.readouterr().out + + +def test_validate_invalid_compose_exits_1(mocker, capsys): + """docker compose config failure prints stderr and exits with 1.""" + mocker.patch( + "validate_docker_compose.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "docker", stderr="invalid service"), + ) + with pytest.raises(SystemExit) as exc_info: + validate_docker_compose("docker-compose.yml") + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "[ERROR] Error in file docker-compose.yml:" in captured.out + assert "invalid service" in captured.out + + +def test_validate_docker_not_installed_exits_1(mocker, capsys): + """Missing docker binary exits with 1.""" + mocker.patch("validate_docker_compose.main.subprocess.run", side_effect=FileNotFoundError) + with pytest.raises(SystemExit) as exc_info: + validate_docker_compose("docker-compose.yml") + assert exc_info.value.code == 1 + assert "docker compose not found" in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["validate_docker_compose"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yml") + monkeypatch.setattr(sys, "argv", ["validate_docker_compose", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out + + +def test_main_formats_and_validates_all_files(monkeypatch, tmp_path, mocker, capsys): + """main() formats and validates every file, then exits 0.""" + mocker.patch("validate_docker_compose.main.subprocess.run") + file_a = _write(tmp_path, "a.yml", "services: {}\n") + file_b = _write(tmp_path, "b.yml", "---\nservices: {}\n") + monkeypatch.setattr(sys, "argv", ["validate_docker_compose", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "All checks passed successfully." in captured.out + assert (tmp_path / "a.yml").read_text(encoding="utf-8").startswith("---\n") From 29d00b129d6232ac99f80ea81db18fdd68e1fb9e Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Mon, 15 Jun 2026 08:47:15 -0500 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20derive=20chan?= =?UTF-8?q?gelog=20versions=20from=20bump=20commits=20to=20close=20gaps=20?= =?UTF-8?q?[patch=20candidate]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 91 ++++++++++++++++++------- generate_changelog/main.py | 111 ++++++++++++++++++++++++++++++- tests/test_generate_changelog.py | 94 +++++++++++++++++++++----- 3 files changed, 253 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f32c1..488bfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,25 @@ ## [1.1.20] - 2026-05-30 -### Documentation +### Features -- **core**: update changelog +- **core**: add validate container names script for cloudformation templates (`patch candidate`) -## [1.1.19] - 2026-03-03 +### Bug Fixes -### Features +- **core**: use docker compose v2 plugin instead of deprecated docker-compose v1 +- **core**: make fetch tags non-fatal in generate changelog hook -- **core**: add inline release creation to version controller (`patch candidate`) +## [1.1.19] - 2026-03-03 ### Bug Fixes - **core**: add pull-requests write permission to version controller (`patch candidate`) -### Documentation +## [1.1.18] - 2026-03-03 + +### Features -- **core**: update changelog +- **core**: add inline release creation to version controller (`patch candidate`) ## [1.1.17] - 2026-03-03 @@ -24,21 +27,13 @@ - **core**: use access token for tag push to trigger release workflow (`patch candidate`) -### Documentation - -- **core**: update changelog - ## [1.1.16] - 2026-03-03 ### Features - **core**: add inline release notes and update cicd workflows (`patch candidate`) -### Documentation - -- **core**: update changelog - -## [1.1.15] - 2026-03-02 +## [1.1.15] - 2026-03-03 ### Bug Fixes @@ -94,6 +89,8 @@ - **core**: bump year version (`patch candidate`) +## [1.1.11] - 2025-01-07 + ### Chores - **core**: bump year version (`patch candidate`) @@ -113,6 +110,9 @@ - deps: update pytest-cov requirement from ^5.0.0 to ^6.0.0 (#45) - deps: update pytest-cov requirement from ^5.0.0 to ^6.0.0 +- Update CNAME +- Update CNAME +- Create CNAME ## [1.1.9] - 2024-10-29 @@ -157,20 +157,37 @@ ## [1.1.2] - 2024-10-27 -### Features +### Bug Fixes - **core**: fix commit icons and commit regex validation (`patch candidate`) -- **core**: added commit icons and commit regex validation (`minor candidate`) -### Bug Fixes +## [1.1.1] - 2024-10-27 + +### Features - **core**: fix commit icons and commit regex validation (`patch candidate`) +## [1.1.0] - 2024-10-27 + +### Features + +- **core**: added commit icons and commit regex validation (`minor candidate`) + ### Chores - **core**: test icons - **core**: test icons + +## [1.0.19] - 2024-10-27 + +### Chores + - **core**: test icons (`patch candidate`) + +## [1.0.18] - 2024-10-27 + +### Chores + - **core**: test icons (`patch candidate`) - **core**: test icons - **core**: test icons flow @@ -180,6 +197,11 @@ ### Chores - **core**: test icons flow (`patch candidate`) + +## [1.0.14] - 2024-10-27 + +### Chores + - **core**: test version controller (`patch candidate`) ## [1.0.13] - 2024-10-27 @@ -210,6 +232,11 @@ ### Chores - **style**: fixed bump year flow (`patch candidate`) + +## [1.0.9] - 2024-10-26 + +### Chores + - **style**: fixed bump year flow (`patch candidate`) - **core**: added crypto controller @@ -219,19 +246,28 @@ - **core**: fixed confs (`patch candidate`) -### Other Changes - -- Update CNAME -- Update CNAME -- Create CNAME - ## [1.0.7] - 2024-10-23 ### Styles - **core**: fixed confs (`patch candidate`) + +## [1.0.6] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) + +## [1.0.5] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) + +## [1.0.4] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) ## [1.0.3] - 2024-10-23 @@ -239,6 +275,11 @@ ### Styles - **core**: added readme file (`patch candidate`) + +## [1.0.2] - 2024-10-23 + +### Styles + - **core**: added readme file (`patch candidate`) - **core**: added readme file diff --git a/generate_changelog/main.py b/generate_changelog/main.py index 9923b84..43300f3 100644 --- a/generate_changelog/main.py +++ b/generate_changelog/main.py @@ -43,6 +43,15 @@ re.compile(r"from \w+ - from \w+", re.IGNORECASE), ] +# Matches a bump2version commit "Bump version: " (robust to a leading 'v' +# and to pre-release suffixes on either side). CI promotion commits +# ("From → Bump version: vX.Y.Z- into ") use "into" instead of +# the arrow, so they are NOT matched here. +BUMP_VERSION_REGEX = re.compile( + r"Bump version:\s*v?\d+\.\d+\.\d+\S*\s*(?:→|->)\s*(?Pv?\d+\.\d+\.\d+)", + re.IGNORECASE, +) + # Mapping of commit types to changelog sections TYPE_MAPPING = { "feat": "### Features", @@ -176,6 +185,88 @@ def compare_versions(v1: str, v2: str) -> int: return 0 +def normalize_version(version_str: str) -> str: + """ + Normalizes a version string to bare 'X.Y.Z', tolerating an optional leading 'v' + and any pre-release suffix (e.g. 'v1.2.8-development' -> '1.2.8', '1.2.8' -> '1.2.8'). + + Args: + version_str (str): The raw version string. + + Returns: + str: The normalized 'X.Y.Z' string. + """ + s = version_str.strip() + if s[:1].lower() == "v": + s = s[1:] + return s.split("-")[0] + + +def get_version_history() -> List[Dict[str, str]]: + """ + Derives the released version sequence from 'Bump version: X -> Y' commits, + ordered oldest to newest. This is independent of tags, so every bumped version is + documented even if it never received a release tag (closing changelog gaps). + + Returns: + List[Dict[str, str]]: List of {"version": 'X.Y.Z', "commit": , "date": 'YYYY-MM-DD'}. + """ + try: + output = subprocess.check_output( + ["git", "log", "--reverse", "--pretty=format:%H\x1f%ai\x1f%s"], + encoding="utf-8", + ) + except subprocess.CalledProcessError as error: + logger.error(f"Error retrieving commit history: {error}") + return [] + + history: List[Dict[str, str]] = [] + seen = set() + for line in output.split("\n"): + if not line.strip(): + continue + parts = line.split("\x1f") + if len(parts) != 3: + continue + commit_hash, date_str, subject = parts + match = BUMP_VERSION_REGEX.search(subject) + if not match: + continue + version = normalize_version(match.group("new")) + if version in seen: + continue + seen.add(version) + history.append({"version": version, "commit": commit_hash, "date": date_str.split(" ")[0]}) + + # Sort by semantic version so the changelog stays consistent even if history was rebased. + history.sort(key=lambda entry: parse_version(entry["version"])) + logger.debug(f"Derived {len(history)} versions from bump commits.") + return history + + +def build_from_history( + history: List[Dict[str, str]], +) -> Tuple[Dict[str, List[str]], Dict[str, str]]: + """ + Builds the per-version commit map and dates from the bump-version history. + Commits in (previous_bump, current_bump] belong to the current version. + + Args: + history (List[Dict[str, str]]): Output of get_version_history() (ascending). + + Returns: + Tuple[Dict[str, List[str]], Dict[str, str]]: (commits_by_version, dates_by_version). + """ + commits_dict: Dict[str, List[str]] = OrderedDict() + dates: Dict[str, str] = {} + previous_commit = "" + for entry in history: + commits_dict[entry["version"]] = get_commits_between_tags(previous_commit, entry["commit"]) + dates[entry["version"]] = entry["date"] + previous_commit = entry["commit"] + return commits_dict, dates + + def is_noise_commit(commit: str) -> bool: """ Checks if a commit message is noise that should be filtered out. @@ -480,6 +571,10 @@ def generate_full_changelog(commits_dict: Dict[str, List[str]], tag_dates: Dict[ if not commits: continue changelog, non_conforming = parse_commits(commits) + # Skip versions whose range is entirely noise (bumps/merges) to avoid empty headers. + if not changelog and not non_conforming: + logger.debug(f"Skipping version {version} with no conforming commits.") + continue date = tag_dates.get(version, datetime.now(timezone.utc).strftime("%Y-%m-%d")) changelog_entry = generate_changelog_entry(version, changelog, non_conforming, date) changelog_content += changelog_entry @@ -536,9 +631,19 @@ def main() -> None: Main function to generate or update the CHANGELOG.md. """ fetch_tags() - sorted_tags = get_sorted_tags() - tag_dates = get_tag_dates(sorted_tags) - commits_dict = get_all_commits(sorted_tags) + + # Primary: derive every version from 'Bump version: X -> Y' commits so no bumped + # version is skipped (tag-independent). Fallback: legacy tag-based generation for + # repositories that do not use bump-version commits. + history = get_version_history() + if history: + commits_dict, tag_dates = build_from_history(history) + else: + logger.info("No bump-version commits found; falling back to tag-based changelog.") + sorted_tags = get_sorted_tags() + tag_dates = get_tag_dates(sorted_tags) + commits_dict = get_all_commits(sorted_tags) + changelog_content = generate_full_changelog(commits_dict, tag_dates) if not changelog_content: diff --git a/tests/test_generate_changelog.py b/tests/test_generate_changelog.py index 8f7c7eb..6f8a7c9 100644 --- a/tests/test_generate_changelog.py +++ b/tests/test_generate_changelog.py @@ -325,17 +325,13 @@ def test_update_changelog_write_error_returns_false(tmp_path, monkeypatch): def test_main_generates_and_updates(mocker): - """main() builds the changelog from tags and hands it to update_changelog.""" + """main() builds the changelog from bump-version history and hands it to update_changelog.""" mocker.patch("generate_changelog.main.fetch_tags") - mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) mocker.patch( - "generate_changelog.main.get_tag_dates", - return_value={"1.0.0": "2026-01-01", "Unreleased": TODAY_UTC}, - ) - mocker.patch( - "generate_changelog.main.get_all_commits", - return_value=OrderedDict([("1.0.0", ["feat: first"])]), + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=["feat: first"]) mock_update = mocker.patch("generate_changelog.main.update_changelog", return_value=True) gcl.main() @@ -349,11 +345,11 @@ def test_main_generates_and_updates(mocker): def test_main_without_commits_does_not_update(mocker, caplog): """main() skips the update when there is nothing to include.""" mocker.patch("generate_changelog.main.fetch_tags") - mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) - mocker.patch("generate_changelog.main.get_tag_dates", return_value={"Unreleased": TODAY_UTC}) mocker.patch( - "generate_changelog.main.get_all_commits", return_value=OrderedDict([("1.0.0", [])]) + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=[]) mock_update = mocker.patch("generate_changelog.main.update_changelog") with caplog.at_level(logging.INFO, logger="generate_changelog.main"): @@ -366,6 +362,23 @@ def test_main_without_commits_does_not_update(mocker, caplog): def test_main_logs_when_no_update_needed(mocker, caplog): """main() reports when the changelog content did not change.""" mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch( + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], + ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=["feat: first"]) + mocker.patch("generate_changelog.main.update_changelog", return_value=False) + + with caplog.at_level(logging.INFO, logger="generate_changelog.main"): + gcl.main() + + assert "Changelog was not updated" in caplog.text + + +def test_main_falls_back_to_tags_without_bump_commits(mocker): + """Without bump-version commits, main() falls back to the legacy tag-based path.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch("generate_changelog.main.get_version_history", return_value=[]) mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) mocker.patch( "generate_changelog.main.get_tag_dates", @@ -375,9 +388,60 @@ def test_main_logs_when_no_update_needed(mocker, caplog): "generate_changelog.main.get_all_commits", return_value=OrderedDict([("1.0.0", ["feat: first"])]), ) - mocker.patch("generate_changelog.main.update_changelog", return_value=False) + mock_update = mocker.patch("generate_changelog.main.update_changelog", return_value=True) - with caplog.at_level(logging.INFO, logger="generate_changelog.main"): - gcl.main() + gcl.main() - assert "Changelog was not updated" in caplog.text + content = mock_update.call_args.args[0] + assert "## [1.0.0] - 2026-01-01" in content + + +def test_normalize_version_handles_v_prefix_and_suffix(): + """normalize_version tolerates an optional 'v' and pre-release suffixes.""" + assert gcl.normalize_version("v1.2.8-development") == "1.2.8" + assert gcl.normalize_version("1.2.8") == "1.2.8" + assert gcl.normalize_version("V1.2.8") == "1.2.8" + assert gcl.normalize_version(" v2.0.0 ") == "2.0.0" + + +def test_get_version_history_parses_bump_commits(mocker): + """Versions come from 'Bump version: X -> Y'; CI 'into' promotions are ignored.""" + log = ( + "h1\x1f2026-01-01 00:00:00 +0000\x1fBump version: 1.0.0 → 1.0.1\n" + "h2\x1f2026-01-02 00:00:00 +0000\x1ffeat: work\n" + "h3\x1f2026-01-03 00:00:00 +0000\x1fBump version: v1.0.1 -> v1.0.2\n" + "h4\x1f2026-01-04 00:00:00 +0000\x1fFrom development → Bump version: " + "v1.0.3-development into testing\n" + ) + mocker.patch("generate_changelog.main.subprocess.check_output", return_value=log) + history = gcl.get_version_history() + assert [entry["version"] for entry in history] == ["1.0.1", "1.0.2"] + assert history[0]["commit"] == "h1" + assert history[0]["date"] == "2026-01-01" + + +def test_build_from_history_builds_ranges_and_dates(mocker): + """build_from_history maps each version to its commit range and date.""" + mocker.patch( + "generate_changelog.main.get_commits_between_tags", + side_effect=[["feat: a"], ["fix: b"]], + ) + history = [ + {"version": "1.0.0", "commit": "c1", "date": "2026-01-01"}, + {"version": "1.0.1", "commit": "c2", "date": "2026-01-02"}, + ] + commits_dict, dates = gcl.build_from_history(history) + assert list(commits_dict.keys()) == ["1.0.0", "1.0.1"] + assert commits_dict["1.0.0"] == ["feat: a"] + assert dates["1.0.1"] == "2026-01-02" + + +def test_generate_full_changelog_skips_noise_only_versions(): + """A version whose range is all noise produces no (empty) entry.""" + commits_dict = OrderedDict( + [("1.0.0", ["feat: real"]), ("1.0.1", ["Bump version: 1.0.0 -> 1.0.1"])] + ) + dates = {"1.0.0": "2026-01-01", "1.0.1": "2026-01-02"} + content = gcl.generate_full_changelog(commits_dict, dates) + assert "## [1.0.0]" in content + assert "## [1.0.1]" not in content From aa4e883cc6a0aff4ba5718f327963c0dcb4961e6 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Mon, 15 Jun 2026 08:47:42 -0500 Subject: [PATCH 12/14] =?UTF-8?q?Bump=20version:=201.1.20=20=E2=86=92=201.?= =?UTF-8?q?1.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 86a4f23..1925afd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.20 +current_version = 1.1.21 commit = True tag = False diff --git a/CHANGELOG.md b/CHANGELOG.md index 488bfc7..24cf3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.1.21] - 2026-06-15 + +### Bug Fixes + +- **core**: derive changelog versions from bump commits to close gaps (`patch candidate`) +- **core**: resolve key pair year rollover, formatter hook bugs and enforce 96 coverage gate +- **core**: handle existing tag gracefully in version-controller workflow +- **deps**: update setuptools requirement from ^82.0.0 to ^82.0.1 (#115) +- **deps**: update requests requirement from ^2.32.3 to ^2.34.2 (#114) +- **deps**: update bump2version requirement from ^1.0.0 to ^1.0.1 (#113) +- **deps**: update pytest requirement from ^8.3.1 to ^9.0.3 (#112) + ## [1.1.20] - 2026-05-30 ### Features diff --git a/pyproject.toml b/pyproject.toml index 002e904..25e5c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "1.1.20" +version = "1.1.21" description = "CICD Core Scripts" authors = ["B "] license = "GLPv3" From 94e1efac426b842018c13b22ebd27b5772cc2bf9 Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Mon, 15 Jun 2026 09:04:31 -0500 Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=90=9B=20fix(core):=20restage=20tra?= =?UTF-8?q?cked=20changes=20and=20retry=20amend=20in=20version=20bump=20ho?= =?UTF-8?q?ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commit_msg_version_bump/main.py | 38 ++++++++++++++++----------- tests/test_commit_msg_version_bump.py | 25 +++++++++++++++--- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/commit_msg_version_bump/main.py b/commit_msg_version_bump/main.py index 03721d9..4f26f6c 100644 --- a/commit_msg_version_bump/main.py +++ b/commit_msg_version_bump/main.py @@ -233,26 +233,32 @@ def amend_commit(new_commit_msg: str) -> None: """ Amends the current commit with the new commit message. + Pre-commit hooks (e.g. generate-changelog, prettier) may modify tracked files such + as CHANGELOG.md while the amend runs, which aborts the amend. We re-stage those + tracked changes with ``git add -u`` and retry so they are folded into the bump + commit. ``--no-verify``/``--force`` are never used (global rule): the hooks are + idempotent, so the retry converges once their changes are staged. + Args: new_commit_msg (str): The new commit message. - - Raises: - subprocess.CalledProcessError: If git amend fails. """ - try: - # Amend the commit with the new commit message - subprocess.run( - ["git", "commit", "--amend", "-m", new_commit_msg], - check=True, - encoding="utf-8", - ) - logger.info("Successfully amended the commit with the new version bump.") - logger.info( - "Please perform a push using 'git push' to update the remote repository. Avoid using --force" + amend_cmd = ["git", "commit", "--amend", "-m", new_commit_msg] + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + result = subprocess.run(amend_cmd, encoding="utf-8") + if result.returncode == 0: + logger.info("Successfully amended the commit with the new version bump.") + logger.info( + "Please perform a push using 'git push' to update the remote repository. Avoid using --force" + ) + return + logger.warning( + f"Amend attempt {attempt} aborted by pre-commit hooks modifying files; " + "re-staging tracked changes and retrying." ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to amend the commit: {e}") - sys.exit(1) + subprocess.run(["git", "add", "-u"], check=True, encoding="utf-8") + logger.error(f"Failed to amend the commit after {max_attempts} attempts.") + sys.exit(1) def main() -> None: diff --git a/tests/test_commit_msg_version_bump.py b/tests/test_commit_msg_version_bump.py index ba6d830..92638b9 100644 --- a/tests/test_commit_msg_version_bump.py +++ b/tests/test_commit_msg_version_bump.py @@ -161,26 +161,43 @@ def test_stage_changes_failure_exits_1(mocker): def test_amend_commit_invokes_git(mocker): """The commit is amended with the bump message.""" - mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + mock_run = mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0), + ) amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") mock_run.assert_called_once_with( ["git", "commit", "--amend", "-m", "🔖 Bump version: 1.0.0 → 1.0.1"], - check=True, encoding="utf-8", ) def test_amend_commit_failure_exits_1(mocker): - """A git amend failure exits with code 1.""" + """An amend that keeps being aborted by hooks exits with code 1 after retries.""" mocker.patch( "commit_msg_version_bump.main.subprocess.run", - side_effect=subprocess.CalledProcessError(1, "git"), + return_value=subprocess.CompletedProcess(args=[], returncode=1), ) with pytest.raises(SystemExit) as exc_info: amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") assert exc_info.value.code == 1 +def test_amend_commit_restages_and_retries_when_hooks_modify_files(mocker): + """When pre-commit hooks modify files, the amend re-stages (git add -u) and retries.""" + mock_run = mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=[ + subprocess.CompletedProcess(args=[], returncode=1), # 1st amend aborted by hooks + subprocess.CompletedProcess(args=[], returncode=0), # git add -u + subprocess.CompletedProcess(args=[], returncode=0), # 2nd amend succeeds + ], + ) + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + assert mock_run.call_count == 3 + assert any(call.args[0] == ["git", "add", "-u"] for call in mock_run.call_args_list) + + def test_main_with_candidate_bumps_and_exits_1(tmp_path, monkeypatch, mocker): """A candidate keyword triggers bump, stage and amend, then aborts the push.""" monkeypatch.chdir(tmp_path) From fe27bf483afe64bbb2bbb96c1d011439b3194dfe Mon Sep 17 00:00:00 2001 From: Juan Villa Date: Mon, 15 Jun 2026 09:26:16 -0500 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=90=9B=20fix(deps):=20split=20runti?= =?UTF-8?q?me=20and=20dev=20requirements=20and=20resolve=20requests=20advi?= =?UTF-8?q?sory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-controller.yml | 2 +- README.md | 2 +- pyproject.toml | 8 ++--- requirements.dev.txt | 25 ++++++++++++++ requirements.txt | 43 +++++++++--------------- 5 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 requirements.dev.txt diff --git a/.github/workflows/release-controller.yml b/.github/workflows/release-controller.yml index 2602e63..2c88bf8 100644 --- a/.github/workflows/release-controller.yml +++ b/.github/workflows/release-controller.yml @@ -117,7 +117,7 @@ jobs: run: | zip -r "${REPO_NAME}-${GITHUB_REF_NAME}.zip" \ {INSTALL,SECURITY,README,ICONS,CONTRIBUTING,CODE_OF_CONDUCT}.md \ - requirements.txt .github scripts *adm* smtp-relay elastalert \ + requirements.txt requirements.dev.txt .github scripts *adm* smtp-relay elastalert \ pyproject.toml env: REPO_NAME: ${{ env.REPO_NAME }} diff --git a/README.md b/README.md index fc55686..6af2a71 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Setting up a Python virtual environment ensures that dependencies are managed ef 4. **Install Dependencies** ```bash - pip install -r requirements.txt + pip install -r requirements.txt -r requirements.dev.txt pip install poetry poetry lock poetry install diff --git a/pyproject.toml b/pyproject.toml index 25e5c8b..13755c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,13 +10,13 @@ package-mode = false [tool.poetry.dependencies] python = "^3.12" setuptools = "^82.0.1" -idna = "^3.0" -certifi = "^2026.1.4" +idna = "^3.18" +certifi = "^2026.5.20" bump2version = "^1.0.1" python-dotenv = "^1.0.0" cryptography = "^46.0.5" requests = "^2.34.2" -wheel = ">=0.36.2" +wheel = ">=0.47.0" [tool.poetry.group.dev.dependencies] pre-commit = "^4.0.0" @@ -26,7 +26,7 @@ isort = "^6.0.1" toml = "^0.10.0" black = "^26.1.0" pytest = "^9.0.3" -httpx = { version = ">=0.24.0", optional = true } +httpx = { version = ">=0.28.1", optional = true } pytest-cov = "^6.0.0" coverage = "^7.2.5" diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..aa64a72 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,25 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Development / CI dependencies — mirrors [tool.poetry.group.dev.dependencies]. +# Runtime dependencies live in requirements.txt. +# ───────────────────────────────────────────────────────────────────────────── + +# Pre-Commit for hook management +pre-commit>=4.0.0 + +# Linters and formatters +pylint>=4.0.5 +yamllint>=1.35.0 +isort>=6.0.1 +black>=26.1.0 + +# Toml for pyproject.toml management +toml>=0.10.0 + +# Poetry for package management +poetry>=2.4.1 + +# Testing +pytest>=9.0.3 +pytest-cov>=6.0.0 +coverage>=7.2.5 +httpx>=0.28.1 diff --git a/requirements.txt b/requirements.txt index 2c38fac..61ae1d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,22 @@ -# Pre-Commit for pre-commit hooks management -pre-commit>=4.0.0 +# ───────────────────────────────────────────────────────────────────────────── +# Runtime dependencies — mirrors [tool.poetry.dependencies] in pyproject.toml. +# Development / CI tooling lives in requirements.dev.txt. +# ───────────────────────────────────────────────────────────────────────────── -# Pylint for python lint -pylint>=3.3.1 - -# YamlLint for YAML lint -yamllint>=1.35.1 - -# Poetry for package management alternative -poetry>=1.8.4 +# Setuptools / Wheel for builds +setuptools>=82.0.1 +wheel>=0.47.0 # Bump2Version for version release control -bump2version>=1.0.0 - -# Toml for pyproject.toml management -toml>=0.10.1 - -# Black for Code Formatting -black>=24.3.0 - -# Pytest for Unit/Integration Testing -pytest>=7.3.1 -pytest-cov>=5.0.0 - -# Coverage Reporting -coverage>=7.2.5 - -requests~=2.32.4 +bump2version>=1.0.1 +# Python-dotenv for environment management python-dotenv>=1.0.0 -cryptography>=44.0.0 +# Requests + certifi + idna for HTTP and TLS +requests>=2.34.2 +certifi>=2026.5.20 +idna>=3.18 + +# Cryptography for key management / encryption +cryptography>=46.0.5