4
\$\begingroup\$

So I am using i3 an Linux window manager to manage my windows. In addition this is run on a laptop that is frequently mounted to several output-displays. This is handled by the following lines in my i3 config file

set $firstMonitor DP-2-1
set $secondMonitor DP-1-2
set $laptop eDP-1
workspace 1 output $firstMonitor $laptop
workspace 2 output $firstMonitor $laptop
workspace 3 output $firstMonitor $laptop
workspace 4 output $firstMonitor $laptop
workspace 5 output $secondMonitor $laptop
workspace 6 output $secondMonitor $laptop
workspace 7 output $secondMonitor $laptop
workspace 8 output $secondMonitor $laptop
workspace 9 output $secondMonitor $laptop
workspace 10 output $laptop $laptop

For convience we can define variables in the config file using set and prefix them with $. The lines above tells my window manager I would like workspace 1 to 4 on $firstMonitor if it exists. Otherwise fall back to $laptop.

I am using this for various scripts and therefore need to extract this information from my config file, and store it somewhere. For instance it could look like this

[
 {
 'DP-2-1': ['1', '2', '3', '4'],
 'DP-1-2': ['5', '6', '7', '8', '9'],
 'eDP-1': ['10']
 },
 {
 'eDP-1': ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'
 }
]

parsed. To do so I wrote the following Python code to extract which output each workspace is assigned to. Note that the i3 file is picky about whitespaces, meaning the line set$firstMonitor DP-2-1 will not compile.

from pathlib import Path
import collections
CONFIG = Path.home() / ".config" / "i3" / "config"
def read_config(config=CONFIG):
 with open(config, "r") as f:
 return f.readlines()
def read_workspace_outputs(lines=read_config()):
 """Reads an i3 config, returns which output each workspaces is assigned to
 Example:
 set $firstMonitor DP-2-1
 set $secondMonitor DP-1-2
 set $laptop eDP-1
 set $browser 1
 workspace $browser output $firstMonitor $laptop
 workspace 2 output $firstMonitor $laptop
 workspace 3 output $firstMonitor $laptop
 workspace 4 output $firstMonitor $laptop
 workspace 5 output $secondMonitor $laptop
 workspace 6 output $secondMonitor $laptop
 workspace 7 output $secondMonitor $laptop
 workspace 8 output $secondMonitor $laptop
 workspace 9 output $secondMonitor $laptop
 workspace 10 output $laptop $laptop
 Will return
 [
 {
 'DP-2-1': ['1', '2', '3', '4'],
 'DP-1-2': ['5', '6', '7', '8', '9'],
 'eDP-1': ['10']
 },
 {
 'eDP-1': ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'
 }
 ]
 >>> read_workspace_outputs(lines=['workspace 1 output eDP-1'])
 [defaultdict(<class 'list'>, {'eDP-1': ['1']})]
 >>> read_workspace_outputs(lines=['set $laptop eDP-1','workspace 1 output eDP-1'])
 [defaultdict(<class 'list'>, {'eDP-1': ['1']})]
 >>> read_workspace_outputs(lines=['set $browser 1','workspace $browser output eDP-1'])
 [defaultdict(<class 'list'>, {'eDP-1': ['1']})]
 >>> read_workspace_outputs(lines=[
 ... "set $firstMonitor DP-2-1",
 ... "set $secondMonitor DP-1-2",
 ... "set $laptop eDP-1",
 ... "",
 ... "workspace 1 output $firstMonitor $laptop",
 ... "workspace 3 output $secondMonitor $laptop",
 ... "workspace 5 output $laptop $laptop",
 ... ])
 [defaultdict(<class 'list'>, {'DP-2-1': ['1'], 'DP-1-2': ['3'], 'eDP-1': ['5']}), defaultdict(<class 'list'>, {'eDP-1': ['1', '3', '5']})]
 """
 # Extract workspaces and variables [set $name command] from file
 def get_workspaces_and_variables(lines):
 workspaces = []
 variables_2_commands = dict()
 for line in lines:
 if line.startswith("workspace"):
 workspaces.append(line.strip())
 elif line.startswith("set"):
 _, variable, *command = line.split()
 variables_2_commands[variable] = " ".join(command)
 return workspaces, variables_2_commands
 # Convert back $name to command for outputs and workspaces
 def workspaces_without_variables(workspaces, variables):
 workspace_outputs = []
 for workspace in workspaces:
 workspace_str, output_str = workspace.split("output")
 workspace, variable = workspace_str.split()
 workspace_number = (
 variables[variable] if variable.startswith("$") else variable
 )
 outputs = [
 variables[output] if output.startswith("$") else output
 for output in output_str.split()
 ]
 workspace_outputs.append([workspace_number, outputs])
 return workspace_outputs
 # Currently things are stored as workspaces = [[output1, output 2], ...]
 # This flips the order and stores it as a dict with outputs as keys and values workspaces
 def workspaces_2_outputs(workspaces):
 output_workspaces = [
 collections.defaultdict(list)
 for _ in range(len(max((x[1] for x in workspaces), key=len)))
 ]
 for (workspace_number, outputs) in workspaces:
 for j, output in enumerate(outputs):
 output_workspaces[j][output].append(workspace_number)
 return output_workspaces
 workspaces_w_variables, variables = get_workspaces_and_variables(lines)
 variable_free_workspaces = workspaces_without_variables(
 workspaces_w_variables, variables
 )
 return workspaces_2_outputs(variable_free_workspaces)
