Use attrs schemas for host configs

This commit is contained in:
Adam Gausmann 2025-01-17 17:40:22 -06:00
parent 614bda2855
commit e5a973d8c5
21 changed files with 227 additions and 168 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ __pycache__
*.pyc *.pyc
http_cache.sqlite http_cache.sqlite
test_output

View file

@ -9,5 +9,7 @@ toml = "*"
pyyaml = "*" pyyaml = "*"
requests = "*" requests = "*"
requests-cache = "*" requests-cache = "*"
attrs = "*"
cattrs = "*"
[dev-packages] [dev-packages]

4
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "50f1560a571ae9cbd1a971a155ec3ad2ffcbf9c2c641e7cd71f59795d7b18dd7" "sha256": "3b96d3b70c9cd62e42cf05545b406ce7aba31fe6b71050e7f6f71dfe3afe7624"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -19,6 +19,7 @@
"sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff",
"sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"
], ],
"index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.3.0" "version": "==24.3.0"
}, },
@ -27,6 +28,7 @@
"sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0", "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0",
"sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85" "sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85"
], ],
"index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.1.2" "version": "==24.1.2"
}, },

View file

@ -1,29 +1,29 @@
wireless = ["wlp5s0"] wireless = ["wlp5s0"]
ethernet = ["enp6s0"] ethernet = ["enp6s0"]
# hwmon2 = k10temp, temp1 = Tctl # hwmon2 = k10temp, temp1 = Tctl
temperature-path = "/sys/class/hwmon/hwmon2/temp1_input" temperature_path = "/sys/class/hwmon/hwmon2/temp1_input"
[[outputs]] [[outputs]]
match = "*" match = "*"
background = ["~/.local/share/backgrounds/adventurer/full.jpg", "fill"] background_path = "~/.local/share/backgrounds/adventurer/full.jpg"
[[outputs]] [[outputs]]
match = "ASUSTek COMPUTER INC VG258 M1LMQS004947" match = "ASUSTek COMPUTER INC VG258 M1LMQS004947"
position = [0, 1080] position = [0, 1080]
mode = "1920x1080@165Hz" mode = "1920x1080@165Hz"
background = ["~/.local/share/backgrounds/adventurer/l.jpg", "fill"] background_path = "~/.local/share/backgrounds/adventurer/l.jpg"
[[outputs]] [[outputs]]
match = "BNQ BenQ GL2580 46J02196SL0" match = "BNQ BenQ GL2580 46J02196SL0"
position = [1920, 1080] position = [1920, 1080]
mode = "1920x1080@60Hz" mode = "1920x1080@60Hz"
background = ["~/.local/share/backgrounds/adventurer/r.jpg", "fill"] background_path = "~/.local/share/backgrounds/adventurer/r.jpg"
[[outputs]] [[outputs]]
match = "BNQ BenQ GL2580 46J02151SL0" match = "BNQ BenQ GL2580 46J02151SL0"
position = [960, 0] position = [960, 0]
mode = "1920x1080@60Hz" mode = "1920x1080@60Hz"
background = ["~/.local/share/backgrounds/adventurer/t.jpg", "fill"] background_path = "~/.local/share/backgrounds/adventurer/t.jpg"
[[outputs]] [[outputs]]
match = "Pioneer Electronic Corporation AV Receiver Unknown" match = "Pioneer Electronic Corporation AV Receiver Unknown"

View file

@ -3,7 +3,7 @@ ethernet = ["eno1"]
[[outputs]] [[outputs]]
match = "*" match = "*"
background = ["~/.local/share/backgrounds/concord/full.jpg", "fill"] background_path = "~/.local/share/backgrounds/concord/full.jpg"
[[outputs]] [[outputs]]
match = "Pioneer Electronic Corporation AV Receiver Unknown" match = "Pioneer Electronic Corporation AV Receiver Unknown"

View file

@ -1,19 +0,0 @@
is-virtual = false
base16-scheme = "seti"
wireless = []
ethernet = []
disks = ["/"]
system-font = "Fira Sans"
system-mono-font = "Fira Mono"
#temperature-path = ""
terminal = "alacritty"
lock-cmd = "swaylock -c 000000"
display-on-cmd = "wlopm --on '*'"
display-off-cmd = "wlopm --off '*'"
use-jump-host = false
inputs = []
[[outputs]]
match = "*"
background = ["~/.local/share/backgrounds/sway-dark-1920x1080.png", "fill"]

View file

