Skip to content

Commit ab27a3a

Browse files
authored
Merge pull request #23 from natekspencer/python
Add python to vbml monorepo
2 parents 149816f + 2f0b03b commit ab27a3a

47 files changed

Lines changed: 4637 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

python/.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv
11+
12+
# Unit test / coverage reports
13+
.coverage

python/.pre-commit-config.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/uv-pre-commit
3+
# uv version.
4+
rev: 0.10.7
5+
hooks:
6+
# Update the uv lockfile
7+
- id: uv-lock
8+
- repo: https://github.com/astral-sh/ruff-pre-commit
9+
# Ruff version.
10+
rev: v0.15.4
11+
hooks:
12+
# Run the linter.
13+
- id: ruff-check
14+
args: [--fix]
15+
# Run the formatter.
16+
- id: ruff-format

python/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.10

python/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Nathan Spencer
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

python/pyproject.toml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
[project]
2+
name = "pyvbml"
3+
description = "A python package to parse Vestaboard markup language (VBML) to Vestaboard character arrays"
4+
authors = [{ name = "Nathan Spencer", email = "natekspencer@gmail.com" }]
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
license = "MIT"
8+
dependencies = [
9+
"backports-strenum>=1.3.1; python_version < '3.11'",
10+
]
11+
dynamic = ["version"]
12+
13+
[dependency-groups]
14+
dev = [
15+
"mypy>=1.19.1",
16+
"pytest>=9.0.2",
17+
"pytest-cov>=7.0.0",
18+
"ruff>=0.15.4",
19+
]
20+
21+
[build-system]
22+
requires = ["hatchling", "uv-dynamic-versioning"]
23+
build-backend = "hatchling.build"
24+
25+
[tool.hatch.build.hooks.version]
26+
path = "pyvbml/__init__.py"
27+
pattern = true
28+
29+
[tool.hatch.build.targets.sdist]
30+
include = ["pyvbml"]
31+
32+
[tool.hatch.version]
33+
source = "uv-dynamic-versioning"
34+
35+
[tool.ruff.lint]
36+
select = ["D"]
37+
ignore = ["D203", "D213"]
38+
39+
[tool.ruff.lint.isort]
40+
force-sort-within-sections = true
41+
forced-separate = ["tests"]
42+
combine-as-imports = true
43+
split-on-trailing-comma = false
44+
45+
[tool.uv]
46+
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true }}]
47+
48+
[tool.uv-dynamic-versioning]
49+
pattern = "default-unprefixed"