if __name__ == "__main__":
 import doctest
 import yaml
 from yaml.representer import Representer
 doctest.testmod()
 OUTPUT_WORKSPACE_CONFIG = (
 Path.home() / ".config" / "i3" / "oisov-scripts" / "i3-output-workspace-2.yaml"
 )
 yaml.add_representer(collections.defaultdict, Representer.represent_dict)
 with open(OUTPUT_WORKSPACE_CONFIG, "w") as file:
 yaml.dump(read_workspace_outputs(), file)

The output.yaml file looks like this

- DP-1-2:
 - '5'
 - '6'
 - '7'
 - '8'
 - '9'
 DP-2-1:
 - '1'
 - '2'
 - '3'
 - '4'
 eDP-1:
 - '10'
- eDP-1:
 - '1'
 - '2'
 - '3'
 - '4'
 - '5'
 - '6'
 - '7'
 - '8'
 - '9'
 - '10'

I will add some typing hints in the future, but for now I was wondering if this is the best approach to extract this information. The code feels a bit clunky, even if it does that I want it to.

For testing one can use the first code block in this example. A longer i3 example file can for instance be https://github.com/sainathadapa/i3-wm-config/blob/master/i3-default-config-backup, due note that this does not set the workspaces to specific outputs.

H3AR7B3A7
2392 silver badges12 bronze badges
asked Oct 29, 2021 at 14:52
\$\endgroup\$

2 Answers 2

3
\$\begingroup\$

As far as coding in Python, I think this is nicely done, with good style, comments, and testing included. I agree with you it's a bit clunky, and I have some other minor comments too.

What's clunky

I have some ideas why it might feel a bit clunky.

get_workspaces_and_variables could return more useful objects. The workspaces are raw lines, not yet parsed. The variables is already good, it's a dictionary ready to use to substitute symbols with actual values. As I try to build a mental model of the program in my head, I feel a heavy burden here, having to remember about two kinds of objects, where one of them needs more work. I would prefer a parse_workspaces function that returns workspaces in an intuitive format.

workspaces_without_variables takes raw workspace lines and variable mappings, and returns workspaces in an intuitive format, with all variables resolved. And that's fine, it just looks kind of a lot of code. It would be good to extract to a function the duplicated variables[name] if name.startswith("$") else name. Also, I think the parsing could be done a bit simpler. In a code comment, an example raw line would be helpful too.

Finally, workspaces_2_outputs creates the final desired mapping, I just find this line a little bit cryptic:

for _ in range(len(max((x[1] for x in workspaces), key=len)))

That is, it finds the max entry by length, and then takes the length as the limit of the range. I would find it more natural to take the lengths, and then find the max of them. And instead of numeric indexing, I would use descriptive names. Like this:

for _ in range(max(len(outputs) for _, outputs in workspaces))]

Putting the above comments together, consider this alternative implementation:

def parse_variable(line):
 # example line: "set $firstMonitor DP-2-1"
 _, name, value = line.split()
 return name, value
def parse_workspace(line, variables):
 def resolve(name):
 return variables[name] if name.startswith('$') else name
 # example line: "workspace 1 output $firstMonitor $laptop"
 _, workspace, _, *devices = line.split()
 return resolve(workspace), [resolve(device) for device in devices]
def outputs_to_workspaces(workspaces):
 mappings = [collections.defaultdict(list)
 for _ in range(max(len(outputs) for _, outputs in workspaces))]
 for workspace, outputs in workspaces:
 for index, output in enumerate(outputs):
 mappings[index][output].append(workspace)
 return mappings
def parse_workspaces():
 variables = {}
 workspaces = []
 for line in lines:
 if line.startswith('set'):
 name, value = parse_variable(line)
 variables[name] = value
 elif line.startswith('workspace'):
 workspaces.append(parse_workspace(line, variables))
 return workspaces
return outputs_to_workspaces(parse_workspaces())

This passes the original test cases, so I think the behavior is preserved, even though the parsing uses a bit simplified logic.

Note that for parse_workspaces to work like this it's important that all the variables needed to parse a workspace line have already been defined. I don't know the i3 format, and if this is a safe assumption or not. If not, then indeed a 2nd pass would be needed, similar to the way you did.

Notice that the scope of variables is much more limited than in the posted code. I think this helps reducing mental burden.

Shadowing names from outer scope

In the posted code, some names shadow names from outer scope, for example:

def get_workspaces_and_variables(lines):
 ^^^^^
def workspaces_without_variables(workspaces, variables):
 ^^^^^^^^^

I've experienced nasty bugs in the past due to this, I suggest to avoid such shadowing.

Usability

I would have liked to play around with the program, but with the input and output file paths hardcoded, this was not easy enough. It would be good to accept these as command line arguments, and perhaps use the current values as default.