@ -1,7 +1,7 @@
wireless = ["wlp4s0"] wireless = ["wlp4s0"]
ethernet = ["enp2s0"] ethernet = ["enp2s0"]
temperature-path = "/sys/class/hwmon/hwmon5/temp1_input" temperature_path = "/sys/class/hwmon/hwmon5/temp1_input"
[[outputs]] [[outputs]]
match = "*" match = "*"
background = ["~/.local/share/backgrounds/faithful/full.jpg", "fill"] background_path = "~/.local/share/backgrounds/faithful/full.jpg"

View file

@ -1,12 +1,12 @@
wireless = ["wlp170s0"] wireless = ["wlp170s0"]
#ethernet = ["enp2s0"] #ethernet = ["enp2s0"]
temperature-path = "/sys/class/hwmon/hwmon4/temp1_input" temperature_path = "/sys/class/hwmon/hwmon4/temp1_input"
[[outputs]] [[outputs]]
match = "eDP-1" match = "eDP-1"
background = ["~/.local/share/backgrounds/sway-dark-1920x1080.png", "fill"] background_path = "~/.local/share/backgrounds/sway-dark-1920x1080.png"
scale = "1.25" scale = 1.25
[[inputs]] [[inputs]]
match = "2362:628:PIXA3854:00_093A:0274_Touchpad" match = "2362:628:PIXA3854:00_093A:0274_Touchpad"
tap = "enabled" tap = true

View file

@ -1,4 +1,4 @@
wireless = ["wlan0"] wireless = ["wlan0"]
ethernet = ["eth0"] ethernet = ["eth0"]
disk = ["/"] disk = ["/"]
temperature-path = "/sys/class/hwmon/hwmon0/temp1_input" temperature_path = "/sys/class/hwmon/hwmon0/temp1_input"

View file