python/pyvbml/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Python VBML module."""
2+
3+
from __future__ import annotations
4+
5+
from . import vbml
6+
from .types import Align, Justify
7+
8+
__all__ = ["vbml", "Align", "Justify"]
9+
10+
__version__ = "0.0.0.post126.dev0+25f7025"

python/pyvbml/calendar.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Calendar.
2+
3+
Port of Vestaboard/vbml/src/calendar.ts
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import math
9+
from calendar import monthrange
10+
from datetime import date
11+
12+
from .character_codes import CharacterCode
13+
14+
15+
def _char_code_for_digit(digit: str) -> int:
16+
"""Char code for digit."""
17+
return 36 if digit == "0" else int(digit) + 26
18+
19+
20+
def _char_code_for_day(day: str) -> int:
21+
"""Char code for day."""
22+
return {
23+
"Sun": 19,
24+
"Mon": 13,
25+
"Tue": 20,
26+
"Wed": 23,
27+
"Thu": 20,
28+
"Fri": 6,
29+
"Sat": 19,
30+
}.get(day, 0)
31+
32+
33+
def make_calendar(
34+
month: int,
35+
year: int,
36+
*,
37+
default_day_color: CharacterCode | int | None = None,
38+
highlighted_days: dict[int | str, CharacterCode | int] | None = None,
39+
hide_day_of_week: bool = False,
40+
hide_dates: bool = False,
41+
hide_month_year: bool = False,
42+
) -> list[list[int]]:
43+
"""Make calendar."""
44+
num_days = monthrange(year, month)[1]
45+
46+
first_day_of_month = date(year, month, 1).strftime("%a") # e.g. "Mon"
47+
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
48+
offset = days_of_week.index(first_day_of_month)
49+
50+
cal_color = CharacterCode.YELLOW if default_day_color is None else default_day_color
51+
52+
# Row day ranges (as strings, matching the TS template literals)
53+
first_row_days = [str(1), str(7 - offset)]
54+
second_row_days = [str(7 - offset + 1), str(7 - offset + 7)]
55+
third_row_days = [str(7 - offset + 8), str(7 - offset + 14)]
56+
fourth_row_days = [str(7 - offset + 15), str(7 - offset + 21)]
57+
fifth_start = 7 - offset + 22
58+
fifth_end = min(7 - offset + num_days, num_days)
59+
fifth_row_days = (
60+
[str(fifth_start), str(fifth_end)] if fifth_start <= num_days else None
61+
)
62+
num_days_last_row = fifth_end - (7 - offset + 22) + 1
63+
64+
def dc(digit: str) -> int:
65+
"""Digit → character code, or 0 when dates are hidden."""
66+
return 0 if hide_dates else _char_code_for_digit(digit)
67+
68+
if first_row_days[0] == first_row_days[1]:
69+
first_row = (
70+
[0, 0, 0, dc(first_row_days[0]), 0]
71+
+ [0] * offset
72+
+ [cal_color] * (7 - offset)
73+
+ [0] * (22 - 12)
74+
)
75+
else:
76+
first_row = (
77+
[
78+
0,
79+
dc(first_row_days[0]),
80+
0 if hide_dates else 44,
81+
dc(first_row_days[1]),
82+
0,
83+
]
84+
+ [0] * offset
85+
+ [cal_color] * (7 - offset)
86+
+ [0] * (22 - 12)
87+
)
88+
89+
def _two_digit_row(row_days: list[str]) -> list[int]:
90+
start, end = list(row_days[0]), list(row_days[1])
91+
row: list[int] = []
92+
row += [dc(start[0]), dc(start[1])] if len(start) > 1 else [0, dc(start[0])]
93+
row += [0 if hide_dates else 44]
94+
row += [dc(end[0]), dc(end[1])] if len(end) > 1 else [dc(end[0]), 0]
95+
row += [cal_color] * 7
96+
row += [0] * (22 - 12)
97+
return row
98+
99+
second_row = _two_digit_row(second_row_days)
100+
third_row = _two_digit_row(third_row_days)
101+
102+
def _exact_two_digit_row(row_days: list[str]) -> list[int]:
103+
start, end = list(row_days[0]), list(row_days[1])
104+
row = [
105+
dc(start[0]),
106+
dc(start[1]),
107+
0 if hide_dates else 44,
108+
dc(end[0]),
109+
dc(end[1]),
110+
]
111+
row += [cal_color] * 7
112+
row += [0] * (22 - 12)
113+
return row
114+
115+
fourth_row = _exact_two_digit_row(fourth_row_days)
116+
117+
if not fifth_row_days:
118+
fifth_row = [0] * 22
119+
else:
120+
start, end = list(fifth_row_days[0]), list(fifth_row_days[1])
121+
fifth_row = [
122+
dc(start[0]),
123+
dc(start[1]),
124+
0 if hide_dates or start == end else 44,
125+
0 if start == end else dc(end[0]),
126+
0 if start == end else dc(end[1]),
127+
]
128+
fifth_row += [cal_color] * num_days_last_row
129+
fifth_row += [0] * (22 - (5 + num_days_last_row))
130+
131+
# Header: month/year + day-of-week labels
132+
if hide_month_year:
133+
month_year = [0, 0, 0, 0, 0]
134+
else:
135+
month_year = (
136+
[_char_code_for_digit(c) for c in str(month)]
137+
+ [59] # slash
138+
+ [_char_code_for_digit(c) for c in str(year)[2:4]]
139+
)
140+
141+
header_space = 5 - len(month_year)
142+
header_row = (
143+
month_year
144+
+ [0] * header_space
145+
+ (
146+
[0] * 7
147+
if hide_day_of_week
148+
else [_char_code_for_day(d) for d in days_of_week]
149+
)
150+
+ [0] * (22 - (7 + 5))
151+
)
152+
153+
calendar = [header_row, first_row, second_row, third_row, fourth_row, fifth_row]
154+
155+
# Overlay individual day colors
156+
for day_key, color in (highlighted_days or {}).items():
157+
if (day := int(day_key)) > num_days:
158+
continue # ignore days that don't exist in month
159+
todays_row = math.floor((day + offset - 1) / 7) + 1
160+
modulus = (day + offset - 1) % 7
161+
# Account for spillover off the board
162+
todays_col = (12 if modulus == 0 else 13) if todays_row > 5 else modulus + 5
163+
calendar[5 if todays_row > 5 else todays_row][todays_col] = color
164+
165+
return calendar

0 commit comments

Comments
 (0)