diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..1b8cd6c --- /dev/null +++ b/.bazelrc @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 + +common --registry=https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/ +common --registry=https://bcr.bazel.build + +#common --extra_toolchains=@score_gcc_x86_64_toolchain//:x86_64-linux + +# libclang toolchain for the C/C++ parser used by the score rules. Registered +# only for score_tooling's own build (its LLVM is a dev dependency); integrating +# repositories register their own libclang toolchain instead. +#common --extra_toolchains=//cpp/libclang:score_tooling_libclang_toolchain + +build --java_language_version=17 +build --tool_java_language_version=17 +build --java_runtime_version=remotejdk_17 +build --tool_java_runtime_version=remotejdk_17 + +# Use GNU ld (bfd) as the Rust linker; avoids the deprecation warning that +# newer Rust emits when it detects the system default linker is gold. +#build --@rules_rust//rust/settings:extra_rustc_flag=-Clink-arg=-fuse-ld=bfd + +# Rust clippy linter +#build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect +#build:clippy --output_groups=+clippy_checks + +# Log level configuration for rules_score build output +# normal build: only errors and warnings (default – no flag needed) +# info build: additionally show info messages from all tools +build:info --//bazel/rules/rules_score:verbosity=info +# debug build: complete output including debug/trace from all tools +build:debug --//bazel/rules/rules_score:verbosity=debug + +# Standard combined coverage (Rust + Python, no Ferrocene required) +# Usage: bazel coverage --config=coverage +# Then run: bazel run //coverage:combined_report +coverage:coverage --combined_report=lcov +#coverage:coverage --@rules_rust//rust/settings:extra_rustc_flag=-Clink-dead-code +#coverage:coverage --@rules_rust//rust/settings:extra_rustc_flag=-Ccodegen-units=1 diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000..acd405b --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.6.0 diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..1b398af --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,27 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: pre-commit +on: + pull_request: + types: [opened, reopened, synchronize] +jobs: + self_test: + name: 🔬 Self Test + runs-on: ubuntu-latest + steps: + - name: 📥 Check out + uses: actions/checkout@v7.0.0 + - name: ⚙️ Setup uv + uses: astral-sh/setup-uv@v7 + - name: 🛠️ Run pre-commit + run: uvx pre-commit run --all-files diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ac62650 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# +on: + pull_request: + types: [opened, reopened, synchronize] +jobs: + tests: + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: write + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.0 + - uses: castler/setup-bazel@cache-optimized + with: + bazelisk-cache: true + disk-cache: integration_tests + repository-cache: true + cache-optimized: true + cache-save: ${{ github.ref == 'refs/heads/main' }} + - name: ⚙️ Setup uv + uses: astral-sh/setup-uv@v7 + # - name: Run python_basics integration tests + # run: | + # cd python_basics/integration_tests + # bazel test //... + - name: Run cr_checker unit tests + run: | + uv run pytest cr_checker/tests/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c1191b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 + +bazel-* +MODULE.bazel.lock +external +.vscode/ + +__pycache__ +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c5ec0e7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + exclude: requirements_lock.txt|\.svg$ + - id: trailing-whitespace + - id: check-shebang-scripts-are-executable + - id: check-executables-have-shebangs + - id: check-added-large-files + args: [--maxkb=100, --enforce-all] # increase or add git lfs if too strict + exclude: org.eclipse.dash.licenses-1.1.0.jar|blanket_index.html + - repo: https://github.com/google/yamlfmt + rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # v0.21.0 + hooks: + - id: yamlfmt + # Ensure every file has a copyright header as per the Eclipse Foundation's requirements + - repo: local + hooks: + - id: copyright + name: Check and fix copyright headers with cr_checker + entry: cr_checker/tool/cr_checker.py --exclusion copyright_exclusions.txt --fix + language: script + minimum_pre_commit_version: 3.2.0 diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..7612592 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +- id: copyright + name: Check and fix copyright headers with cr_checker + entry: cr_checker/tool/cr_checker.py --fix + language: script + minimum_pre_commit_version: 3.2.0 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/BUILD b/BUILD new file mode 100644 index 0000000..2aebf5c --- /dev/null +++ b/BUILD @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@score_tools//cr_checker:cr_checker.bzl", "copyright_checker") + + +copyright_checker( + name = "copyright", + visibility = ["//visibility:public"], +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..5b1de9c --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module( + name = "score_tools" +) +# CR_CHECKER +bazel_dep(name = "aspect_rules_py", version = "1.6.3") +bazel_dep(name = "score_tooling", version = "1.2.0") diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9c933ea --- /dev/null +++ b/NOTICE @@ -0,0 +1,38 @@ + + +# Notices for Eclipse Safe Open Vehicle Core + +This content is produced and maintained by the Eclipse Safe Open Vehicle Core project. + + * Project home: https://projects.eclipse.org/projects/automotive.score + +## Trademarks + +Eclipse, and the Eclipse Logo are registered trademarks of the Eclipse Foundation. + +## Copyright + +All content is the property of the respective authors or their employers. +For more information regarding authorship of content, please consult the +listed source code repository logs. + +## Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Apache License Version 2.0 which is available at +https://www.apache.org/licenses/LICENSE-2.0. + +SPDX-License-Identifier: Apache-2.0 + +## Cryptography + +Content may contain encryption software. The country in which you are currently +may have restrictions on the import, possession, and use, and/or re-export to +another country, of encryption software. BEFORE using any encryption software, +please check the country's laws, regulations and policies concerning the import, +possession, or use, and re-export of encryption software, to see if this is +permitted. diff --git a/README.md b/README.md index fbff2dd..818a361 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ -# tools -Home of score-tools, the new pypi based tools approach + + + + + +# Tools + +## Why this repository exists + +The utilities/tools in this repository used to live inside [tooling](https://github.com/eclipse-score/tooling). That +repo works well for its main purpose, but it also pulls in a large and +growing dependency graph because it bundles many unrelated concerns together. + +That created a real cost for consumers: if you only needed one small, +self-contained utility, building or depending on it meant pulling in the +entire dependency graph of `tooling`, even though 99% of it was +irrelevant to what you actually wanted. + +This repository exists to fix that. It pulls a set of small, self-contained +utilities out of `tooling` into their own home, so that depending on +a utility costs you only what that utility actually needs — not the +dependency footprint of an unrelated, much larger codebase. + +## Scope + +This repo is intentionally narrow. To stay useful, it needs to *stay* +narrow — the whole point of splitting it out was to avoid recreating the +same problem in a new location. + +**In scope:** +- Small, self-contained, general-purpose utilities with minimal external + dependencies. +- Code with no coupling to `tooling`. +- Utilities that are (or are likely to be) reused across multiple, + otherwise-unrelated projects. + +**Out of scope:** +- Anything tied to a specific product, service, or domain — that belongs in + the repo that owns that domain. +- Utilities that only make sense in the context of `tooling`. +- Anything that would pull in a large or heavyweight dependency for the + benefit of a single utility. If a proposed addition needs a big + dependency, prefer keeping it in whichever repo already depends on that + library, or give it its own repo instead of adding it here. +- Grab-bag/"misc" code with no clear justification for living here. When in + doubt, ask whether this utility would want to be depended on by something + that doesn't want any of the other dependencies this repo would then + bring in. If the answer isn't clearly yes, it doesn't belong here. + +## Adding something new + +Before adding a new utility here, check: +1. Is it genuinely general-purpose, not tied to one product's domain logic? +2. Does it avoid pulling in dependencies that most other things in this + repo don't already need? +3. Would splitting it into its own small repo/target actually serve + consumers better than adding it here? + +If any of these gives you pause, raise it for discussion before merging. diff --git a/copyright_exclusions.txt b/copyright_exclusions.txt new file mode 100644 index 0000000..de38ffc --- /dev/null +++ b/copyright_exclusions.txt @@ -0,0 +1 @@ +cr_checker/tool/templates.ini diff --git a/cr_checker/BUILD b/cr_checker/BUILD new file mode 100644 index 0000000..e69de29 diff --git a/cr_checker/README.md b/cr_checker/README.md new file mode 100644 index 0000000..0293758 --- /dev/null +++ b/cr_checker/README.md @@ -0,0 +1,147 @@ + + +# CopyRight Checker + +`cr_checker.py` is a tool designed to check if files contain a specified copyright header. It provides configurable logging, color-coded console output, and can handle large file sets efficiently. The script supports reading configuration files for custom copyright templates and can utilize memory-mapped file reading for better performance with large files. Tool itself can also append copyright header at the beginning of file if flag `--fix` is used. + +## Features + +- Checks files for specified copyright headers based on file extensions. +- When no explicit inputs are given, automatically discovers files via `git ls-files --cached --other --exclude-standard`, so it always operates on the full set of tracked and untracked-but-not-ignored files in the repository. +- Configurable logging, including color-coded output for easy visibility of log levels. +- Can use memory mapping for large file handling. +- Customizable file encoding. +- Automatically detects and skips past a shebang (`#!...`) line before looking for the header. +- Can append copyright headers. + +## Requirements + +- Python 3.12+ +- `argparse`, `logging`, `os`, `sys`, `mmap`, `subprocess`, `tempfile`, and `pathlib` (standard library modules) +- `git` available on `PATH` (used to discover files when no explicit inputs are given) + +## Usage + +`cr_checker` is **not intended to be invoked directly**. It's meant to be wired into your module through one of the two supported integrations — [pre-commit](#how-to-pre-commit) or [Bazel](#how-to-bazel) — both described below under [Integrating `cr_checker` into your own module](#integrating-cr_checker-into-your-own-module). Both integrations run the exact same script; the flags below are simply the ones you pass through `args:` in your `pre-commit` hook config or through the matching parameters of the `copyright_checker` Bazel macro. + +### Arguments + +- **-t**, **--template-file**: Path to the template file that defines the copyright text for each file extension. Defaults to the bundled `templates.ini` next to the script. +- **-v**, **--verbose**: Enable debug-level logging. +- **-l**, **--log-file**: Path to a log file where logs will be saved. If not provided, logs will print to the console. +- **-e**, **--extensions**: List of file extensions to filter, e.g., `-e py cpp`. This list replaces (rather than extends) the built-in default list. +- **--encoding**: File encoding (default is utf-8). +- **--exclusion-file**: Path to a file listing paths (one per line, relative to the repository root) to exclude from the check. +- **--fix**: Setting script into fix mode where copyright header will be added to the files if it's missing from same. +- **inputs**: (Optional) Directories and/or files to check. Neither integration passes these explicitly for a full-repo check: `pre-commit` passes whichever files it decided to run against instead, and the Bazel macro never passes any, which makes the tool fall back to running `git ls-files --cached --other --exclude-standard` (resolved against `BUILD_WORKSPACE_DIRECTORY` if set, otherwise the current working directory) and checking everything that comes back. + + +### Template File Format + +The template file should be in INI format, with each section representing a file extension and a section specifying the copyright text. +The copyright text can use format expressions to match the year and the author. + +Example templates.ini: + +```ini +[py,sh] +# Copyright (c) {year} {author} + +[cpp,c,hpp, h] +// Copyright (c) {year} {author} +``` + +## Exit Codes + +- 0: All files contain the required copyright text. +- 1: Some files are missing the required copyright text, have a duplicate/malformed header, or the exclusion file contains invalid entries. +- Other: Error encountered during file processing. + + +## Integrating `cr_checker` into your own module + +There are two supported ways to run `cr_checker` against your own repository: as a `pre-commit` hook, or as a Bazel target. Both rely on the same default behavior described above: if you don't tell it which files to look at, it discovers them itself. + +### How-to: pre-commit + +This repository ships a [`.pre-commit-hooks.yaml`](../.pre-commit-hooks.yaml) that defines a `copyright` hook (`language: script`, running `cr_checker.py --fix`), so any repository can pull it in as a remote `pre-commit` hook. + +1. In your module's `.pre-commit-config.yaml`, add this repository as a hook source: + + ```yaml + repos: + - repo: https://github.com/eclipse-score/tools + rev: # pin to a specific commit/tag of this repo + hooks: + - id: copyright + args: + # - --exclusion-file=copyright_exclusions.txt # optional + # - --template-file=templates.ini # optional, provide your own header templates + # - --extensions=py cpp h # optional, overrides the built-in list + ``` + +2. Any `args` you add here are appended after the hard-coded `--fix` from the hook definition, and the hook runs with your repository as the working directory, so relative paths (like `copyright_exclusions.txt` above) are resolved against your repo root. + +3. `pre-commit` itself decides which files to pass to the hook (the changed/staged files by default, or every tracked file with `pre-commit run --all-files`), so in normal `pre-commit` usage `cr_checker` checks exactly the files `pre-commit` hands it — the "no inputs → scan the whole repo via `git ls-files`" fallback described above mainly matters when you invoke the script directly or via Bazel (see below) without passing any files. + +4. Run `pre-commit run --all-files` once after adding the hook to confirm your repo is clean, then let it run on every commit as usual. + +### How-to: Bazel + +1. Declare a dependency on this repository's Bazel module in your `MODULE.bazel`: + + ```python + bazel_dep(name = "score_tools", version = "") + ``` + + If this module isn't available from the Bazel Central Registry yet, add the registry that hosts it to your `.bazelrc` first, e.g.: + + ```python + common --registry=https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/ + common --registry=https://bcr.bazel.build + ``` + +2. In the `BUILD` file where you want the check available (typically your repo root), load and instantiate the `copyright_checker` macro: + + ```python + load("@score_tools//cr_checker:cr_checker.bzl", "copyright_checker") + + copyright_checker( + name = "copyright_check", + # template: optional, path to your own templates.ini; omit to use the bundled default templates. + # template = "//:templates.ini", + # exclusion = "//:copyright_exclusions.txt", # optional, list of files to ignore + # extensions = ["py", "cpp", "h"], # optional, overrides the built-in list + visibility = ["//visibility:public"], + ) + ``` + + This defines two runnable targets plus convenience aliases: + - `bazel test //:copyright_check.check` (aliased as `bazel run //:copyright-check`) — reports missing/duplicate headers. + - `bazel run //:copyright_check.fix` (aliased as `bazel run //:copyright-fix`) — inserts missing headers. + +3. There is no `srcs` parameter anymore — the macro doesn't take a file list at all. Every invocation shells out to `git ls-files --cached --other --exclude-standard` against your workspace and filters the result by `extensions`, so it always covers the whole repository (minus whatever `exclusion` lists). + +4. Because of that, **use `bazel run`, not `bazel build`/`bazel test`**: the tool relies on the `BUILD_WORKSPACE_DIRECTORY` environment variable to find your real workspace root, and Bazel only sets that variable for `run`. It also needs `git` available on `PATH` at run time. + +#### Parameters + +- **name**: Unique identifier for the rule. +- **visibility**: Defines which targets can access this rule. +- **template** (optional): Path to the copyright header template. Defaults to the tool's bundled `templates.ini` if omitted. +- **exclusion** (optional): Path to the project-specific exclusion file. +- **extensions** (optional): List of file extensions to filter files. Defaults to the tool's built-in list. +- **debug** (optional): Enables verbose logging for debugging. +- **fix** (optional): Automatically applies fixes instead of just reporting issues. +- **target_compatible_with** (optional): Standard Bazel platform-compatibility constraint list. diff --git a/cr_checker/cr_checker.bzl b/cr_checker/cr_checker.bzl new file mode 100644 index 0000000..39fc2c4 --- /dev/null +++ b/cr_checker/cr_checker.bzl @@ -0,0 +1,110 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Defines Bazel rules for running copyright checks and fixes.""" + +load("@aspect_rules_py//py:defs.bzl", "py_binary") + +def copyright_checker( + name, + visibility, + template = None, + exclusion = None, + extensions = [], + debug = False, + fix = False, + target_compatible_with = None): + """ + Defines a custom build rule for checking and optionally fixing files for compliance + with specific requirements, such as copyright headers. + + Args: + name (str): The name of the rule, used as an identifier in the build system. + visibility (list): A list defining the visibility of the rule, specifying which + targets can use this rule. + template (str, optional): Path to the template resource used for validation. + Defaults to "//tools/cr_checker/resources:templates". + exclusion (str, optional): Path to a text file listing files to be excluded from the copyright check. + File format: one path per line, relative to the repository root. + extensions (list, optional): A list of file extensions to filter the source files. + Defaults to an empty list, meaning all files are checked. + debug (bool, optional): Whether to enable debug mode, providing additional logs. + Defaults to False. + fix (bool, optional): Whether to apply fixes to files instead of just reporting issues. + Defaults to False. + + Returns: + None: This function defines a rule for a build system and does not return a value. + """ + t_names = [ + "{}.check".format(name), + "{}.fix".format(name), + ] + args = [] + if template: + args.append( + "-t $(location {})".format(template), + ) + if len(extensions): + args.append("-e {exts}".format( + exts = " ".join([exts for exts in extensions]), + )) + + if exclusion: + args.append("--exclusion-file $(location {})".format(exclusion)) + + if debug: + args.append("-v") + + data = [] + if template: + data.append(template) + if exclusion: + data.append(exclusion) + for t_name in t_names: + if t_name == "{}.fix".format(name): + args.insert(0, "--fix") + + + py_binary( + name = t_name, + main = "cr_checker.py", + srcs = [ + "@score_tools//cr_checker/tool:cr_checker_lib", + ], + args = args, + data = data, + visibility = visibility, + target_compatible_with = target_compatible_with, + ) + + native.alias( + name = "copyright-check", + actual = ":" + name + ".check", + visibility = visibility, + target_compatible_with = target_compatible_with, + tags = [ + "cli_help=Check for license headers:\n" + + "bazel run //:copyright-check", + ], + ) + + native.alias( + name = "copyright-fix", + actual = ":" + name + ".fix", + visibility = visibility, + tags = [ + "cli_help=Fix license headers:\n" + + "bazel run //:copyright-fix", + ], + ) diff --git a/cr_checker/tests/test_cr_checker.py b/cr_checker/tests/test_cr_checker.py new file mode 100644 index 0000000..1d5fad9 --- /dev/null +++ b/cr_checker/tests/test_cr_checker.py @@ -0,0 +1,417 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# unit tests for the shebang handling in the cr_checker module +from __future__ import annotations + +import importlib.util +import pytest +from datetime import datetime +from pathlib import Path + + +# load the cr_checker module +def load_cr_checker_module(): + module_path = Path(__file__).resolve().parents[1] / "tool" / "cr_checker.py" + spec = importlib.util.spec_from_file_location("cr_checker_module", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Failed to load cr_checker module from {module_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# load the license template +def load_template(extension: str) -> str: + cr_checker = load_cr_checker_module() + template_file = Path(__file__).resolve().parents[1] / "tool" / "templates.ini" + templates = cr_checker.load_templates(template_file) + return templates[extension] + + + +# test that offset matches the length of the shebang line including trailing newlines +def test_detect_shebang_offset_counts_trailing_newlines(tmp_path): + cr_checker = load_cr_checker_module() + script = tmp_path / "script.py" + script.write_text( + "#!/usr/bin/env python3\n\nprint('hi')\n", + encoding="utf-8", + ) + + offset = cr_checker.detect_shebang_offset(script, "utf-8") + + assert offset == len("#!/usr/bin/env python3\n\n".encode("utf-8")) + + +@pytest.fixture( + params=[ + "cpp", + "c", + "h", + "hpp", + "py", + "sh", + "bzl", + "ini", + "yml", + "yaml", + "BUILD", + "bazel", + "rs", + "rst", + ] +) +def prepare_test_with_header(request: SubRequest, tmp_path: PosixPath) -> tuple: + extension = request.param + test_file = tmp_path / ("file." + extension) + header_template = load_template(extension) + current_year = datetime.now().year + header = header_template.format(year=current_year) + test_file.write_text( + header + "some content\n", + encoding="utf-8", + ) + return test_file, extension, header_template + + +@pytest.fixture( + params=[ + "cpp", + "c", + "h", + "hpp", + "py", + "sh", + "bzl", + "ini", + "yml", + "yaml", + "BUILD", + "bazel", + "rs", + "rst", + ] +) +def prepare_test_no_header(request: SubRequest, tmp_path: PosixPath) -> tuple: + extension = request.param + test_file = tmp_path / ("file." + extension) + header_template = load_template(extension) + current_year = datetime.now().year + test_file.write_text( + "some content\n", + encoding="utf-8", + ) + return test_file, extension, header_template, tmp_path + + +def test_process_files_detects_header(prepare_test_with_header): + cr_checker = load_cr_checker_module() + test_file, extension, header_template = prepare_test_with_header + + results = cr_checker.process_files( + files=[test_file], + templates={extension: header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +def test_process_files_detects_missing_header(prepare_test_no_header): + cr_checker = load_cr_checker_module() + test_file, extension, header_template, tmp_path = prepare_test_no_header + + results = cr_checker.process_files( + files=[test_file], + templates={extension: header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 1 + + +def test_process_files_inserts_missing_header(prepare_test_no_header): + cr_checker = load_cr_checker_module() + test_file, extension, header_template, tmp_path = prepare_test_no_header + + results = cr_checker.process_files( + files=[test_file], + templates={extension: header_template}, + fix=True, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 1 + assert results["fixed"] == 1 + expected_header = header_template.format(year=datetime.now().year) + assert test_file.read_text(encoding="utf-8").startswith(expected_header) + + +def test_process_files_skips_exclusion_with_missing_header(prepare_test_no_header): + cr_checker = load_cr_checker_module() + test_file, extension, header_template, tmp_path = prepare_test_no_header + + results = cr_checker.process_files( + files=[test_file], + templates={extension: header_template}, + fix=False, + exclusion=[str(test_file)], + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +# test that process_files function validates a license header after the shebang line +def test_process_files_accepts_header_after_shebang(tmp_path): + cr_checker = load_cr_checker_module() + script = tmp_path / "script.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + script.write_text( + "#!/usr/bin/env python3\n" + header + "print('hi')\n", + encoding="utf-8", + ) + + results = cr_checker.process_files( + files=[script], + templates={"py": header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +# test that process_files function fixes a missing license header after the shebang line +def test_process_files_fix_inserts_header_after_shebang(tmp_path): + cr_checker = load_cr_checker_module() + script = tmp_path / "script.py" + script.write_text( + "#!/usr/bin/env python3\nprint('hi')\n", + encoding="utf-8", + ) + header_template = load_template("py") + current_year = datetime.now().year + + results = cr_checker.process_files( + files=[script], + templates={"py": header_template}, + fix=True, + use_mmap=False, + encoding="utf-8", + ) + + assert results["fixed"] == 1 + assert results["no_copyright"] == 1 + expected_header = header_template.format(year=current_year) + assert script.read_text(encoding="utf-8") == ( + "#!/usr/bin/env python3\n" + expected_header + "\n" + "print('hi')\n" + ) + + +# test that process_files function validates a license header without the shebang line +def test_process_files_accepts_header_without_shebang(tmp_path): + cr_checker = load_cr_checker_module() + script = tmp_path / "script.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + script.write_text(header + "print('hi')\n", encoding="utf-8") + + results = cr_checker.process_files( + files=[script], + templates={"py": header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +# test that process_files function fixes a missing license header without the shebang +def test_process_files_fix_inserts_header_without_shebang(tmp_path): + cr_checker = load_cr_checker_module() + script = tmp_path / "script.py" + script.write_text("print('hi')\n", encoding="utf-8") + header_template = load_template("py") + current_year = datetime.now().year + + results = cr_checker.process_files( + files=[script], + templates={"py": header_template}, + fix=True, + use_mmap=False, + encoding="utf-8", + ) + + assert results["fixed"] == 1 + assert results["no_copyright"] == 1 + expected_header = header_template.format(year=current_year) + assert ( + script.read_text(encoding="utf-8") == expected_header + "\n" + "print('hi')\n" + ) + + +# test that border lines with different fill characters are accepted (flexible matching) +def test_process_files_accepts_flexible_border(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.cpp" + current_year = datetime.now().year + # Use '/' fill chars instead of '*' for border lines + header = ( + "/////////////////////////////////////////////////////////////////////////////////////\n" + f" * Copyright (c) {current_year} Author\n" + " *\n" + " * See the NOTICE file(s) distributed with this work for additional\n" + " * information regarding copyright ownership.\n" + " *\n" + " * This program and the accompanying materials are made available under the\n" + " * terms of the Apache License Version 2.0 which is available at\n" + " * https://www.apache.org/licenses/LICENSE-2.0\n" + " *\n" + " * SPDX-" "License-Identifier: Apache-2.0\n" + " /////////////////////////////////////////////////////////////////////////////////////\n" + ) + test_file.write_text(header + "int main() {}\n", encoding="utf-8") + header_template = load_template("cpp") + + results = cr_checker.process_files( + files=[test_file], + templates={"cpp": header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +# test that a blank line after the header does not cause a check failure +def test_process_files_accepts_header_with_trailing_blank_line(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + test_file.write_text(header + "\nsome content\n", encoding="utf-8") + + results = cr_checker.process_files( + files=[test_file], + templates={"py": header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["no_copyright"] == 0 + + +# test that fix_copyright inserts a blank line after the header +def test_process_files_fix_inserts_trailing_blank_line(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + test_file.write_text("some content\n", encoding="utf-8") + header_template = load_template("py") + current_year = datetime.now().year + + cr_checker.process_files( + files=[test_file], + templates={"py": header_template}, + fix=True, + use_mmap=False, + encoding="utf-8", + ) + + expected_header = header_template.format(year=current_year) + assert test_file.read_text(encoding="utf-8").startswith(expected_header + "\n") + + +# test that has_duplicate_copyright detects a header that appears twice +def test_has_duplicate_copyright_detects_duplicate(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + test_file.write_text(header + header + "some content\n", encoding="utf-8") + + result = cr_checker.has_duplicate_copyright( + test_file, header_template, False, "utf-8", 0 + ) + + assert result is True + + +# test that has_duplicate_copyright returns False for a single header +def test_has_duplicate_copyright_single_header(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + test_file.write_text(header + "some content\n", encoding="utf-8") + + result = cr_checker.has_duplicate_copyright( + test_file, header_template, False, "utf-8", 0 + ) + + assert result is False + + +# test that process_files counts duplicate headers separately from missing headers +def test_process_files_detects_duplicate_header(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + header_template = load_template("py") + current_year = datetime.now().year + header = header_template.format(year=current_year) + test_file.write_text(header + header + "some content\n", encoding="utf-8") + + results = cr_checker.process_files( + files=[test_file], + templates={"py": header_template}, + fix=False, + use_mmap=False, + encoding="utf-8", + ) + + assert results["duplicate_copyright"] == 1 + assert results["no_copyright"] == 0 + + +# test that has_duplicate_copyright detects two headers with different year ranges +def test_has_duplicate_copyright_detects_different_year_ranges(tmp_path): + cr_checker = load_cr_checker_module() + test_file = tmp_path / "file.py" + header_template = load_template("py") + header1 = header_template.format(year="2026") + header2 = header_template.format(year="2024-2026") + test_file.write_text(header1 + header2 + "some content\n", encoding="utf-8") + + result = cr_checker.has_duplicate_copyright( + test_file, header_template, False, "utf-8", 0 + ) + + assert result is True diff --git a/cr_checker/tool/BUILD b/cr_checker/tool/BUILD new file mode 100644 index 0000000..66c370b --- /dev/null +++ b/cr_checker/tool/BUILD @@ -0,0 +1,25 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@aspect_rules_py//py:defs.bzl", "py_library") + +# `templates.ini` is inside srcs. +# This is because if it would be in the `data` attr we would need to find the actual path of it with a lot of effort +# So this is a good workaround that does not change the workings of the lib. +py_library( + name = "cr_checker_lib", + srcs = [ + "cr_checker.py", + "templates.ini", + ], + visibility = ["//visibility:public"], +) diff --git a/cr_checker/tool/cr_checker.py b/cr_checker/tool/cr_checker.py new file mode 100755 index 0000000..2a7ffcf --- /dev/null +++ b/cr_checker/tool/cr_checker.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python3 + +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""The tool for checking if artifacts have proper copyright.""" + +import argparse +import logging +import mmap +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +BYTES_TO_READ = 4 * 1024 + +BORDER_FILL_PATTERN = re.compile(r"([/*#'\-=+])\1{4,}") +FILL_CHARS_REGEX = r"[/*#'\-=+]+" + +LOGGER = logging.getLogger() + +COLORS = { + "BLUE": "\033[34m", + "GREEN": "\033[32m", + "YELLOW": "\033[33m", + "RED": "\033[31m", + "DARK_RED": "\033[35;1m", + "ENDC": "\033[0m", +} + +LOGGER_COLORS = { + "DEBUG": COLORS["BLUE"], + "INFO": COLORS["GREEN"], + "WARNING": COLORS["YELLOW"], + "ERROR": COLORS["RED"], + "CRITICAL": COLORS["DARK_RED"], +} + + +class ColoredFormatter(logging.Formatter): + """ + A custom logging formatter to add color to log level names based on the logging level. + + The `ColoredFormatter` class extends `logging.Formatter` and overrides the `format` + method to add color codes to the log level name (e.g., `INFO`, `WARNING`, `ERROR`) + based on a predefined color mapping in `LOGGER_COLORS`. This color coding helps in + visually distinguishing log messages by severity. + + Attributes: + LOGGER_COLORS (dict): A dictionary mapping log level names (e.g., "INFO", "ERROR") + to their respective color codes. + COLORS (dict): A dictionary of terminal color codes, including an "ENDC" key to reset + colors after the level name. + + Methods: + format(record): Adds color to the `levelname` attribute of the log record and then + formats the record as per the superclass `Formatter`. + """ + + def format(self, record): + log_color = LOGGER_COLORS.get(record.levelname, "") + record.levelname = f"{log_color}{record.levelname}:{COLORS['ENDC']}" + return super().format(record) + + +def convert_bre_to_regex(template: str) -> str: + """ + Convert BRE-style template (literal by default) to standard regex. + In the template: * is literal, \\* is a metacharacter. + """ + # First, escape all regex metacharacters to make them literal + escaped = re.escape(template) + # Now, find escaped backslashes followed by escaped metacharacters + # and convert them back to actual regex metacharacters + metacharacters = r"\\.*+-?[]{}()^$|" + for char in metacharacters: + escaped = escaped.replace(re.escape("\\" + char), char) + return escaped + + +def line_to_flexible_regex(line: str) -> str: + """ + Convert a border line to a regex that accepts any fill characters. + + Runs of 5+ identical fill characters (e.g. ``****``) are replaced with + ``[/*#'\\-=+]+`` so that alternative styles (e.g. ``////``) are also + accepted. + """ + stripped = line.rstrip("\n") + has_newline = line.endswith("\n") + result = [] + last_end = 0 + for m in BORDER_FILL_PATTERN.finditer(stripped): + result.append(re.escape(stripped[last_end : m.start()])) + result.append(FILL_CHARS_REGEX) + last_end = m.end() + result.append(re.escape(stripped[last_end:])) + if has_newline: + result.append("\n") + return "".join(result) + + +def load_templates(path: Path): + """ + Loads the copyright templates from a configuration file. + + Args: + path (str): Path to the template file. + + Returns: + dict: A dictionary where each key is a file extension (e.g., ".cpp") + and the value is the template string from the config. + """ + + def add_template_for_extensions( + templates: dict[str, str], extensions: list[str], template: str + ): + # Remove trailing lines from template and ensure line end + template = template.rstrip() + "\n" + for extension in extensions: + templates[extension] = template + + templates: dict[str, str] = {} + current_extensions = [] + + with open(path, "r", encoding="utf-8") as file: + lines = file.readlines() + template_for_extensions = "" + + for line in lines: + stripped_line = line.strip() + + if stripped_line.startswith("[") and stripped_line.endswith("]"): + add_template_for_extensions( + templates, current_extensions, template_for_extensions + ) + + template_for_extensions = "" + + extensions = stripped_line[1:-1].split(",") + current_extensions = [ext.strip() for ext in extensions] + LOGGER.debug(current_extensions) + else: + template_for_extensions += line + + add_template_for_extensions( + templates, current_extensions, template_for_extensions + ) + + LOGGER.debug(templates) + return templates + + +def load_exclusion(path): + """ + Loads the list of files being excluded from the copyright check. + + Args: + path (str): Path to the exclusion file. + + Returns: + tuple(list, bool): a list of files that are excluded from the copyright check and a boolean indicating whether + all paths listed in the exclusion file exist and are files. + """ + + exclusion = [] + valid = True + with open(path, "r", encoding="utf-8") as file: + for item in file.read().splitlines(): + path = Path(item) + if not path.exists(): + LOGGER.error("Excluded file %s does not exist.", item) + valid = False + continue + if not path.is_file(): + LOGGER.error("Excluded file %s is not a file.", item) + valid = False + continue + exclusion.append(item) + + LOGGER.debug(exclusion) + return exclusion, valid + + +def configure_logging(log_file_path=None, verbose=False): + """ + Configures logging to write messages to the specified log file. + + Args: + log_file_path (str, optional): Path to the log file. + verbose (bool, optional): If True, sets log level to DEBUG. Otherwise, sets it to INFO. + """ + log_level = logging.DEBUG if verbose else logging.INFO + LOGGER.setLevel(log_level) + LOGGER.handlers.clear() + + if log_file_path is not None: + handler = logging.FileHandler(log_file_path) + formatter = logging.Formatter("%(levelname)s: %(message)s") + else: + handler = logging.StreamHandler() + formatter = ColoredFormatter("%(levelname)s %(message)s") + + handler.setLevel(log_level) + handler.setFormatter(formatter) + LOGGER.addHandler(handler) + + +def detect_shebang_offset(path, encoding): + """ + Detects if a file starts with a shebang (#!) and returns the byte offset + to skip it (length of the first line including newline). + + Args: + path (Path): A `pathlib.Path` object pointing to the file. + encoding (str): Encoding type to use when reading the file. + + Returns: + int: The byte length of the shebang line (including newline) if present, + otherwise 0. + """ + try: + with open(path, "r", encoding=encoding) as handle: + first_line = handle.readline() + if first_line.startswith("#!"): + # Calculate byte length of the first line + byte_length = len(first_line.encode(encoding)) + while True: + next_char = handle.read(1) + if not next_char or next_char not in ("\n", "\r"): + break + byte_length += len(next_char.encode(encoding)) + LOGGER.debug( + "Detected shebang in %s with offset %d bytes", path, byte_length + ) + return byte_length + except (IOError, OSError) as err: + LOGGER.debug("Could not detect shebang in %s: %s", path, err) + return 0 + + +def load_text_from_file(path, header_length, encoding, offset): + """ + Reads the first portion of a file, up to `header_length` characters + plus an additional offset if provided. + + Args: + path (Path): A `pathlib.Path` object pointing to the file. + header_length (int): Number of characters to read for the header. + encoding (str): Encoding type to use when reading the file. + offset (int): Additional number of characters to read beyond + `header_length`, typically used to account for extra + lines (such as a shebang) before the header. + + Returns: + str: The portion of the file read, which should contain the header if present, + including any extra characters specified by `offset`. + """ + total_length = header_length + offset + LOGGER.debug( + "Reading first %d characters from file: %s [%s]", total_length, path, encoding + ) + with open(path, "r", encoding=encoding) as handle: + content = handle.read(total_length) + return content[offset:] if offset else content + + +def load_text_from_file_with_mmap(path, header_length, encoding, offset): + """ + Maps the file and reads only the first `header_length` bytes plus + an additional offset if provided. + + Args: + path (Path): A `pathlib.Path` object pointing to the file. + header_length (int): Length of the header text to check. + encoding (str): String for setting decoding type. + offset (int): Additional number of characters to read beyond + `header_length`, typically used to account for extra + lines (such as a shebang) before the header. + + Returns: + str: The portion of the file read, which should contain the header if present. + """ + + file_size = os.path.getsize(path) + total_length = header_length + offset + length = min(total_length, file_size) + + if not length: + LOGGER.warning( + "File %s is empty [length: %d]. Return empty string.", path, length + ) + return "" + + LOGGER.debug("Memory mapping first %d bytes from file: %s", total_length, path) + with open(path, "r", encoding=encoding) as handle: + with mmap.mmap(handle.fileno(), length=length, access=mmap.ACCESS_READ) as fmap: + return fmap[:length].decode(encoding)[offset:] + + +def has_copyright(path, template, use_mmap, encoding, offset): + """ + Checks if the specified copyright text is present in the beginning of a file. + + Args: + path (Path): A `pathlib.Path` object pointing to the file to check. + template (str): The copyright text to search for at the beginning + of the file. + use_mmap (bool): If True, uses memory-mapped file reading for efficient + large file handling. + encoding (str): Encoding type to use when reading the file. + offset (int): Additional number of characters to read beyond the length + of `copyright_text`, used to account for extra content + (such as a shebang) before the copyright text. + + Returns: + bool: True if the file contains the copyright text, False if it is missing. + + Raises: + IOError: If there is an error opening or reading the file. + """ + + load_text = load_text_from_file_with_mmap if use_mmap else load_text_from_file + + lines = template.splitlines(keepends=True) + regex_parts = [] + for line in lines: + stripped_line = line.rstrip("\n") + if BORDER_FILL_PATTERN.search(stripped_line): + regex_parts.append(line_to_flexible_regex(line)) + else: + formatted = line.format(year=r"\\d\{4\}\(-\\d\{4\}\)\?", author=r"\.\*") + regex_parts.append(convert_bre_to_regex(formatted)) + template_regex = "".join(regex_parts) + "\n?" + + if re.match(template_regex, load_text(path, BYTES_TO_READ, encoding, offset)): + LOGGER.debug("File %s has copyright.", path) + return True + + LOGGER.debug("File %s doesn't have copyright.", path) + return False + + +def has_any_copyright(path, use_mmap, encoding, offset): + """ + Checks if any copyright notice is present in the file header, regardless of format. + + Args: + path (Path): A `pathlib.Path` object pointing to the file to check. + use_mmap (bool): If True, uses memory-mapped file reading. + encoding (str): Encoding type to use when reading the file. + offset (int): Byte offset to skip (e.g. shebang line). + + Returns: + bool: True if any copyright notice is found, False otherwise. + """ + load_text = load_text_from_file_with_mmap if use_mmap else load_text_from_file + content = load_text(path, BYTES_TO_READ, encoding, offset) + return bool( + re.search( + r"Copyright.*SPDX-License-Identifier", content, re.IGNORECASE | re.DOTALL + ) + ) + + +def has_duplicate_copyright(path, template, use_mmap, encoding, offset): + """ + Checks if more than one copyright notice is present in the file header. + + The check is format-agnostic: it counts occurrences of ``SPDX-License-Identifier`` + within a window of twice the template length, so that headers written by different + tools (e.g. REUSE vs. cr_checker) are both counted while string literals that + embed copyright text further into the file are ignored. + + Args: + path (Path): A `pathlib.Path` object pointing to the file to check. + template (str): The copyright template; its length defines the search window. + use_mmap (bool): If True, uses memory-mapped file reading. + encoding (str): Encoding type to use when reading the file. + offset (int): Byte offset to skip (e.g. shebang line). + + Returns: + bool: True if more than one copyright notice is found, False otherwise. + """ + load_text = load_text_from_file_with_mmap if use_mmap else load_text_from_file + content = load_text(path, 2 * len(template), encoding, offset) + matches = list(re.finditer(r"SPDX-License-Identifier", content, re.IGNORECASE)) + if len(matches) > 1: + LOGGER.debug("File %s has %d copyright headers.", path, len(matches)) + return True + return False + + +def get_files_from_dir(directory, exts=None): + """ + Finds files in the specified directories. Filters by extensions if provided. + + Args: + dirs (list of str): List of directories to search for files. + exts (list of str, optional): List of extensions to filter files. + If None, all files are returned. + + Returns: + list of str: List of file paths found in the directories. + """ + collected_files = [] + LOGGER.debug("Getting files from directory: %s", directory) + for path in directory.rglob("*"): + if path.is_file() and path.stat().st_size != 0: + if ( + exts is None + or path.suffix[1:] in exts + or (path.name == "BUILD" and "BUILD" in exts) + ): + collected_files.append(path) + return collected_files + + +def collect_inputs(inputs: list[str], exts=None): + """ + Collects files from a list of input paths, optionally filtering by file extensions. + + Args: + inputs (list): A list of paths to files or directories. + If a directory is provided, all files within it are added to the output. + exts (list, optional): A list of file extensions to filter by (e.g., ['.py', '.txt']). + Only files with these extensions will be included if specified. + + Returns: + list: A list of file paths collected from the input paths, filtered by the given extensions. + If an input is neither a file nor a directory, it is skipped with a warning. + + Logs: + Logs messages at the DEBUG level, detailing processing of directories and files, + and warns if an invalid input path is encountered. + """ + all_files = [] + LOGGER.debug("Extensions: %s", exts) + workspace_dir = Path(os.environ.get("BUILD_WORKSPACE_DIRECTORY", "").strip()) + if not inputs: + cmds = ["git", "ls-files", "--cached", "--other", "--exclude-standard"] + found_dirs = subprocess.run( + cmds, capture_output=True, text=True, cwd=workspace_dir, check=True + ) + inputs = found_dirs.stdout.splitlines() + for i in inputs: + item = Path(workspace_dir / i) + if item.is_dir(): + LOGGER.debug("Processing directory: %s", item) + all_files.extend(get_files_from_dir(item, exts)) + elif item.is_file() and ( + exts is None + or item.suffix[1:] in exts + or (item.name == "BUILD" and "BUILD" in exts) + ): + LOGGER.debug("Processing file: %s", item) + all_files.append(item) + elif item.is_file(): + LOGGER.debug("Skipped (no configuration for file extension): %s", item) + else: + LOGGER.warning("Skipped (input is not a valid file or directory): %s", item) + return all_files + + +def create_temp_file(path, encoding): + """ + Creates a temporary file with the provided content. + + Args: + path (str): The path of file to write the content to the temporary file. + encoding (str, optional): Encoding type to use when writing the file. + + Returns: + str: The path to the temporary file created. + """ + with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as temp: + with open(path, "r", encoding=encoding) as handle: + for chunk in iter(lambda: handle.read(4096), ""): + temp.write(chunk) + return temp.name + + +def remove_old_header(file_path, encoding, num_of_chars): + """ + Removes the first `num_of_chars` characters from a file and updates it in-place. + + Args: + file_path (str): Path to the file to be modified. + encoding (str): Encoding used to read and write the file. + num_of_chars (int): Number of characters to remove from the beginning of the file. + + Raises: + IOError: If there is an issue reading or writing the file. + ValueError: If `num_of_chars` is negative. + """ + with open(file_path, "r", encoding=encoding) as file: + file.seek(num_of_chars) + with tempfile.NamedTemporaryFile( + "w", delete=False, encoding=encoding + ) as temp_file: + shutil.copyfileobj(file, temp_file) + shutil.move(temp_file.name, file_path) + + +def fix_copyright(path, copyright_text, encoding, offset) -> bool: + """ + Inserts a copyright header into the specified file, ensuring that existing + content is preserved according to the provided offset. + + Args: + path (str): The path to the file that needs the copyright header. + copyright_text (str): The copyright text to be added. + encoding (str): The character encoding used to read and write the file. + offset (int): The number of bytes to preserve at the top of the file. + If 0, the first line is overwritten unless it's empty. + For non-zero offsets, ensures the correct number of bytes + are preserved. + Returns: + bool: True if the copyright header was successfully added, False if there was an error + """ + + temporary_file = create_temp_file(path, encoding) + + with open(temporary_file, "r", encoding=encoding) as temp: + first_line = temp.readline() + byte_array = len(first_line.encode(encoding)) + + if offset > 0 and offset != byte_array: + LOGGER.error( + "%s: Invalid offset value: %d, expected: %d", path, offset, byte_array + ) + return False + + with open(path, "w", encoding=encoding) as handle: + temp.seek(0) + if offset > 0: + handle.write(first_line) + temp.seek(offset) + handle.write(copyright_text.format(year=datetime.now().year) + "\n") + for chunk in iter(lambda: temp.read(4096), ""): + handle.write(chunk) + LOGGER.info("Fixed missing header in: %s", path) + return True + + +def process_files( + *, + files, + templates, + fix, + exclusion: list[str] | None = None, + use_mmap=False, + encoding="utf-8", +): # pylint: disable=too-many-arguments + """ + Processes a list of files to check for the presence of copyright text. + + Args: + files (list): A list of file paths to check. + templates (dict): A dictionary where keys are file extensions + (e.g., '.py', '.txt') and values are strings or patterns + representing the required copyright text. + exclusion (list): A list of paths to files to be excluded from the copyright + check. + use_mmap (bool): Flag for using mmap function for reading files + (instead of standard option). + encoding (str): Encoding type to use when reading the file. + + Returns: + int: The number of files that do not contain the required copyright text. + """ + if exclusion is None: + exclusion = [] + results = {"no_copyright": 0, "fixed": 0, "duplicate_copyright": 0} + for item in files: + name = Path(item).name + key = name if name == "BUILD" else Path(item).suffix[1:] + if key not in templates.keys(): + logging.debug( + "Skipped (no configuration for selected file extension): %s", item + ) + continue + + if str(item) in exclusion: + logging.debug("Skipped due to exclusion: %s", item) + continue + + if os.path.getsize(item) == 0: + # No need to add copyright headers to empty files + continue + + # Automatically detect shebang and use its offset if no manual offset provided + shebang_offset = detect_shebang_offset(item, encoding) + + if has_duplicate_copyright( + item, templates[key], use_mmap, encoding, shebang_offset + ): + LOGGER.error("Duplicate copyright header in: %s", item) + results["duplicate_copyright"] += 1 + elif not has_copyright( + item, templates[key], use_mmap, encoding, shebang_offset + ): + if has_any_copyright(item, use_mmap, encoding, shebang_offset): + LOGGER.warning( + "Wrong copyright format in: %s, expected format from template", item + ) + elif fix: + fix_result = fix_copyright( + item, templates[key], encoding, shebang_offset + ) + results["no_copyright"] += 1 + if fix_result: + results["fixed"] += 1 + else: + # TODO: Clean up error message file name + LOGGER.error( + "Missing copyright header in: %s, use --fix to introduce it", item + ) + results["no_copyright"] += 1 + return results + + +def parse_arguments(argv): + """ + Parses command-line arguments. + + Args: + argv (list of str): List of command-line arguments. + + Returns: + argparse.Namespace: Parsed arguments containing files, directories, + copyright_file, extensions and log_file. + """ + parser = argparse.ArgumentParser( + description="A script to check for copyright in files with specific extensions." + ) + + parser.add_argument( + "-t", + "--template-file", + type=Path, + required=False, + default=Path(__file__).parent / "templates.ini", + help="Path to the template file", + ) + + parser.add_argument( + "--exclusion-file", + type=Path, + required=False, + help="Path to the file listing file paths excluded from the copyright check.", + ) + + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable debug logging level" + ) + + parser.add_argument( + "-l", + "--log-file", + type=Path, + default=None, + help="Redirect logs from STDOUT to this file", + ) + + parser.add_argument( + "-e", + "--extensions", + type=str, + nargs="+", + default=[ + "md", + "h", + "hpp", + "c", + "cpp", + "rs", + "rst", + "py", + "sh", + "bzl", + "lni", + "yml", + "yaml", + "trlc", + "rsl", + "puml", + "svg", + "BUILD", + "bazel", + ], + help="List of extensions to filter when searching for files, e.g., '.h .cpp'", + ) + + parser.add_argument( + "-f", + "--fix", + action="store_true", + help="Fix missing copyright headers by inserting them", + ) + + parser.add_argument( + "--encoding", default="utf-8", help="File encoding (default: utf-8)." + ) + + parser.add_argument( + "inputs", + nargs="*", + default=None, + help="Directories and/or files to parse.", + ) + + return parser.parse_args(argv) + + +def main(argv=None): + """ + Entry point for processing files to check for the presence of required copyright text. + + This function parses command-line arguments, configures logging, loads copyright templates, + collects input files based on provided criteria, and checks each file for the required + copyright text. + + Args: + argv (list, optional): List of command-line arguments. + If `None`, defaults to `sys.argv[1:]`. + + Returns: + int: Error code if an IOError occurs during loading templates or collecting input files; + otherwise, returns 0 as success. + """ + args = parse_arguments(argv if argv is not None else sys.argv[1:]) + configure_logging(args.log_file, args.verbose) + + try: + templates = load_templates(args.template_file) + except IOError as err: + LOGGER.error("Failed to load copyright text: %s", err) + return err.errno + + exclusion = [] + exclusion_valid = True + if args.exclusion_file: + try: + exclusion, exclusion_valid = load_exclusion(args.exclusion_file) + except IOError as err: + LOGGER.error("Failed to load exclusion list: %s", err) + return err.errno + + try: + files = collect_inputs(args.inputs, args.extensions) + except IOError as err: + LOGGER.error("Failed to process file %s with error", err.filename) + return err.errno + + LOGGER.debug("Running check on files: %s", files) + + results = process_files( + files=files, + templates=templates, + fix=args.fix, + exclusion=exclusion, + encoding=args.encoding, + ) + total_no = results["no_copyright"] + total_duplicates = results["duplicate_copyright"] + + return 0 if (total_no == 0 and total_duplicates == 0 and exclusion_valid) else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/cr_checker/tool/templates.ini b/cr_checker/tool/templates.ini new file mode 100644 index 0000000..3bc4f88 --- /dev/null +++ b/cr_checker/tool/templates.ini @@ -0,0 +1,91 @@ +# ******************************************************************************* +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +[cpp,c,h,hpp,trlc,rsl] +/******************************************************************************** + * Copyright (c) {year} Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +[py,sh,bzl,ini,yml,yaml,BUILD,bazel] +# ******************************************************************************* +# Copyright (c) {year} Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +[rst] +.. + # ******************************************************************************* + # Copyright (c) {year} Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* +[rs] +// ******************************************************************************* +// Copyright (c) {year} Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* +[puml] +' ******************************************************************************* +' Copyright (c) {year} Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* +[md] + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce807fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "tools" +version = "0.1.0" +description = "Gathering of different small tools used throughout S-CORE" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "bazel-runfiles==1.3.0", + "pytest>=9.1.1", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bd25d70 --- /dev/null +++ b/uv.lock @@ -0,0 +1,87 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "bazel-runfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/5e/c178358cd38c8431db47866698f4eb65c33b4b6a3cbee05d4de8f7a51788/bazel_runfiles-1.3.0-py3-none-any.whl", hash = "sha256:3978fa1c8225686d39aa0d9523860e3e1e6d34297066f7c97ea2eeafc776b097", size = 7582, upload-time = "2025-03-27T18:32:37.168Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tools" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bazel-runfiles" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "bazel-runfiles", specifier = "==1.3.0" }, + { name = "pytest", specifier = ">=9.1.1" }, +]