diff --git a/.github/workflows/testCode.yaml b/.github/workflows/testCode.yaml
index 397b342..4a4459f 100644
--- a/.github/workflows/testCode.yaml
+++ b/.github/workflows/testCode.yaml
@@ -8,36 +8,38 @@ on:
jobs:
testCode:
-
runs-on: windows-latest
strategy:
matrix:
python-version: [3.13]
steps:
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@v6
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- architecture: x86
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- python -m pip install tox
- - name: Test with tox
+ architecture: x64
+ - name: Run unit tests
+ shell: cmd
# Run automated/unit tests
- run: tox
- - name: Lint with flake8
+ run: .\rununittests.bat
+ - name: Lint
+ shell: cmd
# Check code with the linter
- run: .\runlint.ps1
+ run: .\runlint.bat
- name: Validate metadata
+ shell: cmd
# E2E: test to check the script can be run, no need to actually test the file.
# The internal checks are covered with unit tests.
- run: .\runvalidate.ps1 --dry-run _test/testData/addons/fake/13.0.json _tests\testData\nvdaAPIVersions.json
+ run: .\runvalidate.bat --dry-run _test/testData/addons/fake/13.0.json tests\testData\nvdaAPIVersions.json
- name: Get sha256
+ shell: cmd
# E2E: test to check the script can be run
- run: .\runsha.ps1 _tests\testData\fake.nvda-addon
+ run: .\runsha.bat tests\testData\fake.nvda-addon
- name: Generate json file
+ shell: cmd
# E2E: test to check the script can be run
- run: .\runcreatejson.ps1 -f _tests\testData\fake.nvda-addon --dir _tests\testOutput\test_runcreatejson --channel=stable --publisher=fakepublisher --sourceUrl=https://github.com/fake/ --url=https://github.com/fake.nvda-addon --licName="GPL v2" --licUrl="https://www.gnu.org/licenses/gpl-2.0.html"
+ run: .\runcreatejson.bat -f tests\testData\fake.nvda-addon --dir tests\testOutput\test_runcreatejson --channel=stable --publisher=fakepublisher --sourceUrl=https://github.com/fake/ --url=https://github.com/fake.nvda-addon --licName="GPL v2" --licUrl="https://www.gnu.org/licenses/gpl-2.0.html"
diff --git a/.gitignore b/.gitignore
index c7fce1c..523c646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-.tox
.venv
__pycache__
-_tests/testOutput
+testOutput
+*.egg-info
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..e3ce905
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,105 @@
+# https://pre-commit.ci/
+# Configuration for Continuous Integration service
+ci:
+ # Can't run Windows scons scripts on Linux.
+ # Pyright does not seem to work in pre-commit CI
+ skip: [unitTest, pyright]
+ autoupdate_schedule: monthly
+ autoupdate_commit_msg: "Pre-commit auto-update"
+ autofix_commit_msg: "Pre-commit auto-fix"
+ submodules: true
+
+default_language_version:
+ python: python3.13
+
+repos:
+- repo: https://github.com/pre-commit-ci/pre-commit-ci-config
+ rev: v1.6.1
+ hooks:
+ - id: check-pre-commit-ci-config
+
+- repo: meta
+ hooks:
+ # ensures that exclude directives apply to any file in the repository.
+ - id: check-useless-excludes
+ # ensures that the configured hooks apply to at least one file in the repository.
+ - id: check-hooks-apply
+
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ # Prevents commits to certain branches
+ - id: no-commit-to-branch
+ args: ["--branch", "main"]
+ # Checks that large files have not been added. Default cut-off for "large" files is 500kb.
+ - id: check-added-large-files
+ # Checks python syntax
+ - id: check-ast
+ # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time)
+ - id: check-case-conflict
+ # Checks for artifacts from resolving merge conflicts.
+ - id: check-merge-conflict
+ # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs.
+ - id: debug-statements
+ # Removes trailing whitespace.
+ - id: trailing-whitespace
+ types_or: [python, batch, markdown, toml, yaml, powershell]
+ # Ensures all files end in 1 (and only 1) newline.
+ - id: end-of-file-fixer
+ types_or: [python, batch, markdown, toml, yaml, powershell]
+ # Removes the UTF-8 BOM from files that have it.
+ # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding
+ - id: fix-byte-order-marker
+ types_or: [python, batch, markdown, toml, yaml, powershell]
+ # Validates TOML files.
+ - id: check-toml
+ # Validates YAML files.
+ - id: check-yaml
+ # Ensures that links to lines in files under version control point to a particular commit.
+ - id: check-vcs-permalinks
+ # Avoids using reserved Windows filenames.
+ - id: check-illegal-windows-names
+ # Checks that tests are named test_*.py.
+ - id: name-tests-test
+ args: ["--unittest"]
+
+- repo: https://github.com/asottile/add-trailing-comma
+ rev: v3.2.0
+ hooks:
+ # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables,
+ # if a trailing comma is added.
+ # This adds a trailing comma to args/iterable items in case it was missed.
+ - id: add-trailing-comma
+
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ # Matches Ruff version in pyproject.
+ rev: v0.13.0
+ hooks:
+ - id: ruff
+ name: lint with ruff
+ args: [ --fix ]
+ - id: ruff-format
+ name: format with ruff
+
+- repo: https://github.com/RobertCraigie/pyright-python
+ rev: v1.1.405
+ hooks:
+ - id: pyright
+ name: Check types with pyright
+
+- repo: https://github.com/astral-sh/uv-pre-commit
+ rev: 0.8.17
+ hooks:
+ - id: uv-lock
+ name: Verify uv lock file
+ # Override python interpreter from .python-versions as that is too strict for pre-commit.ci
+ args: ["-p3.13"]
+
+- repo: local
+ hooks:
+ - id: unitTest
+ name: unit tests
+ entry: ./rununittests.bat
+ language: script
+ pass_filenames: false
+ types_or: [python, batch]
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..ad929f8
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+cpython-3.13-windows-x86_64-none
diff --git a/README.md b/README.md
index 825d81e..26172d4 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ The Action aims to validate the metadata of add-ons submitted to
* The `*.nvda-addon` file can be downloaded
* The Sha256 of the downloaded `*.nvda-addon` file matches.
* Check data matches the addon's manifest file.
- * The manifest exists in the downloaded `*.nvda-addon` file and can be loaded by the `AddonManifest` class.
+ * The manifest exists in the downloaded `*.nvda-addon` file and can be loaded by the `AddonManifest` class.
* The submission addonName matches the manifest summary field
* The submission description matches the manifest description field
* The homepage URL matches the manifest URL field
@@ -40,8 +40,7 @@ From cmd.exe:
To test the scripts used in this action, you can run the unit tests.
-1. Install [tox](https://pypi.org/project/tox): `pip install tox`
-1. `tox`
+1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
## Python linting
diff --git a/_validate/addonManifest.py b/_validate/addonManifest.py
index 9ae4d4d..7be77e3 100644
--- a/_validate/addonManifest.py
+++ b/_validate/addonManifest.py
@@ -1,32 +1,25 @@
-#!/usr/bin/env python
-
-# Copyright (C) 2022-2023 NV Access Limited
+# Copyright (C) 2022-2025 NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
-import os
-import sys
-from typing import (
- Optional,
- TextIO,
- Tuple,
-)
-from io import StringIO
+from io import StringIO, TextIOBase
+from typing import Any, cast
from configobj import ConfigObj
from configobj.validate import Validator, ValidateError
-sys.path.append(os.path.dirname(__file__))
-# E402 module level import not at top of file
-from majorMinorPatch import MajorMinorPatch # noqa:E402
-del sys.path[-1]
+from .majorMinorPatch import MajorMinorPatch
+
+ApiVersionT = tuple[int, int, int] # major, minor, patch
class AddonManifest(ConfigObj):
"""From the NVDA addonHandler module. Should be kept in sync.
- Add-on manifest file. It contains metadata about an NVDA add-on package. """
- configspec = ConfigObj(StringIO(
- """
+ Add-on manifest file. It contains metadata about an NVDA add-on package."""
+
+ configspec = ConfigObj(
+ StringIO(
+ """
# NVDA Add-on Manifest configuration specification
# Add-on unique name
# Suggested convention is lowerCamelCase.
@@ -66,56 +59,59 @@ class AddonManifest(ConfigObj):
# "0.0.0" is also valid.
# The final integer can be left out, and in that case will default to 0. E.g. 2019.1
+ """,
+ ),
+ )
+
+ def __init__(self, input: str | TextIOBase, translatedInput: str | None = None):
"""
- ))
+ Constructs an :class:`AddonManifest` instance from manifest string data.
- def __init__(self, input: TextIO, translatedInput: Optional[TextIO] = None):
- """ Constructs an L{AddonManifest} instance from manifest string data
- @param input: data to read the manifest information
- @param translatedInput: translated manifest input
+ :param input: data to read the manifest information. Can be a filename or a file-like object.
+ :param translatedInput: translated manifest input
"""
- super().__init__(
+ super().__init__( # type: ignore[reportUnknownMemberType]
input,
configspec=self.configspec,
- encoding='utf-8',
- default_encoding='utf-8',
+ encoding="utf-8",
+ default_encoding="utf-8",
)
- self._errors: Optional[str] = None
- val = Validator({"apiVersion": validate_apiVersionString})
- result = self.validate(val, copy=True, preserve_errors=True)
+ self._errors: str | None = None
+ validator = Validator({"apiVersion": validate_apiVersionString})
+ result = self.validate(validator, copy=True, preserve_errors=True) # type: ignore[reportUnknownMemberType]
if result is not True:
self._errors = result
elif self._validateApiVersionRange() is not True:
self._errors = "Constraint not met: minimumNVDAVersion ({}) <= lastTestedNVDAVersion ({})".format(
- self.get("minimumNVDAVersion"),
- self.get("lastTestedNVDAVersion")
+ cast(ApiVersionT, self.get("minimumNVDAVersion")), # type: ignore[reportUnknownMemberType]
+ cast(ApiVersionT, self.get("lastTestedNVDAVersion")), # type: ignore[reportUnknownMemberType]
)
self._translatedConfig = None
if translatedInput is not None:
- self._translatedConfig = ConfigObj(translatedInput, encoding='utf-8', default_encoding='utf-8')
- for key in ('summary', 'description'):
- val = self._translatedConfig.get(key)
+ self._translatedConfig = ConfigObj(translatedInput, encoding="utf-8", default_encoding="utf-8")
+ for key in ("summary", "description"):
+ val: str = self._translatedConfig.get(key) # type: ignore[reportUnknownMemberType]
if val:
self[key] = val
@property
- def errors(self) -> str:
+ def errors(self) -> str | None:
return self._errors
def _validateApiVersionRange(self) -> bool:
- lastTested = self.get("lastTestedNVDAVersion")
- minRequiredVersion = self.get("minimumNVDAVersion")
+ lastTested = cast(ApiVersionT, self.get("lastTestedNVDAVersion")) # type: ignore[reportUnknownMemberType]
+ minRequiredVersion = cast(ApiVersionT, self.get("minimumNVDAVersion")) # type: ignore[reportUnknownMemberType]
return minRequiredVersion <= lastTested
-def validate_apiVersionString(value: str) -> Tuple[int, int, int]:
+def validate_apiVersionString(value: str | Any) -> ApiVersionT:
"""From the NVDA addonHandler module. Should be kept in sync."""
if not value or value == "None":
return (0, 0, 0)
if not isinstance(value, str):
raise ValidateError(
"Expected an apiVersion in the form of a string. "
- f"e.g. '2019.1.0' instead of {value} (type {type(value)})"
+ f"e.g. '2019.1.0' instead of {value} (type {type(value)})",
)
try:
versionParsed = MajorMinorPatch.getFromStr(value)
diff --git a/_validate/createJson.py b/_validate/createJson.py
index febc407..f94f398 100644
--- a/_validate/createJson.py
+++ b/_validate/createJson.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
# Copyright (C) 2022-2025 Noelia Ruiz Martínez, NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
@@ -9,27 +7,39 @@
import json
import argparse
import os
-import sys
+from typing import cast
import zipfile
-from typing import (
- Dict,
- Optional,
- cast,
-)
-
-sys.path.append(os.path.dirname(__file__)) # To allow this module to be run as a script by runcreatejson.bat
-# E402 module level import not at top of file
-from addonManifest import AddonManifest # noqa:E402
-from manifestLoader import getAddonManifest, getAddonManifestLocalizations # noqa:E402
-from majorMinorPatch import MajorMinorPatch # noqa:E402
-import sha256 # noqa:E402
-del sys.path[-1]
+from .addonManifest import AddonManifest, ApiVersionT
+from .manifestLoader import getAddonManifest, getAddonManifestLocalizations
+from .majorMinorPatch import MajorMinorPatch
+from .sha256 import sha256_checksum
+
+
+@dataclasses.dataclass
+class AddonData:
+ addonId: str
+ displayName: str
+ URL: str
+ description: str
+ sha256: str
+ addonVersionName: str
+ addonVersionNumber: dict[str, int]
+ minNVDAVersion: dict[str, int]
+ lastTestedVersion: dict[str, int]
+ channel: str
+ publisher: str
+ sourceURL: str
+ license: str
+ homepage: str | None
+ licenseURL: str | None
+ submissionTime: int
+ translations: list[dict[str, str]]
def getSha256(addonPath: str) -> str:
with open(addonPath, "rb") as f:
- sha256Addon = sha256.sha256_checksum(f)
+ sha256Addon = sha256_checksum(f)
return sha256Addon
@@ -38,17 +48,17 @@ def getCurrentTime() -> int:
def generateJsonFile(
- manifest: AddonManifest,
- addonPath: str,
- parentDir: str,
- channel: str,
- publisher: str,
- sourceUrl: str,
- url: str,
- licenseName: str,
- licenseUrl: Optional[str],
+ manifest: AddonManifest,
+ addonPath: str,
+ parentDir: str,
+ channel: str,
+ publisher: str,
+ sourceUrl: str,
+ url: str,
+ licenseName: str,
+ licenseUrl: str | None,
) -> None:
- data = _createDictMatchingJsonSchema(
+ data = _createDataclassMatchingJsonSchema(
manifest=manifest,
sha=getSha256(addonPath),
channel=channel,
@@ -62,84 +72,80 @@ def generateJsonFile(
filePath = buildOutputFilePath(data, parentDir)
with open(filePath, "wt", encoding="utf-8") as f:
- json.dump(data, f, indent="\t", ensure_ascii=False)
+ json.dump(dataclasses.asdict(data), f, indent="\t", ensure_ascii=False)
print(f"Wrote json file: {filePath}")
-def buildOutputFilePath(data, parentDir) -> os.PathLike:
- addonDir = os.path.join(parentDir, data["addonId"])
- versionNumber = MajorMinorPatch(**data["addonVersionNumber"])
- canonicalVersionString = ".".join(
- (str(i) for i in dataclasses.astuple(versionNumber))
- )
+def buildOutputFilePath(data: AddonData, parentDir: str) -> os.PathLike[str]:
+ addonDir = os.path.join(parentDir, data.addonId)
+ versionNumber = MajorMinorPatch(**data.addonVersionNumber)
+ canonicalVersionString = ".".join((str(i) for i in dataclasses.astuple(versionNumber)))
if not os.path.isdir(addonDir):
os.makedirs(addonDir)
- filePath = os.path.join(addonDir, f'{canonicalVersionString}.json')
- return cast(os.PathLike, filePath)
-
-
-def _createDictMatchingJsonSchema(
- manifest: AddonManifest,
- sha: str,
- channel: str,
- publisher: str,
- sourceUrl: str,
- url: str,
- licenseName: str,
- licenseUrl: Optional[str],
-) -> Dict[str, str]:
+ filePath = os.path.join(addonDir, f"{canonicalVersionString}.json")
+ return cast(os.PathLike[str], filePath)
+
+
+def _createDataclassMatchingJsonSchema(
+ manifest: AddonManifest,
+ sha: str,
+ channel: str,
+ publisher: str,
+ sourceUrl: str,
+ url: str,
+ licenseName: str,
+ licenseUrl: str | None,
+) -> AddonData:
"""Refer to _validate/addonVersion_schema.json"""
try:
- addonVersionNumber = MajorMinorPatch.getFromStr(manifest["version"])
+ addonVersionNumber = MajorMinorPatch.getFromStr(cast(str, manifest["version"]))
except ValueError as e:
- raise ValueError(f"Manifest version invalid {addonVersionNumber}") from e
+ raise ValueError(f"Manifest version invalid {manifest['version']}") from e
- try:
- addonData = {
- "addonId": manifest["name"],
- "displayName": manifest["summary"],
- "URL": url,
- "description": manifest["description"],
- "sha256": sha,
- "addonVersionName": manifest["version"],
- "addonVersionNumber": dataclasses.asdict(addonVersionNumber),
- "minNVDAVersion": dataclasses.asdict(
- MajorMinorPatch(*manifest["minimumNVDAVersion"])
- ),
- "lastTestedVersion": dataclasses.asdict(
- MajorMinorPatch(*manifest["lastTestedNVDAVersion"])
- ),
- "channel": channel,
- "publisher": publisher,
- "sourceURL": sourceUrl,
- "license": licenseName,
- }
- except KeyError as e:
- raise KeyError(f"Manifest missing required key '{e.args[0]}'.") from e
+ for key in ("name", "summary", "description", "minimumNVDAVersion", "lastTestedNVDAVersion", "version"):
+ if key not in manifest:
+ raise KeyError(f"Manifest missing required key '{key}'.")
# Add optional fields
- homepage = manifest.get("url")
- if homepage and homepage != 'None':
- # The config default is None
- # which is parsed by configobj as a string not a NoneType
- addonData["homepage"] = homepage
- if licenseUrl:
- addonData["licenseURL"] = licenseUrl
- addonData["submissionTime"] = getCurrentTime()
-
- addonData["translations"] = []
+ homepage: str | None = manifest.get("url") # type: ignore[reportUnknownMemberType]
+ if not homepage or homepage == "None":
+ homepage = None
+
+ translations: list[dict[str, str]] = []
for langCode, manifest in getAddonManifestLocalizations(manifest):
try:
- addonData["translations"].append(
+ translations.append(
{
"language": langCode,
- "displayName": manifest["summary"],
- "description": manifest["description"],
- }
+ "displayName": cast(str, manifest["summary"]),
+ "description": cast(str, manifest["description"]),
+ },
)
except KeyError as e:
raise KeyError(f"Translation for {langCode} missing required key '{e.args[0]}'.") from e
+ addonData = AddonData(
+ addonId=cast(str, manifest["name"]),
+ displayName=cast(str, manifest["summary"]),
+ URL=url,
+ description=cast(str, manifest["description"]),
+ sha256=sha,
+ addonVersionName=cast(str, manifest["version"]),
+ addonVersionNumber=dataclasses.asdict(addonVersionNumber),
+ minNVDAVersion=dataclasses.asdict(MajorMinorPatch(*cast(tuple[int], manifest["minimumNVDAVersion"]))),
+ lastTestedVersion=dataclasses.asdict(
+ MajorMinorPatch(*cast(ApiVersionT, manifest["lastTestedNVDAVersion"])),
+ ),
+ channel=channel,
+ publisher=publisher,
+ sourceURL=sourceUrl,
+ license=licenseName,
+ homepage=homepage,
+ licenseURL=licenseUrl,
+ submissionTime=getCurrentTime(),
+ translations=translations,
+ )
+
return addonData
@@ -201,7 +207,7 @@ def main():
required=False,
)
args = parser.parse_args()
- errorFilePath: Optional[str] = args.errorOutputFile
+ errorFilePath: str | None = args.errorOutputFile
try:
manifest = getAddonManifest(args.file)
@@ -243,5 +249,5 @@ def main():
raise
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/_validate/majorMinorPatch.py b/_validate/majorMinorPatch.py
index 2737315..1239916 100644
--- a/_validate/majorMinorPatch.py
+++ b/_validate/majorMinorPatch.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2023 Noelia Ruiz Martínez, NV Access Limited
+# Copyright (C) 2023-2025 Noelia Ruiz Martínez, NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
@@ -20,7 +20,7 @@ def getFromStr(cls, version: str) -> "MajorMinorPatch":
return cls(
major=int(versionParts[0]),
minor=int(versionParts[1]),
- patch=0 if len(versionParts) == 2 else int(versionParts[2])
+ patch=0 if len(versionParts) == 2 else int(versionParts[2]),
)
def __str__(self) -> str:
diff --git a/_validate/manifestLoader.py b/_validate/manifestLoader.py
index f63433c..8e40e2f 100644
--- a/_validate/manifestLoader.py
+++ b/_validate/manifestLoader.py
@@ -1,20 +1,22 @@
-# Copyright (C) 2022 Noelia Ruiz Martínez, NV Access Limited
+# Copyright (C) 2022-2025 Noelia Ruiz Martínez, NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
+from collections.abc import Generator
from glob import glob
import os
import pathlib
import shutil
-from typing import Generator, Tuple
-import zipfile
-from addonManifest import AddonManifest
import tempfile
+import zipfile
+
+from .addonManifest import AddonManifest
+
TEMP_DIR = tempfile.gettempdir()
def getAddonManifest(addonPath: str) -> AddonManifest:
- """ Extract manifest.ini from *.nvda-addon and parse.
+ """Extract manifest.ini from *.nvda-addon and parse.
Raise on error.
"""
extractDir = os.path.join(TEMP_DIR, "tempAddon")
@@ -33,9 +35,9 @@ def getAddonManifest(addonPath: str) -> AddonManifest:
def getAddonManifestLocalizations(
- manifest: AddonManifest
-) -> Generator[Tuple[str, AddonManifest], None, None]:
- """ Extract data from translated manifest.ini from *.nvda-addon and parse.
+ manifest: AddonManifest,
+) -> Generator[tuple[str, AddonManifest], None, None]:
+ """Extract data from translated manifest.ini from *.nvda-addon and parse.
Raise on error.
"""
if manifest.filename is None:
diff --git a/_validate/regenerateTranslations.py b/_validate/regenerateTranslations.py
index 9ead97b..0f015b7 100644
--- a/_validate/regenerateTranslations.py
+++ b/_validate/regenerateTranslations.py
@@ -1,27 +1,16 @@
-#!/usr/bin/env python
-
-# Copyright (C) 2023 NV Access Limited
+# Copyright (C) 2023-2025 NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
import argparse
import glob
import json
-import os
-import sys
from urllib.request import urlretrieve
-from typing import (
- Optional,
-)
-
-sys.path.append(os.path.dirname(__file__)) # To allow this module to be run as a script by runcreatejson.bat
-# E402 module level import not at top of file
-from manifestLoader import getAddonManifest, getAddonManifestLocalizations # noqa:E402
-del sys.path[-1]
+from .manifestLoader import getAddonManifest, getAddonManifestLocalizations
-def regenerateJsonFile(filePath: str, errorFilePath: Optional[str]) -> None:
+def regenerateJsonFile(filePath: str, errorFilePath: str | None) -> None:
with open(filePath, encoding="utf-8") as f:
addonData = json.load(f)
if addonData.get("legacy"):
@@ -41,9 +30,9 @@ def regenerateJsonFile(filePath: str, errorFilePath: Optional[str]) -> None:
"language": langCode,
"displayName": manifest["summary"],
"description": manifest["description"],
- }
+ },
)
-
+
with open(filePath, "wt", encoding="utf-8") as f:
json.dump(addonData, f, indent="\t", ensure_ascii=False)
print(f"Wrote json file: {filePath}")
@@ -64,10 +53,10 @@ def main():
default=None,
)
args = parser.parse_args()
- errorFilePath: Optional[str] = args.errorOutputFile
+ errorFilePath: str | None = args.errorOutputFile
for addonJsonFile in glob.glob(f"{args.parentDir}/**/*.json"):
regenerateJsonFile(addonJsonFile, errorFilePath)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/_validate/sha256.py b/_validate/sha256.py
index 4455d90..c3e120c 100644
--- a/_validate/sha256.py
+++ b/_validate/sha256.py
@@ -1,11 +1,9 @@
-#!/usr/bin/env python
-
-# Copyright (C) 2020 NV Access Limited
+# Copyright (C) 2020-2025 NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
-import hashlib
import argparse
+import hashlib
import typing
#: The read size for each chunk read from the file, prevents memory overuse with large files.
@@ -19,9 +17,9 @@ def sha256_checksum(binaryReadModeFile: typing.BinaryIO, blockSize: int = BLOCK_
:return: The Sha256 hex digest.
"""
sha256 = hashlib.sha256()
- assert binaryReadModeFile.readable() and binaryReadModeFile.mode == 'rb'
+ assert binaryReadModeFile.readable() and binaryReadModeFile.mode == "rb"
f = binaryReadModeFile
- for block in iter(lambda: f.read(blockSize), b''):
+ for block in iter(lambda: f.read(blockSize), b""):
sha256.update(block)
return sha256.hexdigest()
@@ -29,14 +27,14 @@ def sha256_checksum(binaryReadModeFile: typing.BinaryIO, blockSize: int = BLOCK_
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
- type=argparse.FileType('rb'),
+ type=argparse.FileType("rb"),
dest="file",
- help="The NVDA addon (*.nvda-addon) to use when computing the sha256."
+ help="The NVDA addon (*.nvda-addon) to use when computing the sha256.",
)
args = parser.parse_args()
checksum = sha256_checksum(args.file)
print(f"Sha256:\t {checksum}")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/_validate/validate.py b/_validate/validate.py
index 17cd2d7..28402d8 100644
--- a/_validate/validate.py
+++ b/_validate/validate.py
@@ -1,38 +1,26 @@
-#!/usr/bin/env python
-
-# Copyright (C) 2021-2023 Noelia Ruiz Martínez, NV Access Limited
+# Copyright (C) 2021-2025 Noelia Ruiz Martínez, NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
import argparse
+from collections.abc import Generator
from glob import glob
import json
import os
import re
-import sys
-from typing import (
- Any,
- Dict,
- Generator,
- Iterable,
- List,
- Optional,
-)
+from typing import Any, cast
import urllib.request
from jsonschema import validate, exceptions
-sys.path.append(os.path.dirname(__file__)) # To allow this module to be run as a script by runValidate.bat
-# E402 module level import not at top of file
-import sha256 # noqa:E402
-from addonManifest import AddonManifest # noqa:E402
-from manifestLoader import getAddonManifest, TEMP_DIR # noqa:E402
-from majorMinorPatch import MajorMinorPatch # noqa:E402
-del sys.path[-1]
+from .addonManifest import AddonManifest, ApiVersionT
+from .manifestLoader import getAddonManifest, TEMP_DIR
+from .majorMinorPatch import MajorMinorPatch
+from .sha256 import sha256_checksum
JSON_SCHEMA = os.path.join(os.path.dirname(__file__), "addonVersion_schema.json")
-JsonObjT = Dict[str, Any]
+JsonObjT = dict[str, Any]
ValidationErrorGenerator = Generator[str, None, None]
@@ -44,23 +32,21 @@ def getAddonMetadata(filename: str) -> JsonObjT:
"""
with open(filename, encoding="utf-8") as f:
data: JsonObjT = json.load(f)
- _validateJson(data)
+ validateJson(data)
return data
-def getExistingVersions(verFilename: str) -> List[str]:
- """Loads API versions file and returns list of versions formatted as strings.
- """
+def getExistingVersions(verFilename: str) -> list[str]:
+ """Loads API versions file and returns list of versions formatted as strings."""
with open(verFilename, encoding="utf-8") as f:
- data: List[JsonObjT] = json.load(f)
+ data: list[JsonObjT] = json.load(f)
return [_formatVersionString(version["apiVer"].values()) for version in data]
-def getExistingStableVersions(verFilename: str) -> List[str]:
- """Loads API versions file and returns list of stable versions formatted as strings.
- """
+def getExistingStableVersions(verFilename: str) -> list[str]:
+ """Loads API versions file and returns list of stable versions formatted as strings."""
with open(verFilename, encoding="utf-8") as f:
- data: List[JsonObjT] = json.load(f)
+ data: list[JsonObjT] = json.load(f)
return [
_formatVersionString(version["apiVer"].values())
for version in data
@@ -68,8 +54,8 @@ def getExistingStableVersions(verFilename: str) -> List[str]:
]
-def _validateJson(data: JsonObjT) -> None:
- """ Ensure that the loaded metadata conforms to the schema.
+def validateJson(data: JsonObjT) -> None:
+ """Ensure that the loaded metadata conforms to the schema.
Raise error if not
"""
with open(JSON_SCHEMA, encoding="utf-8") as f:
@@ -123,15 +109,14 @@ def checkSha256(addonPath: str, expectedSha: str) -> ValidationErrorGenerator:
Return an error if it does not match the expected.
"""
with open(addonPath, "rb") as f:
- sha256Addon = sha256.sha256_checksum(f)
+ sha256Addon = sha256_checksum(f)
if sha256Addon.upper() != expectedSha.upper():
yield f"Sha256 of .nvda-addon at URL is: {sha256Addon}"
def checkSummaryMatchesDisplayName(manifest: AddonManifest, submission: JsonObjT) -> ValidationErrorGenerator:
- """ The submission Name must match the *.nvda-addon manifest summary field.
- """
- summary = manifest["summary"]
+ """The submission Name must match the *.nvda-addon manifest summary field."""
+ summary = cast(str, manifest["summary"])
if summary != submission["displayName"]:
yield (
f"Submission 'displayName' must be set to '{summary}' in json file."
@@ -140,8 +125,8 @@ def checkSummaryMatchesDisplayName(manifest: AddonManifest, submission: JsonObjT
def checkDescriptionMatches(manifest: AddonManifest, submission: JsonObjT) -> ValidationErrorGenerator:
- """ The submission description must match the *.nvda-addon manifest description field."""
- description = manifest["description"]
+ """The submission description must match the *.nvda-addon manifest description field."""
+ description = cast(str, manifest["description"])
if description != submission["description"]:
yield (
f"Submission 'description' must be set to '{description}' in json file."
@@ -150,32 +135,29 @@ def checkDescriptionMatches(manifest: AddonManifest, submission: JsonObjT) -> Va
def checkUrlMatchesHomepage(manifest: AddonManifest, submission: JsonObjT) -> ValidationErrorGenerator:
- """ The submission homepage must match the *.nvda-addon manifest url field.
- """
- manifestUrl = manifest.get("url")
- if manifestUrl == 'None':
+ """The submission homepage must match the *.nvda-addon manifest url field."""
+ manifestUrl = manifest.get("url") # type: ignore[reportUnknownMemberType]
+ if manifestUrl == "None":
# The config default is None which is parsed by configobj as a string not a NoneType
manifestUrl = None
if manifestUrl != submission.get("homepage"):
- yield f"Submission 'homepage' must be set to '{manifest.get('url')}' " \
- f"in json file instead of {submission.get('homepage')}"
+ yield (
+ f"Submission 'homepage' must be set to '{manifest.get('url')}' " # type: ignore[reportUnknownMemberType]
+ f"in json file instead of {submission.get('homepage')}"
+ )
def checkAddonId(
- manifest: AddonManifest,
- submissionFilePath: str,
- submission: JsonObjT,
+ manifest: AddonManifest,
+ submissionFilePath: str,
+ submission: JsonObjT,
) -> ValidationErrorGenerator:
- """ The submitted json file must be placed in a folder matching the *.nvda-addon manifest name field.
- """
- expectedName = manifest["name"]
+ """The submitted json file must be placed in a folder matching the *.nvda-addon manifest name field."""
+ expectedName = cast(str, manifest["name"])
idInPath = os.path.basename(os.path.dirname(submissionFilePath))
if expectedName != idInPath:
- yield (
- "Submitted json file must be placed in a folder matching"
- f" the addonId/name '{expectedName}'"
- )
- if expectedName != submission['addonId']:
+ yield (f"Submitted json file must be placed in a folder matching the addonId/name '{expectedName}'")
+ if expectedName != submission["addonId"]:
yield (
"Submission data 'addonId' field does not match 'name' field in addon manifest:"
f" {expectedName} vs {submission['addonId']}"
@@ -192,14 +174,13 @@ def checkAddonId(
VERSION_PARSE = re.compile(r"^(\d+)(?:$|(?:\.(\d+)$)|(?:\.(\d+)\.(\d+)$))")
-def parseVersionStr(ver: str) -> Dict[str, int]:
-
+def parseVersionStr(ver: str) -> dict[str, int]:
matches = VERSION_PARSE.match(ver)
if not matches:
return {
"major": 0,
"minor": 0,
- "patch": 0
+ "patch": 0,
}
groups = list(x for x in matches.groups() if x)
@@ -207,27 +188,23 @@ def parseVersionStr(ver: str) -> Dict[str, int]:
version = {
"major": int(groups[0]),
"minor": int(groups[1]),
- "patch": int(groups[2])
+ "patch": int(groups[2]),
}
return version
-def _formatVersionString(versionValues: Iterable) -> str:
- versionValues = list(versionValues)
- assert 1 < len(versionValues) < 4
- return ".".join(
- str(x) for x in versionValues
- )
+def _formatVersionString(versionValues: ApiVersionT) -> str:
+ return ".".join(str(x) for x in versionValues)
def checkSubmissionFilenameMatchesVersionNumber(
- submissionFilePath: str,
- submission: JsonObjT,
+ submissionFilePath: str,
+ submission: JsonObjT,
) -> ValidationErrorGenerator:
versionFromPath: str = os.path.splitext(os.path.basename(submissionFilePath))[0]
- versionNumber: JsonObjT = submission['addonVersionNumber']
- formattedVersionNumber = _formatVersionString(versionNumber.values())
+ versionNumber: dict[str, int] = submission["addonVersionNumber"]
+ formattedVersionNumber = _formatVersionString(cast(ApiVersionT, tuple(versionNumber.values())))
if versionFromPath != formattedVersionNumber:
# yield f"Submitted json file should be named '{formattedVersionNumber}.json'"
yield (
@@ -238,24 +215,22 @@ def checkSubmissionFilenameMatchesVersionNumber(
)
-def checkParsedVersionNameMatchesVersionNumber(
- submission: JsonObjT
-) -> ValidationErrorGenerator:
- versionNumber: JsonObjT = submission['addonVersionNumber']
- versionName: str = submission['addonVersionName']
+def checkParsedVersionNameMatchesVersionNumber(submission: JsonObjT) -> ValidationErrorGenerator:
+ versionNumber: dict[str, int] = submission["addonVersionNumber"]
+ versionName: str = submission["addonVersionName"]
parsedVersion = parseVersionStr(versionName)
if parsedVersion != versionNumber:
yield (
"Warning: submission data 'addonVersionName' and 'addonVersionNumber' mismatch."
- f" Unable to parse: {versionName} and match with {_formatVersionString(versionNumber.values())}"
+ f" Unable to parse: {versionName} and match with {_formatVersionString(cast(ApiVersionT, tuple(versionNumber.values())))}"
)
def checkManifestVersionMatchesVersionName(
- manifest: AddonManifest,
- submission: JsonObjT
+ manifest: AddonManifest,
+ submission: JsonObjT,
) -> ValidationErrorGenerator:
- manifestVersion: str = manifest["version"]
+ manifestVersion: str = cast(str, manifest["version"])
addonVersionName: str = submission["addonVersionName"]
if manifestVersion != addonVersionName:
yield (
@@ -264,11 +239,8 @@ def checkManifestVersionMatchesVersionName(
)
-def checkMinNVDAVersionMatches(
- manifest: AddonManifest,
- submission: JsonObjT
-) -> ValidationErrorGenerator:
- manifestMinimumNVDAVersion = MajorMinorPatch(*manifest["minimumNVDAVersion"])
+def checkMinNVDAVersionMatches(manifest: AddonManifest, submission: JsonObjT) -> ValidationErrorGenerator:
+ manifestMinimumNVDAVersion = MajorMinorPatch(*cast(ApiVersionT, manifest["minimumNVDAVersion"]))
minNVDAVersion = MajorMinorPatch(**submission["minNVDAVersion"])
if manifestMinimumNVDAVersion != minNVDAVersion:
yield (
@@ -278,10 +250,10 @@ def checkMinNVDAVersionMatches(
def checkLastTestedNVDAVersionMatches(
- manifest: AddonManifest,
- submission: JsonObjT
+ manifest: AddonManifest,
+ submission: JsonObjT,
) -> ValidationErrorGenerator:
- manifestLastTestedNVDAVersion = MajorMinorPatch(*manifest["lastTestedNVDAVersion"])
+ manifestLastTestedNVDAVersion = MajorMinorPatch(*cast(ApiVersionT, manifest["lastTestedNVDAVersion"]))
lastTestedVersion = MajorMinorPatch(**submission["lastTestedVersion"])
if manifestLastTestedNVDAVersion != lastTestedVersion:
yield (
@@ -291,44 +263,42 @@ def checkLastTestedNVDAVersionMatches(
def checkLastTestedVersionExist(submission: JsonObjT, verFilename: str) -> ValidationErrorGenerator:
- lastTestedVersion: JsonObjT = submission['lastTestedVersion']
- formattedLastTestedVersion: str = _formatVersionString(lastTestedVersion.values())
+ lastTestedVersion: dict[str, int] = submission["lastTestedVersion"]
+ formattedLastTestedVersion: str = _formatVersionString(cast(ApiVersionT, lastTestedVersion.values()))
if formattedLastTestedVersion not in getExistingVersions(verFilename):
yield f"Last tested version error: {formattedLastTestedVersion} doesn't exist"
- elif (
- submission["channel"] == "stable"
- and formattedLastTestedVersion not in getExistingStableVersions(verFilename)
+ elif submission["channel"] == "stable" and formattedLastTestedVersion not in getExistingStableVersions(
+ verFilename,
):
- yield f"Last tested version error: {formattedLastTestedVersion} is not stable yet. " + \
- "Please submit add-on using the beta or dev channel."
+ yield (
+ f"Last tested version error: {formattedLastTestedVersion} is not stable yet. "
+ + "Please submit add-on using the beta or dev channel."
+ )
def checkMinRequiredVersionExist(submission: JsonObjT, verFilename: str) -> ValidationErrorGenerator:
- minRequiredVersion: JsonObjT = submission["minNVDAVersion"]
- formattedMinRequiredVersion: str = _formatVersionString(minRequiredVersion.values())
+ minRequiredVersion: dict[str, int] = submission["minNVDAVersion"]
+ formattedMinRequiredVersion: str = _formatVersionString(cast(ApiVersionT, minRequiredVersion.values()))
if formattedMinRequiredVersion not in getExistingVersions(verFilename):
yield f"Minimum required version error: {formattedMinRequiredVersion} doesn't exist"
- elif (
- submission["channel"] == "stable"
- and formattedMinRequiredVersion not in getExistingStableVersions(verFilename)
+ elif submission["channel"] == "stable" and formattedMinRequiredVersion not in getExistingStableVersions(
+ verFilename,
):
- yield f"Minimum required version error: {formattedMinRequiredVersion} is not stable yet. " + \
- "Please submit add-on using the beta or dev channel."
+ yield (
+ f"Minimum required version error: {formattedMinRequiredVersion} is not stable yet. "
+ + "Please submit add-on using the beta or dev channel."
+ )
def checkVersions(
- manifest: AddonManifest,
- submissionFilePath: str,
- submission: JsonObjT
+ manifest: AddonManifest,
+ submissionFilePath: str,
+ submission: JsonObjT,
) -> ValidationErrorGenerator:
- """Check submitted json file name matches the *.nvda-addon manifest name field.
- """
- yield from checkSubmissionFilenameMatchesVersionNumber(
- submissionFilePath,
- submission
- )
+ """Check submitted json file name matches the *.nvda-addon manifest name field."""
+ yield from checkSubmissionFilenameMatchesVersionNumber(submissionFilePath, submission)
yield from checkManifestVersionMatchesVersionName(manifest, submission)
yield from checkParsedVersionNameMatchesVersionNumber(submission)
@@ -372,7 +342,7 @@ def validateSubmission(submissionFilePath: str, verFilename: str) -> ValidationE
yield f"Fatal error, unable to continue: {e}"
-def outputErrors(addonFileName: str, errors: List[str], errorFilePath: Optional[str] = None):
+def outputErrors(addonFileName: str, errors: list[str], errorFilePath: str | None = None):
if len(errors) > 0:
print("\r\n".join(errors))
if errorFilePath:
@@ -386,15 +356,15 @@ def main():
"--dry-run",
action="store_true",
default=False,
- help="Ensures the correct arguments are passed, doesn't run checks, exists with success."
+ help="Ensures the correct arguments are passed, doesn't run checks, exists with success.",
)
parser.add_argument(
dest="filePathGlob",
- help="The json (.json) files containing add-on metadata. e.g. addons/*/*.json."
+ help="The json (.json) files containing add-on metadata. e.g. addons/*/*.json.",
)
parser.add_argument(
dest="APIVersions",
- help="The JSON file containing valid NVDA API versions."
+ help="The JSON file containing valid NVDA API versions.",
)
parser.add_argument(
"--output",
@@ -404,7 +374,7 @@ def main():
)
args = parser.parse_args()
- addonFiles: List[str] = glob(args.filePathGlob)
+ addonFiles: list[str] = glob(args.filePathGlob)
verFilename: str = args.APIVersions
errorOutputFile: str = args.errorOutputFile
if errorOutputFile and os.path.exists(errorOutputFile):
@@ -425,5 +395,5 @@ def main():
print(f"No validation errors for {args.filePathGlob}")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5b548ff
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,118 @@
+[build-system]
+requires = ["setuptools~=72.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "addon-datastore-validation"
+dynamic = ["version"]
+description = "Add-on datastore validation"
+maintainers = [
+ {name = "NV Access", email = "info@nvaccess.org"},
+]
+requires-python = ">=3.13.0, <3.14"
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: GNU General Public License v3",
+ "Programming Language :: Python :: 3",
+ "Topic :: Accessibility",
+]
+readme = "readme.md"
+license = {file = "LICENSE"}
+dependencies = [
+ "configobj",
+ "jsonschema==4.25.1",
+]
+
+[project.urls]
+Homepage = "https://www.nvaccess.org/"
+Repository = "https://github.com/nvaccess/addon-datastore-validation.git"
+Issues = "https://github.com/nvaccess/addon-datastore-validation/issues"
+
+[tool.pyright]
+venvPath = ".venv"
+venv = "."
+pythonPlatform = "Windows"
+typeCheckingMode = "strict"
+
+include = [
+ "**/*.py",
+]
+
+exclude = [
+ ".git",
+ "__pycache__",
+ ".venv",
+]
+
+# While exclude tells pyright not to scan files in the first instance,
+# it will still analyse files included by other files.
+ignore = [
+ # We do not care about errors in our dependencies.
+ ".venv",
+]
+
+# General config
+analyzeUnannotatedFunctions = true
+deprecateTypingAliases = true
+
+# Stricter typing
+strictParameterNoneValue = true
+strictListInference = true
+strictDictionaryInference = true
+strictSetInference = true
+
+# ignore configobj
+reportMissingTypeStubs = false
+
+[tool.uv]
+default-groups = "all"
+python-preference = "only-system"
+environments = ["sys_platform == 'win32'"]
+required-version = ">=0.8"
+
+[tool.setuptools]
+package-dir = {"" = "_validate"}
+
+[tool.uv.sources]
+configobj = { git = "https://github.com/DiffSK/configobj", rev = "8be54629ee7c26acb5c865b74c76284e80f3aa31" }
+
+[dependency-groups]
+lint = [
+ "ruff==0.13.0",
+ "pre-commit==4.3.0",
+ "pyright==1.1.405",
+]
+
+unit-tests = [
+ # Creating XML unit test reports
+ "unittest-xml-reporting==3.2.0",
+]
+
+[tool.ruff]
+line-length = 110
+
+include = [
+ "*.py",
+]
+
+exclude = [
+ ".git",
+ "__pycache__",
+ "build",
+ "output",
+ ".venv",
+]
+
+[tool.ruff.format]
+indent-style = "tab"
+line-ending = "lf"
+
+[tool.ruff.lint.mccabe]
+max-complexity = 15
+
+[tool.ruff.lint]
+ignore = [
+ # indentation contains tabs
+ "W191",
+]
diff --git a/regenerateTranslations.bat b/regenerateTranslations.bat
new file mode 100644
index 0000000..eefd38b
--- /dev/null
+++ b/regenerateTranslations.bat
@@ -0,0 +1,10 @@
+@echo off
+REM Regenerate translations for files in dir
+set hereOrig=%~dp0
+set here=%hereOrig%
+if #%hereOrig:~-1%# == #\# set here=%hereOrig:~0,-1%
+set unitTestsPath=%here%\tests
+set testOutput=%here%\testOutput
+md %testOutput%
+
+call uv run --directory "%here%" python -m _validate.regenerateTranslations %*
diff --git a/regenerateTranslations.ps1 b/regenerateTranslations.ps1
deleted file mode 100644
index 9531ea7..0000000
--- a/regenerateTranslations.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-# Regenerate translations for files in dir
-$ErrorActionPreference = "Stop";
-& "$PSScriptRoot\venvUtils\venvCmd" "$PSScriptRoot\_validate\regenerateTranslations.py" $args
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 6d0cea5..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-# Dependencies
-flake8==3.9.2
-# flake8-tabs version 2.3.2 gives spurious errors:
-# "ET113 (flake8-tabs) use of alignment as indentation, but option continuation-style=hanging does not permit this"
-flake8-tabs==2.2.2
-
-# Requirements for validate
-configobj @ git+https://github.com/DiffSK/configobj@8be54629ee7c26acb5c865b74c76284e80f3aa31#egg=configobj
-jsonschema==4.23.0
-
diff --git a/runcreatejson.bat b/runcreatejson.bat
new file mode 100644
index 0000000..c6b2f37
--- /dev/null
+++ b/runcreatejson.bat
@@ -0,0 +1,10 @@
+@echo off
+REM create json from manifest
+set hereOrig=%~dp0
+set here=%hereOrig%
+if #%hereOrig:~-1%# == #\# set here=%hereOrig:~0,-1%
+set unitTestsPath=%here%\tests
+set testOutput=%here%\testOutput
+md %testOutput%
+
+call uv run --directory "%here%" python -m _validate.createJson %*
diff --git a/runcreatejson.ps1 b/runcreatejson.ps1
deleted file mode 100644
index 332d748..0000000
--- a/runcreatejson.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-# create json from manifest
-$ErrorActionPreference = "Stop";
-& "$PSScriptRoot\venvUtils\venvCmd" "$PSScriptRoot\_validate\createJson.py" $args
diff --git a/runlint.bat b/runlint.bat
new file mode 100644
index 0000000..d9f0646
--- /dev/null
+++ b/runlint.bat
@@ -0,0 +1,20 @@
+@echo off
+rem runlint [