diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad54e75..8fceccc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,17 +17,22 @@ jobs: python-version: 3.12 cache: pip cache-dependency-path: pyproject.toml - - name: Install dependencies + - name: Set up nodejs 20.13 + uses: actions/setup-node@v4 + with: + node-version: 20.13 + cache: npm + cache-dependency-path: package-lock.json + - name: Install python dependencies run: pip install -r requirements.txt + - name: Install nodejs dependencies + run: npm install - name: Check formatting run: ruff format --check server - name: Lint run: ruff check server - name: Check types - run: mypy server - env: - SECRET_KEY: "test" - BASE_URL: "https://example.com" + run: npx pyright - name: Run tests run: python manage.py test env: diff --git a/.gitignore b/.gitignore index 448b4f3..3cd6fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,133 @@ staticfiles/ # macOS stuff: .DS_Store +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/.tool-versions b/.tool-versions index 69e5cf7..2dcee5a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -python 3.12.2 +python 3.12.3 +nodejs 20.13.1 diff --git a/README.md b/README.md index 233ba26..a98faac 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ So far, I'm liking both. For projects like this one, HTMX is a keeper. `htpy` ha For code cleanliness, we also use: - [Ruff](https://github.com/astral-sh/ruff) for linting -- [mypy](https://mypy-lang.org/) for type checking +- [pyright](https://github.com/microsoft/pyright) for type checking ### Getting a local dev instance running diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..38ca794 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "voterbowl", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "voterbowl", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "pyright": "^1.1.363" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pyright": { + "version": "1.1.363", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.363.tgz", + "integrity": "sha512-ktR1N9esTerTkyiHtNa/eDdhSp2PwU/+TbyqplQQdGNvBPCDqL+qDCTz3W6xFfJ0b7xiDewPM+oedBvmbiZJPA==", + "dev": true, + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..feb5d3e --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "voterbowl", + "version": "0.1.0", + "description": "Encouraging voters to check early and check often", + "author": "Dave Peck ", + "license": "MIT", + "devDependencies": { + "pyright": "^1.1.363" + } +} diff --git a/pyproject.toml b/pyproject.toml index 1bcb2c8..8acac52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] -[tool.mypy] -exclude = [".venv", "migrations"] -ignore_missing_imports = true -disallow_untyped_defs = false -show_error_codes = true -plugins = ["mypy_django_plugin.main"] +[tool.pyright] +include = ["server/**/*.py"] +exclude = ["**/migrations/**"] +typeCheckingMode = "basic" +useLibraryCodeForTypes = true [tool.django-stubs] django_settings_module = "server.settings" diff --git a/requirements.txt b/requirements.txt index 649bb5e..b1b3b01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ gunicorn>=21,<22 htpy>=24.4.0 httpx>=0.20.0 markdown>=3.6.0 -mypy>=1.9.0 pillow>=10.2.0 psycopg[binary]>=3,<4 pydantic>=2.7.0 diff --git a/scripts/test.sh b/scripts/test.sh index 528d652..d39f150 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -13,9 +13,9 @@ ruff format --check server printf "${BLUE}Running ruff...${NC}\n" ruff check server -# Run the Python type checker (mypy). -printf "${BLUE}Running mypy...${NC}\n" -mypy server +# Run the Python type checker (pyright). +printf "${BLUE}Running pyright...${NC}\n" +npx pyright # Run Python tests printf "${BLUE}Running tests...${NC}\n" diff --git a/server/utils/agcod.py b/server/utils/agcod.py index f19a173..0c0b8be 100644 --- a/server/utils/agcod.py +++ b/server/utils/agcod.py @@ -189,10 +189,8 @@ def post_json_rpc( return self.post_json(url, data, headers=headers) -# I would use `type StatusCode = ...` except mypy still has an open issue -# for supporting PEP 695. Ugh; all the other type checkers support it! -StatusCode: t.TypeAlias = t.Literal["SUCCESS", "FAILURE", "RESEND"] -CardStatus: t.TypeAlias = t.Literal["Fulfilled", "RefundedToPurchaser", "Expired"] +type StatusCode = t.Literal["SUCCESS", "FAILURE", "RESEND"] +type CardStatus = t.Literal["Fulfilled", "RefundedToPurchaser", "Expired"] class BaseCamelModel(p.BaseModel): diff --git a/server/utils/components.py b/server/utils/components.py index a6caaea..e47fb92 100644 --- a/server/utils/components.py +++ b/server/utils/components.py @@ -37,14 +37,8 @@ def css_vars(**vars: str) -> str: return " ".join(f"--{k.replace('_', '-')}: {v};" for k, v in vars.items()) -# FUTURE use PEP 695 syntax when mypy supports it -P = t.ParamSpec("P") -C = t.TypeVar("C") -R = t.TypeVar("R", h.Element, h.Node) - - @dataclass(frozen=True) -class with_children(t.Generic[C, P, R]): +class with_children[C, R: (h.Element, h.Node), **P]: """Wrap a function to make it look more like an htpy.Element.""" _f: t.Callable[t.Concatenate[C, P], R] diff --git a/server/vb/management/commands/enter_contest.py b/server/vb/management/commands/enter_contest.py index 147d649..205201c 100644 --- a/server/vb/management/commands/enter_contest.py +++ b/server/vb/management/commands/enter_contest.py @@ -1,6 +1,8 @@ +import typing as t + from django.core.management.base import BaseCommand -from server.vb.models import Contest, School +from server.vb.models import Contest, School, Student from server.vb.ops import enter_contest, send_validation_link_email @@ -27,9 +29,11 @@ def handle(self, contest_id, emails: list[str], **options): def _process_students(self, contest: Contest, school: School, email: str): """Enter one or more students into a contest, if not already entered.""" if email[0] == "@": - students = school.students.filter(email__endswith=email[1:]) + students = t.cast( + t.Iterable[Student], school.students.filter(email__endswith=email[1:]) + ) else: - students = school.students.filter(email=email) + students = t.cast(t.Iterable[Student], school.students.filter(email=email)) when = contest.start_at diff --git a/server/vb/management/commands/get_available_funds.py b/server/vb/management/commands/get_available_funds.py index 580287c..82fd341 100644 --- a/server/vb/management/commands/get_available_funds.py +++ b/server/vb/management/commands/get_available_funds.py @@ -1,3 +1,5 @@ +import typing as t + from django.core.management.base import BaseCommand from server.utils.agcod import AGCODClient @@ -10,7 +12,7 @@ class Command(BaseCommand): "Get available funds remaining for generating gift codes using the AGCOD API." ) - def handle(self, **options): + def handle(self, **options: t.Any): """Handle the command.""" client = AGCODClient.from_settings() try: diff --git a/server/vb/models.py b/server/vb/models.py index 81577f5..16be748 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -183,26 +183,26 @@ class ContestKind(models.TextChoices): """The various kinds of contests.""" # Every student wins a prize (gift card; charitable donation; etc.) - GIVEAWAY = "giveaway", "Giveaway" + GIVEAWAY = "giveaway", "Giveaway" # type: ignore # Every student rolls a dice; some students win a prize. - DICE_ROLL = "dice_roll", "Dice roll" + DICE_ROLL = "dice_roll", "Dice roll" # type: ignore # A single student wins a prize after the contest ends. - SINGLE_WINNER = "single_winner", "Single winner" + SINGLE_WINNER = "single_winner", "Single winner" # type: ignore # No prizes are awarded. - NO_PRIZE = "no_prize", "No prize" + NO_PRIZE = "no_prize", "No prize" # type: ignore class ContestWorkflow(models.TextChoices): """The various workflows for contests.""" # Issue an amazon gift card and email automatically - AMAZON = "amazon", "Amazon" + AMAZON = "amazon", "Amazon" # type: ignore # No automated workflow; manual intervention may be required - NONE = "none", "None" + NONE = "none", "None" # type: ignore class Contest(models.Model):