answered Oct 29, 2021 at 18:14
\$\endgroup\$
2
\$\begingroup\$

You have doctests - good; keep that up.

Where this falls over, mostly, is that you have untyped data soup. Internal data representations should not look like JSON. Python (and in particular dataclass) makes it easy to spin up lightweight models so that you gain some assurance of correctness, particularly if you use mypy.

Assigning a parameter default of lines=read_config() is a bad idea. This default is going to be applied on module load. If the default file is huge module load will be slow, and if the default file changes after some time from module load has passed, the default will be stale. Having default filenames is fine, but I wouldn't have an entire default config like this.

Your startswith parsing can be simplified by the use of regular expressions.

Having a function read_workspace_outputs with a collection of nested functions on the inside, apart from doctests, makes unit testing impossible.

I don't understand much about the yaml module, and the yaml format is trivial enough that it's not difficult to just produce the markup directly. I don't have a strong opinion on this point, but the suggested code below shows direct markup generation.

Your output format is a little strange; how much control do you have over it? It looks like you're implying the outermost tree level to be the index within the workspace monitor list, but it would be less confusing if you also showed the numerical index itself. That said, the example code below abides by your existing output structure.

Suggested

import re
from dataclasses import dataclass, field
from pathlib import Path
from string import Template
from typing import Iterator, Iterable, Tuple, Dict, List, Collection
CONFIG = Path.home() / ".config" / "i3" / "config"
@dataclass(frozen=True)
class Monitor:
 name: str
 workspaces: Dict[int, 'Workspace'] = field(default_factory=dict)
@dataclass(frozen=True)
class Workspace:
 id: int
 monitors: List[Monitor] = field(default_factory=list)
WorkspaceDefinition = Tuple[
 int, # Workspace ID
 Tuple[str], # monitors
]
def read_config(config: Path = CONFIG) -> Iterator[WorkspaceDefinition]:
 variables: Dict[str, str] = {}
 var_pat = re.compile(
 r'^set'
 r' \$(?P<key>\S+)'
 r' (?P<value>.*)'
 r'\n$'
 )
 work_pat = re.compile(
 r'^workspace'
 r' (?P<id>\S+)'
 r' output'
 r' (?P<monitors>.+)'
 r'\n$'
 )
 with config.open() as f:
 for line in f:
 var = var_pat.match(line)
 if var:
 variables[var['key']] = var['value']
 continue
 filled = Template(line).substitute(variables)
 workspace = work_pat.match(filled)
 if workspace:
 yield int(workspace['id']), workspace['monitors'].split(' ')
def load_workspaces(work_defs: Iterable[WorkspaceDefinition]) -> Tuple[
 Tuple[Workspace],
 Tuple[Monitor],
]:
 monitors = {}
 workspaces = {}
 for work_id, monitor_names in work_defs:
 workspace = Workspace(id=work_id)
 workspaces[work_id] = workspace
 for monitor_name in monitor_names:
 monitor = monitors.get(monitor_name)
 if monitor is None:
 monitor = Monitor(name=monitor_name)
 monitors[monitor_name] = monitor
 monitor.workspaces[work_id] = workspace
 workspace.monitors.append(monitor)
 return tuple(workspaces.values()), tuple(monitors.values())
@dataclass(frozen=True)
class MonitorPosition:
 index: int
 workspaces_by_monitor: Dict[str, List[Workspace]]
 @classmethod
 def all(
 cls,
 workspaces: Collection[Workspace],
 monitors: Collection[Monitor],
 ) -> Iterator['MonitorPosition']:
 monitor_positions = max(
 len(workspace.monitors)
 for workspace in workspaces
 )
 for index in range(monitor_positions):
 yield cls(
 index,
 dict(cls.for_index(workspaces, monitors, index)),
 )
 @classmethod
 def for_index(
 cls,
 workspaces: Collection[Workspace],
 monitors: Collection[Monitor],
 index: int,
 ) -> Iterator[Tuple[str, List[Workspace]]]:
 for monitor in monitors:
 workspaces_used = [
 workspace for workspace in workspaces
 if len(workspace.monitors) > index
 and workspace.monitors[index] is monitor
 ]
 if workspaces_used:
 yield monitor.name, workspaces_used
def to_yaml(
 positions: Iterable[MonitorPosition],
 filename: Path,
) -> None:
 with filename.open('w') as yaml:
 for position in positions:
 group_prefix = '-'
 for monitor_name, workspaces in position.workspaces_by_monitor.items():
 yaml.write(f'{group_prefix:<2}{monitor_name}:\n')
 yaml.write('\n'.join(
 f" - '{workspace.id}'"
 for workspace in workspaces
 ))
 yaml.write('\n')
 group_prefix = ' '
def test() -> None:
 workspaces, monitors = load_workspaces(read_config(Path('config')))
 positions = MonitorPosition.all(workspaces, monitors)
 to_yaml(positions, Path('i3-output-workspace-2.yaml'))
if __name__ == '__main__':
 test()
answered Oct 29, 2021 at 19:06
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.