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
http_cache.sqlite
test_output

View file

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

4
Pipfile.lock generated
View file

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

View file

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

View file

@ -3,7 +3,7 @@ ethernet = ["eno1"]
[[outputs]]
match = "*"
background = ["~/.local/share/backgrounds/concord/full.jpg", "fill"]
background_path = "~/.local/share/backgrounds/concord/full.jpg"
[[outputs]]
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"]
ethernet = ["enp2s0"]
temperature-path = "/sys/class/hwmon/hwmon5/temp1_input"
temperature_path = "/sys/class/hwmon/hwmon5/temp1_input"
[[outputs]]
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"]
#ethernet = ["enp2s0"]
temperature-path = "/sys/class/hwmon/hwmon4/temp1_input"
temperature_path = "/sys/class/hwmon/hwmon4/temp1_input"
[[outputs]]
match = "eDP-1"
background = ["~/.local/share/backgrounds/sway-dark-1920x1080.png", "fill"]
scale = "1.25"
background_path = "~/.local/share/backgrounds/sway-dark-1920x1080.png"
scale = 1.25
[[inputs]]
match = "2362:628:PIXA3854:00_093A:0274_Touchpad"
tap = "enabled"
tap = true

View file

@ -1,4 +1,4 @@
wireless = ["wlan0"]
ethernet = ["eth0"]
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
import argparse
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
import os
import shutil
import socket
@ -11,6 +13,8 @@ from functools import partial
from pathlib import Path
from typing import List
from attrs import define, field
import cattrs
import mako.lookup
import mako.template
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())
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():
parser = argparse.ArgumentParser(
description="Generates and installs dotfiles for this host.",
@ -85,53 +216,26 @@ def main():
raw_dir = args.dotfiles / "raw"
templates_dir = args.dotfiles / "templates"
include_dir = args.dotfiles / "include"
default_host_filename = args.dotfiles / "hosts" / "default.toml"
host_filename = args.dotfiles / "hosts" / "{}.toml".format(args.hostname)
with open(default_host_filename) as host_file:
host_config = toml.load(host_file)
host_toml = {
"name": args.hostname,
}
if host_filename.exists():
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
# 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
host_config = cattrs.structure(host_toml, HostConfig)
for output in host_config.outputs:
# Attempt to resolve device names for swaylock template
# (Workaround https://github.com/swaywm/swaylock/issues/114)
#
# This will only work if this is run on the target host
# and if sway is running, but that is usually the case...
if output["match"] != "*":
# and if sway or niri is running, but that is usually the case...
if output.match == "*":
continue
try:
if "SWAYSOCK" in os.environ:
get_outputs = subprocess.check_output(
@ -139,8 +243,8 @@ def main():
).decode("utf-8")
for line in get_outputs.splitlines():
# Line format: Output <device> '<match identifier>'
if line.startswith("Output") and output["match"] in line:
output["device"] = line.split()[1]
if line.startswith("Output") and output.match in line:
output.device = line.split()[1]
break
elif "NIRI_SOCKET" in os.environ:
@ -149,8 +253,8 @@ def main():
).decode("utf-8")
for line in get_outputs.splitlines():
# Line format: Output "<match identifier>" (<device>)
if line.startswith("Output") and output["match"] in line:
output["device"] = (
if line.startswith("Output") and output.match in line:
output.device = (
line.split()[-1].removeprefix("(").removesuffix(")")
)
break
@ -200,9 +304,7 @@ def main():
output = template.render(
host=host_config,
home=args.home,
get_base16=partial(
get_base16, host_config.get("base16-scheme", "default-dark")
),
get_base16=partial(get_base16, host_config.base16_scheme),
)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w+") as output_file:

View file

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

View file

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

View file

@ -58,17 +58,9 @@ input {
// focus-follows-mouse max-scroll-amount="0%"
}
% for output in host.get('outputs', []):
output "${output['match']}" {
% if 'mode' in output:
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
% for output in host.outputs:
output "${output.match}" {
${output.niri_lines}
}
% endfor

View file

@ -37,19 +37,15 @@ client.urgent $base08 $base08 $base00 $base08 $base08
client.placeholder $base01 $base01 $base05 $base01 $base01
client.background $base07
% for output in host['outputs']:
output ${repr(output['match'])} {
% for line in output['sway-lines']:
${line}
% endfor
% for output in host.outputs:
output ${repr(output.match)} {
${output.sway_lines}
}
% endfor
% for input in host['inputs']:
input ${repr(input['match'])} {
% for line in input['sway-lines']:
${line}
% endfor
% for input in host.inputs:
input ${repr(input.match)} {
${input.sway_lines}
}
%endfor
@ -77,7 +73,7 @@ exec dunst
exec udiskie
exec fcitx5
font pango:${host['system-mono-font']} 8
font pango:${host.system_mono_font} 8
focus_follows_mouse no
floating_modifier $mod
@ -86,14 +82,14 @@ bindsym $mod+Shift+c reload
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+q kill
bindsym $mod+Shift+p exec ${host['lock-cmd']}
bindsym $mod+Shift+p exec ${host.lock_cmd}
# Blank individual displays
bindsym $mod+o output - dpms off
# Unblank all displays
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+Shift+d exec wofi --show run
bindsym $mod+p exec wofi-pass
@ -185,7 +181,7 @@ bindsym XF86AudioPlay exec playerctl play-pause
bar {
tray_output none
status_command i3status
font pango:${host['system-mono-font']} 10
font pango:${host.system_mono_font} 10
${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
color=000000
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):
% if output['match'] == '*':
image=${output['background'][0]}
% else:
image=${output['device']}:${output['background'][0]}
% endif
% endif
% endfor
${host.swaylock_images}

View file

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

View file

@ -1,6 +1,6 @@
* {
/* `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;
}

View file

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

View file

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

View file

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