@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from enum import Enum
import os import os
import shutil import shutil
import socket import socket
@ -11,6 +13,8 @@ from functools import partial
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from attrs import define, field
import cattrs
import mako.lookup import mako.lookup
import mako.template import mako.template
import requests_cache import requests_cache
@ -48,6 +52,133 @@ def is_outdated(src: List[Path], dst: Path) -> bool:
return any(a_src.stat().st_mtime > dst_modified for a_src in src if a_src.exists()) return any(a_src.stat().st_mtime > dst_modified for a_src in src if a_src.exists())
class BackgroundMode(Enum):
Fill = "fill"
@property
def sway_value(self) -> str:
match self:
case self.Fill:
return "fill"
@property
def wpaperd_value(self) -> str:
match self:
case self.Fill:
return "center"
@define
class InputConfig:
match: str
tap: bool | None = None
@property
def sway_lines(self) -> str:
lines = []
if self.tap is not None:
lines.append(f" tap {'enabled' if self.tap else 'disabled'}")
return "\n".join(lines)
@define
class OutputConfig:
match: str
position: list | None = None
mode: str | None = None
scale: float | None = None
background_path: str | None = None
background_mode: BackgroundMode = BackgroundMode.Fill
device: str | None = None
@property
def sway_lines(self) -> str:
lines = []
if self.position:
lines.append(f" position {' '.join(map(str, self.position))}")
if self.mode:
lines.append(f" mode '{self.mode}'")
if self.background_path:
lines.append(
f" background '{self.background_path}' {self.background_mode.sway_value}"
)
return "\n".join(lines)
@property
def swaylock_image_line(self) -> str | None:
if (self.match != "*" and self.device is None) or self.background_path is None:
return None
if self.match == "*":
return f"image={self.background_path}"
return f"image={self.device}:{self.background_path}"
@property
def niri_lines(self) -> str:
lines = []
if self.position:
lines.append(f" position x={self.position[0]} y={self.position[1]}")
if self.mode:
lines.append(f" mode \"{self.mode.removesuffix('Hz')}\"")
if self.scale:
lines.append(f" scale {self.scale}")
return "\n".join(lines)
@property
def wpaperd_config_fragment(self) -> dict:
if self.background_path is None:
return {}
key = self.match
if key == "*":
key = "any"
return {
key: {
"path": self.background_path,
"mode": self.background_mode.wpaperd_value,
}
}
@define
class HostConfig:
name: str
is_virtual: bool = False
base16_scheme: str = "seti"
wireless: list[str] = field(factory=list)
ethernet: list[str] = field(factory=list)
disks: list[str] = field(factory=lambda: ["/"])
system_font: str = "Fira Sans"
system_mono_font: str = "Fira Mono"
temperature_path: str | None = None
terminal: str = "alacritty"
lock_cmd: str = "swaylock -c 000000"
display_on_cmd: str = "wlopm --on *"
display_off_cmd: str = "wlopm --off *"
use_jump_host: bool = False
inputs: list[InputConfig] = field(factory=list)
outputs: list[OutputConfig] = field(factory=list)
@property
def swaylock_images(self) -> str:
return "\n".join(
line for output in self.outputs if (line := output.swaylock_image_line)
)
@property
def wpaperd_config(self) -> str:
config = {}
for output in self.outputs:
config.update(output.wpaperd_config_fragment)
return toml.dumps(config)
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Generates and installs dotfiles for this host.", description="Generates and installs dotfiles for this host.",
@ -85,53 +216,26 @@ def main():
raw_dir = args.dotfiles / "raw" raw_dir = args.dotfiles / "raw"
templates_dir = args.dotfiles / "templates" templates_dir = args.dotfiles / "templates"
include_dir = args.dotfiles / "include" include_dir = args.dotfiles / "include"
default_host_filename = args.dotfiles / "hosts" / "default.toml"
host_filename = args.dotfiles / "hosts" / "{}.toml".format(args.hostname) host_filename = args.dotfiles / "hosts" / "{}.toml".format(args.hostname)
with open(default_host_filename) as host_file: host_toml = {
host_config = toml.load(host_file) "name": args.hostname,
}
if host_filename.exists(): if host_filename.exists():
with open(host_filename) as host_file: with open(host_filename) as host_file:
host_config.update(toml.load(host_file)) host_toml.update(toml.load(host_file))
host_config["name"] = args.hostname host_config = cattrs.structure(host_toml, HostConfig)
# Preprocess output configs for sway
for input in host_config.get("inputs", []):
# Generate config lines for sway template
lines = []
for key in input:
if key == "match":
continue
if isinstance(input[key], list):
val = " ".join(repr(elem) for elem in input[key])
else:
val = repr(input[key])
lines.append(f"{key} {val}")
input["sway-lines"] = lines
for output in host_config["outputs"]:
# Generate config lines for sway template
lines = []
for key in output:
if key == "match":
continue
if isinstance(output[key], list):
val = " ".join(repr(elem) for elem in output[key])
else:
val = repr(output[key])
lines.append(f"{key} {val}")
output["sway-lines"] = lines
for output in host_config.outputs:
# Attempt to resolve device names for swaylock template # Attempt to resolve device names for swaylock template
# (Workaround https://github.com/swaywm/swaylock/issues/114) # (Workaround https://github.com/swaywm/swaylock/issues/114)
# #
# This will only work if this is run on the target host # This will only work if this is run on the target host
# and if sway is running, but that is usually the case... # and if sway or niri is running, but that is usually the case...
if output["match"] != "*": if output.match == "*":
continue
try: try:
if "SWAYSOCK" in os.environ: if "SWAYSOCK" in os.environ:
get_outputs = subprocess.check_output( get_outputs = subprocess.check_output(
@ -139,8 +243,8 @@ def main():
).decode("utf-8") ).decode("utf-8")
for line in get_outputs.splitlines(): for line in get_outputs.splitlines():
# Line format: Output <device> '<match identifier>' # Line format: Output <device> '<match identifier>'
if line.startswith("Output") and output["match"] in line: if line.startswith("Output") and output.match in line:
output["device"] = line.split()[1] output.device = line.split()[1]
break break
elif "NIRI_SOCKET" in os.environ: elif "NIRI_SOCKET" in os.environ:
@ -149,8 +253,8 @@ def main():
).decode("utf-8") ).decode("utf-8")
for line in get_outputs.splitlines(): for line in get_outputs.splitlines():
# Line format: Output "<match identifier>" (<device>) # Line format: Output "<match identifier>" (<device>)
if line.startswith("Output") and output["match"] in line: if line.startswith("Output") and output.match in line:
output["device"] = ( output.device = (
line.split()[-1].removeprefix("(").removesuffix(")") line.split()[-1].removeprefix("(").removesuffix(")")
) )
break break
@ -200,9 +304,7 @@ def main():
output = template.render( output = template.render(
host=host_config, host=host_config,
home=args.home, home=args.home,
get_base16=partial( get_base16=partial(get_base16, host_config.base16_scheme),
get_base16, host_config.get("base16-scheme", "default-dark")
),
) )
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w+") as output_file: with open(output_path, "w+") as output_file:

View file

@ -1,9 +1,5 @@
[font] [font]
% if host['is-virtual']: size = ${10 if host.is_virtual else 12}
size = 10
% else:
size = 12
%endif
[font.bold] [font.bold]
family = "Fira Mono" family = "Fira Mono"

View file

