Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
@@ -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')
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ name = "pypi"
jinja2 = "*"
robotvibecoder = {file = ".", editable = true}
mypy = "*"
pylint = "*"

[dev-packages]

Expand Down
61 changes: 59 additions & 2 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 41 additions & 6 deletions src/robotvibecoder/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
"""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
import sys


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
Expand All @@ -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)
Expand All @@ -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.")
Expand All @@ -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)
13 changes: 12 additions & 1 deletion src/robotvibecoder/constants.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
"""
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",
"exampleEncoder",
)


@dataclass
class Colors:
"""
ANSI Escape Codes for text colors and effects
"""

fg_red = "\x1b[31m"
fg_green = "\x1b[32m"
fg_cyan = "\x1b[36m"
Expand Down
14 changes: 9 additions & 5 deletions src/robotvibecoder/main.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
31 changes: 19 additions & 12 deletions src/robotvibecoder/subcommands/generate.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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] = {
Expand All @@ -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:
Expand All @@ -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"
)
Expand All @@ -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)
Loading