diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..a19f0c3 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,58 @@ +name: Pylint + +on: + push: + branches: [main] + pull_request: + branches: ["**"] + +permissions: + contents: read + +jobs: + pylint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pipenv' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pipenv + pipenv install + - name: Analysing the code with pylint + run: | + pipenv run pylint $(git ls-files '*.py') + mypy: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pipenv' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pipenv + pipenv install + - name: Checking types with mypy + run: | + pipenv run mypy $(git ls-files '*.py') \ No newline at end of file diff --git a/Pipfile b/Pipfile index 9be7898..84cea24 100644 --- a/Pipfile +++ b/Pipfile @@ -7,6 +7,7 @@ name = "pypi" jinja2 = "*" robotvibecoder = {file = ".", editable = true} mypy = "*" +pylint = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index e73d139..1ba75e1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d8a64046f8c4846aa559e9e15021ce51527a5d12c7030020b8bca897a6e2e040" + "sha256": "65a62f41824012d6f47a74ba34783901c313cd621c506d82fb8991e523dfc0fe" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,30 @@ ] }, "default": { + "astroid": { + "hashes": [ + "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550", + "sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248" + ], + "markers": "python_full_version >= '3.9.0'", + "version": "==3.3.9" + }, + "dill": { + "hashes": [ + "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", + "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" + ], + "markers": "python_version >= '3.8'", + "version": "==0.4.0" + }, + "isort": { + "hashes": [ + "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", + "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615" + ], + "markers": "python_full_version >= '3.9.0'", + "version": "==6.0.1" + }, "jinja2": { "hashes": [ "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", @@ -92,6 +116,14 @@ "markers": "python_version >= '3.9'", "version": "==3.0.2" }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, "mypy": { "hashes": [ "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", @@ -139,10 +171,35 @@ "markers": "python_version >= '3.5'", "version": "==1.0.0" }, + "platformdirs": { + "hashes": [ + "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", + "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351" + ], + "markers": "python_version >= '3.9'", + "version": "==4.3.7" + }, + "pylint": { + "hashes": [ + "sha256:8b7c2d3e86ae3f94fb27703d521dd0b9b6b378775991f504d7c3a6275aa0a6a6", + "sha256:b634a041aac33706d56a0d217e6587228c66427e20ec21a019bc4cdee48c040a" + ], + "index": "pypi", + "markers": "python_full_version >= '3.9.0'", + "version": "==3.3.6" + }, "robotvibecoder": { "editable": true, "file": "." }, + "tomlkit": { + "hashes": [ + "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", + "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79" + ], + "markers": "python_version >= '3.8'", + "version": "==0.13.2" + }, "typing-extensions": { "hashes": [ "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", @@ -153,4 +210,4 @@ } }, "develop": {} -} \ No newline at end of file +} diff --git a/src/robotvibecoder/config.py b/src/robotvibecoder/config.py index 9632d82..342b126 100644 --- a/src/robotvibecoder/config.py +++ b/src/robotvibecoder/config.py @@ -1,3 +1,10 @@ +"""RobotVibeCoder Config Module + +Houses the MechanismConfig class, a dataclass for describing mechanisms. + +Also contains utils for loading and generating configs. +""" + from dataclasses import dataclass, fields from enum import Enum import json @@ -5,13 +12,23 @@ class MechanismKind(str, Enum): - Arm = "Arm" - Elevator = "Elevator" - Flywheel = "Flywheel" + """ + An enum for different types of mechanisms: arms, elevators, or flywheels + + This is called Kind and not Type because type is a keyword in python. + """ + + ARM = "Arm" + ELEVATOR = "Elevator" + FLYWHEEL = "Flywheel" @dataclass class MechanismConfig: + """ + A dataclass to represent JSON configs. This dataclass is 1:1 with a config JSON file. + """ + package: str name: str kind: MechanismKind @@ -22,6 +39,14 @@ class MechanismConfig: def generate_config_from_data(data: dict) -> MechanismConfig: + """Given a data dict (e.g. raw JSON data), generate a MechanismConfig, printing errors and + exiting when config is malformed. + + :param data: The JSON data to convert + :type data: dict + :return: A MechanismConfig generated from the dict + :rtype: MechanismConfig + """ for key in data: if key not in [field.name for field in fields(MechanismConfig)]: print(f"Error: Config contained unexpected field `{key}`", file=sys.stdout) @@ -41,8 +66,17 @@ def generate_config_from_data(data: dict) -> MechanismConfig: def load_json_config(config_path: str) -> MechanismConfig: + """Given the path a JSON config file, parse the JSON and convert it to a MechanismConfig object. + + This will throw errors and exit the program if malformed JSON is written by the user. + + :param config_path: Path to the config file (e.g. './config.json') + :type config_path: str + :return: The generated MechanismConfig + :rtype: MechanismConfig + """ try: - with open(config_path, "r") as config_file: + with open(config_path, "r", encoding="utf-8") as config_file: data = json.load(config_file) except FileNotFoundError: print(f"Error: Specified config file {config_path} does not exist.") @@ -61,9 +95,10 @@ def validate_config(config: MechanismConfig) -> None: if config.lead_motor not in config.motors: print( - f"Error in config `{config.name}`: `lead_motor` must be one of the motors specified by `motors`" + f"Error in `{config.name}` config: `lead_motor` must be one of the motors listed in `motors`" # pylint: disable=line-too-long ) + print( - f" Found `{config.lead_motor}` but expected one of {', '.join(['`' + motor + '`' for motor in config.motors])}" + f" Found `{config.lead_motor}` but expected one of {', '.join(['`' + motor + '`' for motor in config.motors])}" # pylint: disable=line-too-long ) sys.exit(1) diff --git a/src/robotvibecoder/constants.py b/src/robotvibecoder/constants.py index 6933faf..b1a217b 100644 --- a/src/robotvibecoder/constants.py +++ b/src/robotvibecoder/constants.py @@ -1,10 +1,16 @@ +""" +Constants that will be reused/don't need to live in code, e.g. default config +& color escape codes +""" + +from dataclasses import dataclass from robotvibecoder.config import MechanismConfig, MechanismKind DEFAULT_CONFIG: MechanismConfig = MechanismConfig( "subsystems.example", "Example", - MechanismKind.Arm, + MechanismKind.ARM, "canivore", ["leftMotor", "rightMotor"], "leftMotor", @@ -12,7 +18,12 @@ ) +@dataclass class Colors: + """ + ANSI Escape Codes for text colors and effects + """ + fg_red = "\x1b[31m" fg_green = "\x1b[32m" fg_cyan = "\x1b[36m" diff --git a/src/robotvibecoder/main.py b/src/robotvibecoder/main.py index 58d87be..01fce69 100644 --- a/src/robotvibecoder/main.py +++ b/src/robotvibecoder/main.py @@ -1,18 +1,22 @@ +""" +Houses the main entrypoint for RobotVibeCoder CLI and argument parsing +""" + import argparse -import sys -from sys import argv from robotvibecoder import constants -from robotvibecoder.config import load_json_config -from robotvibecoder.templating import generate_env from robotvibecoder.subcommands.new import new from robotvibecoder.subcommands.generate import generate def main() -> None: + """ + Main entry point for the CLI. Parses args and invokes a subcommand. + """ + parser = argparse.ArgumentParser( prog="RobotVibeCoder", description="Automatically generates boilerplate FRC mechanisms", - epilog="For documentation or to open a ticket, visit https://github.com/team401/robotvibecoder", + epilog="For documentation or to open a ticket, visit https://github.com/team401/robotvibecoder", # pylint: disable=line-too-long ) parser.add_argument( "-f", diff --git a/src/robotvibecoder/subcommands/generate.py b/src/robotvibecoder/subcommands/generate.py index dd72ce9..74ee2a8 100644 --- a/src/robotvibecoder/subcommands/generate.py +++ b/src/robotvibecoder/subcommands/generate.py @@ -1,6 +1,12 @@ +""" +The 'generate' subcommand, for templating/generating a mechanism from a config +""" + from argparse import Namespace import json +import os import sys + from robotvibecoder import constants from robotvibecoder.config import ( MechanismConfig, @@ -10,10 +16,12 @@ validate_config, ) from robotvibecoder.templating import generate_env -import os def generate(args: Namespace) -> None: + """ + Given a config (either a path or via stdin), template and generate mechanism boilerplate files + """ if args.stdin: print("Reading config from stdin.") data = json.load(sys.stdin) @@ -22,7 +30,7 @@ def generate(args: Namespace) -> None: else: if args.config is None: print( - "Error: Config not specified: Either --stdin or --config [file] must be supplied to command." + "Error: Config not specified: Either --stdin or --config [file] must be supplied to command." # pylint: disable=line-too-long ) sys.exit(1) config_path = os.path.join(args.folder, args.config) @@ -33,7 +41,7 @@ def generate(args: Namespace) -> None: env = generate_env() - if config.kind == MechanismKind.Flywheel: + if config.kind == MechanismKind.FLYWHEEL: raise NotImplementedError("Flywheel Mechanisms are not implemented yet :(") template_to_output_map: dict[str, str] = { @@ -46,12 +54,12 @@ def generate(args: Namespace) -> None: if not args.stdin: print( - f"{constants.Colors.fg_red}{constants.Colors.bold}WARNING{constants.Colors.reset}: This will create/overwrite files at the following paths:" + f"{constants.Colors.fg_red}{constants.Colors.bold}WARNING{constants.Colors.reset}: This will create/overwrite files at the following paths:" # pylint: disable=line-too-long ) - for file_template in template_to_output_map: + for file_template, file_output in template_to_output_map.items(): output_path = os.path.join( args.folder, - template_to_output_map[file_template].format(name=config.name), + file_output.format(name=config.name), ) print(f" {output_path}") try: @@ -61,13 +69,12 @@ def generate(args: Namespace) -> None: sys.exit(0) print("Templating files:") - for file_template in template_to_output_map: - output_path = os.path.join( - args.folder, template_to_output_map[file_template].format(name=config.name) - ) + for file_template, file_output in template_to_output_map.items(): + output_path = os.path.join(args.folder, file_output.format(name=config.name)) if os.path.exists(output_path) and args.stdin: - # stdin mode skips the warning prompt at the start, so files would be destroyed, necessitating this check + # stdin mode skips the warning prompt at the start, so files would + # be destroyed, necessitating this check print( f"Error: File {output_path} already exists. Please move/delete it and retry" ) @@ -78,5 +85,5 @@ def generate(args: Namespace) -> None: template = env.get_template(template_path) output: str = template.render(config.__dict__) - with open(output_path, "w+") as outfile: + with open(output_path, "w+", encoding="utf-8") as outfile: outfile.write(output) diff --git a/src/robotvibecoder/subcommands/new.py b/src/robotvibecoder/subcommands/new.py index 025d7a0..236e2aa 100644 --- a/src/robotvibecoder/subcommands/new.py +++ b/src/robotvibecoder/subcommands/new.py @@ -12,13 +12,17 @@ def new_config_interactive() -> MechanismConfig: + """ + Prompt the user for each field of a config to generate one interactively + """ + print( "Interactively generating new config. Please enter each field and press [Enter]." ) print("Package: will come after frc.robot (e.g. `subsystems.scoring`)") package: str = input("> ") print( - "Name: should be capitalized and should not end in Mechanism or Subsystem, as this is automatically added" + "Name: should be capitalized and should not end in Mechanism or Subsystem, as this is automatically added" # pylint: disable=line-too-long ) name: str = input("> ") @@ -67,11 +71,14 @@ def new_config_interactive() -> MechanismConfig: def new(args: Namespace) -> None: + """ + Generate a new config file, either with placeholder values or interactively in the CLI + """ config_path = os.path.join(args.folder, args.outfile) print(f"[{constants.Colors.title_str}] Creating a new config file") print( - f" {constants.Colors.fg_red}{constants.Colors.bold}WARNING{constants.Colors.reset}: This will create/overwrite a file at `{constants.Colors.fg_cyan}{config_path}{constants.Colors.reset}`" + f" {constants.Colors.fg_red}{constants.Colors.bold}WARNING{constants.Colors.reset}: This will create/overwrite a file at `{constants.Colors.fg_cyan}{config_path}{constants.Colors.reset}`" # pylint: disable=line-too-long ) try: input(" Press Ctrl+C to cancel or [Enter] to continue") @@ -86,5 +93,5 @@ def new(args: Namespace) -> None: config = constants.DEFAULT_CONFIG print(f"[{constants.Colors.title_str}] Writing config file") - with open(config_path, "w+") as outfile: + with open(config_path, "w+", encoding="utf-8") as outfile: json.dump(config.__dict__, fp=outfile, indent=2) diff --git a/src/robotvibecoder/templating.py b/src/robotvibecoder/templating.py index fb92c3e..c1427af 100644 --- a/src/robotvibecoder/templating.py +++ b/src/robotvibecoder/templating.py @@ -1,43 +1,73 @@ +""" +Handles creation of a jinja2 environment and the definitions of custom filters. +""" + from jinja2 import Environment, PackageLoader, select_autoescape from robotvibecoder import constants def article(word: str) -> str: + """ + Given a word, return 'an' if the word starts with vowel and 'a' otherwise + """ return "an" if word[0].lower() in "aeiou" else "a" def plural(words: list[str]) -> str: + """ + Given a list of words, return 's' if more than 1 word is present, otherwise '' + """ return "s" if len(words) > 1 else "" def lowerfirst(word: str) -> str: + """ + Lowercase the first letter of the input and return it + """ return word[0].lower() + word[1:] def upperfirst(word: str) -> str: + """ + Uppercase the first letter of the input and return it + """ return word[0].upper() + word[1:] -canIdMap: dict[str, int] = {} -nextId = 1 +class GlobalTemplateState: # pylint: disable=too-few-public-methods + """ + Manages global state for templates: + + For example, CAN IDs should never be reused so this needs to be global + """ + + last_id = 0 # This value is initialized to zero because it is incremented before being used + can_id_map: dict[str, int] = {} + + @staticmethod + def new_id() -> int: + """ + Generate a new CAN ID and increment the last ID counter + """ + + GlobalTemplateState.last_id += 1 + return GlobalTemplateState.last_id def hash_can_id(device: str) -> str: """ Given a device name, generate a unique CAN Id that is tied to that device """ - global nextId - - if device not in canIdMap: - canIdMap[device] = nextId + if device not in GlobalTemplateState.can_id_map: + next_id = GlobalTemplateState.new_id() + GlobalTemplateState.can_id_map[device] = next_id print(f" {constants.Colors.fg_green}➜{constants.Colors.reset} ", end="") print( - f"Mapped device {constants.Colors.fg_cyan}{device}{constants.Colors.reset} to placeholder CAN ID {constants.Colors.fg_cyan}{nextId}{constants.Colors.reset}" + f"Mapped device {constants.Colors.fg_cyan}{device}{constants.Colors.reset} to placeholder CAN ID {constants.Colors.fg_cyan}{next_id}{constants.Colors.reset}" # pylint: disable=line-too-long ) - nextId += 1 - return str(canIdMap[device]) + return str(GlobalTemplateState.can_id_map[device]) def pos_dimension(kind: str) -> str: @@ -46,17 +76,17 @@ def pos_dimension(kind: str) -> str: """ if kind == "Arm": return "Angle" - elif kind == "Elevator": + if kind == "Elevator": return "Distance" - else: - print( - f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to pos_dimension." - ) - print( - "This is a robotvibecoder issue, NOT a user error. Please report this on github!" - ) - raise ValueError(f"Invalid kind {kind} passed to pos_dimension") + # Flywheels won't have a position + print( + f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to pos_dimension." # pylint: disable=line-too-long + ) + print( + "This is a robotvibecoder issue, NOT a user error. Please report this on github!" + ) + raise ValueError(f"Invalid kind {kind} passed to pos_dimension") def vel_dimension(kind: str) -> str: @@ -65,8 +95,8 @@ def vel_dimension(kind: str) -> str: """ if kind == "Elevator": return "LinearVelocity" - else: - return "AngularVelocity" + + return "AngularVelocity" def pos_unit(kind: str) -> str: @@ -75,16 +105,16 @@ def pos_unit(kind: str) -> str: """ if kind == "Arm": return "Rotations" - elif kind == "Elevator": + if kind == "Elevator": return "Meters" - else: - print( - f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to pos_unit." - ) - print( - "This is a robotvibecoder issue, NOT user error. Please report this on github!" - ) - raise ValueError(f"Invalid kind {kind} passed to pos_unit") + + print( + f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to pos_unit." # pylint: disable=line-too-long + ) + print( + "This is a robotvibecoder issue, NOT user error. Please report this on github!" + ) + raise ValueError(f"Invalid kind {kind} passed to pos_unit") def vel_unit(kind: str) -> str: @@ -93,8 +123,8 @@ def vel_unit(kind: str) -> str: """ if kind == "Elevator": return "MetersPerSecond" - else: - return "RotationsPerSecond" + + return "RotationsPerSecond" def goal(kind: str) -> str: @@ -106,11 +136,19 @@ def goal(kind: str) -> str: if kind == "Arm": return "Angle" - elif kind == "Elevator": + if kind == "Elevator": return "Height" - else: + if kind == "Flywheel": return "Speed" + print( + f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to goal." # pylint: disable=line-too-long + ) + print( + "This is a robotvibecoder issue, NOT a user error. Please report this on github!" + ) + raise ValueError(f"Invalid kind {kind} passed to goal filter") + def goal_dimension(kind: str) -> str: """ @@ -121,13 +159,26 @@ def goal_dimension(kind: str) -> str: if kind == "Arm": return "Angle" - elif kind == "Elevator": + if kind == "Elevator": return "Distance" - else: + if kind == "Flywheel": return "AngularVelocity" + print( + f"{constants.Colors.fg_red}Error:{constants.Colors.reset} Invalid kind {kind} passed to goal_dimension." # pylint: disable=line-too-long + ) + print( + "This is a robotvibecoder issue, NOT a user error. Please report this on github!" + ) + raise ValueError(f"Invalid kind {kind} passed to goal_dimension") + def generate_env() -> Environment: + """ + Generate a Jinja2 Environment using a PackageLoader loading from + 'robotvibecoder', add all custom filters, and return it. + """ + env = Environment( loader=PackageLoader("robotvibecoder"), autoescape=select_autoescape() )