@ -5,7 +5,7 @@ general {
${get_base16('i3status')} ${get_base16('i3status')}
% for iface in host['wireless']: % for iface in host.wireless:
wireless ${iface} { wireless ${iface} {
format_up = "${iface} %ip %essid %quality" format_up = "${iface} %ip %essid %quality"
format_down = "${iface} down" format_down = "${iface} down"
@ -13,7 +13,7 @@ wireless ${iface} {
order += "wireless ${iface}" order += "wireless ${iface}"
% endfor % endfor
% for iface in host['ethernet']: % for iface in host.ethernet:
ethernet ${iface} { ethernet ${iface} {
format_up = "${iface} %ip" format_up = "${iface} %ip"
format_down = "${iface} down" format_down = "${iface} down"
@ -38,7 +38,7 @@ battery all {
} }
order += "battery all" order += "battery all"
% for disk in host['disks']: % for disk in host.disks:
disk "${disk}" { disk "${disk}" {
format = "${disk} %avail" format = "${disk} %avail"
} }
@ -59,10 +59,10 @@ memory {
} }
order += "memory" order += "memory"
% if 'temperature-path' in host: % if host.temperature_path:
cpu_temperature 0 { cpu_temperature 0 {
format = "temp %degrees°C" format = "temp %degrees°C"
path = "${host['temperature-path']}" path = "${host.temperature_path}"
} }
order += "cpu_temperature 0" order += "cpu_temperature 0"
% endif % endif

View file

@ -58,17 +58,9 @@ input {
// focus-follows-mouse max-scroll-amount="0%" // focus-follows-mouse max-scroll-amount="0%"
} }
% for output in host.get('outputs', []): % for output in host.outputs:
output "${output['match']}" { output "${output.match}" {
% if 'mode' in output: ${output.niri_lines}
mode "${output['mode'].removesuffix('Hz')}"
% endif
% if 'scale' in output:
scale ${output['scale']}
% endif
% if 'position' in output:
position x=${output['position'][0]} y=${output['position'][1]}
% endif
} }
% endfor % endfor

View file

@ -37,19 +37,15 @@ client.urgent $base08 $base08 $base00 $base08 $base08
client.placeholder $base01 $base01 $base05 $base01 $base01 client.placeholder $base01 $base01 $base05 $base01 $base01
client.background $base07 client.background $base07
% for output in host['outputs']: % for output in host.outputs:
output ${repr(output['match'])} { output ${repr(output.match)} {
% for line in output['sway-lines']: ${output.sway_lines}
${line}
% endfor
} }
% endfor % endfor
% for input in host['inputs']: % for input in host.inputs:
input ${repr(input['match'])} { input ${repr(input.match)} {
% for line in input['sway-lines']: ${input.sway_lines}
${line}
% endfor
} }
%endfor %endfor
@ -77,7 +73,7 @@ exec dunst
exec udiskie exec udiskie
exec fcitx5 exec fcitx5
font pango:${host['system-mono-font']} 8 font pango:${host.system_mono_font} 8
focus_follows_mouse no focus_follows_mouse no
floating_modifier $mod floating_modifier $mod
@ -86,14 +82,14 @@ bindsym $mod+Shift+c reload
bindsym $mod+Shift+r restart bindsym $mod+Shift+r restart
bindsym $mod+Shift+e exec swaynag -t warning -m 'You pressed the exit shortcut. Do you really want to exit sway? This will end your Wayland session.' -b 'Yes, exit sway' 'swaymsg exit' bindsym $mod+Shift+e exec swaynag -t warning -m 'You pressed the exit shortcut. Do you really want to exit sway? This will end your Wayland session.' -b 'Yes, exit sway' 'swaymsg exit'
bindsym $mod+Shift+q kill bindsym $mod+Shift+q kill
bindsym $mod+Shift+p exec ${host['lock-cmd']} bindsym $mod+Shift+p exec ${host.lock_cmd}
# Blank individual displays # Blank individual displays
bindsym $mod+o output - dpms off bindsym $mod+o output - dpms off
# Unblank all displays # Unblank all displays
bindsym $mod+Shift+o output * dpms on bindsym $mod+Shift+o output * dpms on
bindsym $mod+Return exec ${host['terminal']} bindsym $mod+Return exec ${host.terminal}
bindsym $mod+d exec wofi --show drun bindsym $mod+d exec wofi --show drun
bindsym $mod+Shift+d exec wofi --show run bindsym $mod+Shift+d exec wofi --show run
bindsym $mod+p exec wofi-pass bindsym $mod+p exec wofi-pass
@ -185,7 +181,7 @@ bindsym XF86AudioPlay exec playerctl play-pause
bar { bar {
tray_output none tray_output none
status_command i3status status_command i3status
font pango:${host['system-mono-font']} 10 font pango:${host.system_mono_font} 10
${get_base16('i3', 'bar-colors')} ${get_base16('i3', 'bar-colors')}

View file

@ -1 +1 @@
timeout 10 'pgrep swaylock && ${host['display-off-cmd']}' resume 'pgrep swaylock && ${host['display-on-cmd']}' timeout 10 'pgrep swaylock && ${host.display_off_cmd}' resume 'pgrep swaylock && ${host.display_on_cmd}'

View file

@ -2,14 +2,7 @@ ignore-empty-password
indicator-idle-visible indicator-idle-visible
color=000000 color=000000
scaling=fill scaling=fill
font=${host['system-font']} font=${host.system_font}
% for output in host['outputs']:
% if 'background' in output and (output['match'] == '*' or 'device' in output): ${host.swaylock_images}
% if output['match'] == '*':
image=${output['background'][0]}
% else:
image=${output['device']}:${output['background'][0]}
% endif
% endif
% endfor

View file

@ -12,7 +12,7 @@
], ],
"modules-right": [ "modules-right": [
% for iface in host.get('wireless', []) + host.get('ethernet', []): % for iface in host.wireless + host.ethernet:
"network#${iface}", "network#${iface}",
% endfor % endfor
@ -21,7 +21,7 @@
"cpu", "cpu",
"memory", "memory",
% if 'temperature-path' in host: % if host.temperature_path:
"temperature", "temperature",
% endif % endif
@ -29,7 +29,7 @@
"clock#local" "clock#local"
], ],
% for iface in host.get('wireless', []): % for iface in host.wireless:
"network#${iface}": { "network#${iface}": {
"interface": "${iface}", "interface": "${iface}",
"format": "\uf1eb \uf058", "format": "\uf1eb \uf058",
@ -39,7 +39,7 @@
}, },
% endfor % endfor
% for iface in host.get('ethernet', []): % for iface in host.ethernet:
"network#${iface}": { "network#${iface}": {
"interface": "${iface}", "interface": "${iface}",
"format": "\uf6ff \uf058", "format": "\uf6ff \uf058",
@ -59,14 +59,14 @@
// TODO: make drawer that expands orthogonally, outside of the bar // TODO: make drawer that expands orthogonally, outside of the bar
"orientation": "inherit", "orientation": "inherit",
"modules": [ "modules": [
% for disk in host.get('disks', ['/']): % for disk in host.disks:
"disk#${disk}", "disk#${disk}",
% endfor % endfor
], ],
"drawer": {} "drawer": {}
}, },
% for disk in host.get('disks', ['/']): % for disk in host.disks:
"disk#${disk}": { "disk#${disk}": {
"path": "${disk}", "path": "${disk}",
"format": "${disk} {free}", "format": "${disk} {free}",
@ -83,10 +83,10 @@
"tooltip-format": "{used}GiB / {total}GiB" "tooltip-format": "{used}GiB / {total}GiB"
}, },
% if 'temperature-path' in host: % if host.temperature_path:
"temperature": { "temperature": {
"format": "{temperatureC}°C", "format": "{temperatureC}°C",
"hwmon-path": "${host['temperature-path']}" "hwmon-path": "${host.temperature_path}"
}, },
% endif % endif

View file

@ -1,6 +1,6 @@
* { * {
/* `otf-font-awesome` is required to be installed for icons */ /* `otf-font-awesome` is required to be installed for icons */
font-family: '${host["system-mono-font"]}', 'Font Awesome 6 Free'; font-family: '${host.system_mono_font}', 'Font Awesome 6 Free';
font-size: 13px; font-size: 13px;
} }

View file

@ -1,7 +1,7 @@
window { window {
background-color: --wofi-color1; background-color: --wofi-color1;
color: --wofi-color5; color: --wofi-color5;
font-family: '${host['system-mono-font']}'; font-family: '${host.system_mono_font}';
} }
#input { #input {

View file

@ -1,7 +1 @@
% for output in host.get('outputs', []): ${host.wpaperd_config}
% if 'background' in output:
["${'any' if output['match'] == '*' else output['match']}"]
path = "${output['background'][0]}"
mode = "${output['background'][1].replace('fill', 'center')}"
%endif
% endfor

View file

@ -3,7 +3,7 @@ AddKeysToAgent confirm
Host *.mst.edu Host *.mst.edu
User um-ad\ajgq56 User um-ad\ajgq56
% if host['use-jump-host']: % if host.use_jump_host:
Host *.flock.wg Host *.flock.wg
ProxyJump goose@reliant.gaussian.dev ProxyJump goose@reliant.gaussian.dev
% endif % endif