]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
New upstream version 1.14
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import fnmatch
30 import getopt
31 import hashlib
32 import os
33 import posix
34 import pwd
35 import re
36 import subprocess
37 import sys
38 import shutil
39 import time
40
41 from collections import OrderedDict
42 from distutils.version import LooseVersion as Version
43 from functools import reduce
44 from itertools import chain
45
46 if sys.version_info.major == 2:
47     import ConfigParser as configparser
48 else:
49     import configparser
50
51 __version__ = "1.7"
52
53 try:
54     input = raw_input
55 except NameError:
56     pass
57
58 virtual_profiles = [
59     # (name, description, callback)
60     ("off", "Disable all outputs", None),
61     ("common", "Clone all connected outputs at the largest common resolution", None),
62     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
63     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
64     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
65 ]
66
67 help_text = """
68 Usage: autorandr [options]
69
70 -h, --help              get this small help
71 -c, --change            automatically load the first detected profile
72 -d, --default <profile> make profile <profile> the default profile
73 -l, --load <profile>    load profile <profile>
74 -s, --save <profile>    save your current setup to profile <profile>
75 -r, --remove <profile>  remove profile <profile>
76 --batch                 run autorandr for all users with active X11 sessions
77 --current               only list current (active) configuration(s)
78 --config                dump your current xrandr setup
79 --debug                 enable verbose output
80 --detected              only list detected (available) configuration(s)
81 --dry-run               don't change anything, only print the xrandr commands
82 --fingerprint           fingerprint your current hardware setup
83 --force                 force (re)loading of a profile
84 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
85                         to skip both in detecting changes and applying a profile
86 --version               show version information and exit
87
88  If no suitable profile can be identified, the current configuration is kept.
89  To change this behaviour and switch to a fallback configuration, specify
90  --default <profile>.
91
92  autorandr supports a set of per-profile and global hooks. See the documentation
93  for details.
94
95  The following virtual configurations are available:
96 """.strip()
97
98
99 class AutorandrException(Exception):
100     def __init__(self, message, original_exception=None, report_bug=False):
101         self.message = message
102         self.report_bug = report_bug
103         if original_exception:
104             self.original_exception = original_exception
105             trace = sys.exc_info()[2]
106             while trace.tb_next:
107                 trace = trace.tb_next
108             self.line = trace.tb_lineno
109             self.file_name = trace.tb_frame.f_code.co_filename
110         else:
111             try:
112                 import inspect
113                 frame = inspect.currentframe().f_back
114                 self.line = frame.f_lineno
115                 self.file_name = frame.f_code.co_filename
116             except:
117                 self.line = None
118                 self.file_name = None
119             self.original_exception = None
120
121         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
122             self.file_name = None
123
124     def __str__(self):
125         retval = [self.message]
126         if self.line:
127             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
128         if self.original_exception:
129             retval.append(":\n  ")
130             retval.append(str(self.original_exception).replace("\n", "\n  "))
131         if self.report_bug:
132             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
133                           "\nhttps://github.com/phillipberndt/autorandr/issues"
134                           "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
135         return "".join(retval)
136
137
138 class XrandrOutput(object):
139     "Represents an XRandR output"
140
141     # This regular expression is used to parse an output in `xrandr --verbose'
142     XRANDR_OUTPUT_REGEXP = """(?x)
143         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
144         (?:                                                                             # Differentiate disconnected and connected
145             disconnected |                                                              # in first line
146             unknown\ connection |
147             (?P<connected>connected)
148         )
149         \s*
150         (?P<primary>primary\ )?                                                         # Might be primary screen
151         (?:\s*
152             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
153             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
154             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
155             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
156             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
157         )?                                                                              # .. but only if the screen is in use.
158         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
159         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
160         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
161         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
162         (?:\s*(?:                                                                       # Properties of the output
163             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
164             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
165             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
166             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
167         ))+
168         \s*
169         (?P<modes>(?:
170             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution:
171              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+                                   # Extract rate
172              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
173             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
174         )*)
175     """
176
177     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
178         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
179          h:\s+width\s+(?P<width>[0-9]+).+\s+
180          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
181     """
182
183     XRANDR_13_DEFAULTS = {
184         "transform": "1,0,0,0,1,0,0,0,1",
185         "panning": "0x0",
186     }
187
188     XRANDR_12_DEFAULTS = {
189         "reflect": "normal",
190         "rotate": "normal",
191         "gamma": "1.0:1.0:1.0",
192     }
193
194     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
195
196     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
197
198     def __repr__(self):
199         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
200
201     @property
202     def short_edid(self):
203         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
204
205     @property
206     def options_with_defaults(self):
207         "Return the options dictionary, augmented with the default values that weren't set"
208         if "off" in self.options:
209             return self.options
210         options = {}
211         if xrandr_version() >= Version("1.3"):
212             options.update(self.XRANDR_13_DEFAULTS)
213         if xrandr_version() >= Version("1.2"):
214             options.update(self.XRANDR_12_DEFAULTS)
215         options.update(self.options)
216         return {a: b for a, b in options.items() if a not in self.ignored_options}
217
218     @property
219     def filtered_options(self):
220         "Return a dictionary of options without ignored options"
221         return {a: b for a, b in self.options.items() if a not in self.ignored_options}
222
223     @property
224     def option_vector(self):
225         "Return the command line parameters for XRandR for this instance"
226         args = ["--output", self.output]
227         for option, arg in sorted(self.options_with_defaults.items()):
228             args.append("--%s" % option)
229             if arg:
230                 args.append(arg)
231         return args
232
233     @property
234     def option_string(self):
235         "Return the command line parameters in the configuration file format"
236         options = ["output %s" % self.output]
237         for option, arg in sorted(self.filtered_options.items()):
238             if arg:
239                 options.append("%s %s" % (option, arg))
240             else:
241                 options.append(option)
242         return "\n".join(options)
243
244     @property
245     def sort_key(self):
246         "Return a key to sort the outputs for xrandr invocation"
247         if not self.edid:
248             return -2
249         if "off" in self.options:
250             return -1
251         if "pos" in self.options:
252             x, y = map(float, self.options["pos"].split("x"))
253         else:
254             x, y = 0, 0
255         return x + 10000 * y
256
257     def __init__(self, output, edid, options):
258         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
259         self.output = output
260         self.edid = edid
261         self.options = options
262         self.ignored_options = []
263         self.remove_default_option_values()
264
265     def set_ignored_options(self, options):
266         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
267         self.ignored_options = list(options)
268
269     def remove_default_option_values(self):
270         "Remove values from the options dictionary that are superflous"
271         if "off" in self.options and len(self.options.keys()) > 1:
272             self.options = {"off": None}
273             return
274         for option, default_value in self.XRANDR_DEFAULTS.items():
275             if option in self.options and self.options[option] == default_value:
276                 del self.options[option]
277
278     @classmethod
279     def from_xrandr_output(cls, xrandr_output):
280         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
281
282         This method also returns a list of modes supported by the output.
283         """
284         try:
285             xrandr_output = xrandr_output.replace("\r\n", "\n")
286             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
287         except:
288             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
289                                      report_bug=True)
290         if not match_object:
291             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
292             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
293                                      report_bug=True)
294         remainder = xrandr_output[len(match_object.group(0)):]
295         if remainder:
296             raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
297                                      "regular expression, starting at byte %d with ..'%s'." %
298                                      (len(remainder), len(match_object.group(0)), remainder[:10]),
299                                      report_bug=True)
300
301         match = match_object.groupdict()
302
303         modes = []
304         if match["modes"]:
305             modes = []
306             for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
307                 if mode_match.group("name"):
308                     modes.append(mode_match.groupdict())
309             if not modes:
310                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
311
312         options = {}
313         if not match["connected"]:
314             edid = None
315         elif match["edid"]:
316             edid = "".join(match["edid"].strip().split())
317         else:
318             edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
319
320         if not match["width"]:
321             options["off"] = None
322         else:
323             if match["mode_name"]:
324                 options["mode"] = match["mode_name"]
325             elif match["mode_width"]:
326                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
327             else:
328                 if match["rotate"] not in ("left", "right"):
329                     options["mode"] = "%sx%s" % (match["width"], match["height"])
330                 else:
331                     options["mode"] = "%sx%s" % (match["height"], match["width"])
332             options["rotate"] = match["rotate"]
333             if match["primary"]:
334                 options["primary"] = None
335             if match["reflect"] == "X":
336                 options["reflect"] = "x"
337             elif match["reflect"] == "Y":
338                 options["reflect"] = "y"
339             elif match["reflect"] == "X and Y":
340                 options["reflect"] = "xy"
341             options["pos"] = "%sx%s" % (match["x"], match["y"])
342             if match["panning"]:
343                 panning = [match["panning"]]
344                 if match["tracking"]:
345                     panning += ["/", match["tracking"]]
346                     if match["border"]:
347                         panning += ["/", match["border"]]
348                 options["panning"] = "".join(panning)
349             if match["transform"]:
350                 transformation = ",".join(match["transform"].strip().split())
351                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
352                     options["transform"] = transformation
353                     if not match["mode_name"]:
354                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
355                         # I doubt that this special case is actually required.
356                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
357                               "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
358             if match["gamma"]:
359                 gamma = match["gamma"].strip()
360                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
361                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
362                 # so we approximate by 1e-10.
363                 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
364                 options["gamma"] = gamma
365             if match["rate"]:
366                 options["rate"] = match["rate"]
367
368         return XrandrOutput(match["output"], edid, options), modes
369
370     @classmethod
371     def from_config_file(cls, edid_map, configuration):
372         "Instanciate an XrandrOutput from the contents of a configuration file"
373         options = {}
374         for line in configuration.split("\n"):
375             if line:
376                 line = line.split(None, 1)
377                 if line and line[0].startswith("#"):
378                     continue
379                 options[line[0]] = line[1] if len(line) > 1 else None
380
381         edid = None
382
383         if options["output"] in edid_map:
384             edid = edid_map[options["output"]]
385         else:
386             # This fuzzy matching is for legacy autorandr that used sysfs output names
387             fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
388             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
389             if fuzzy_output in fuzzy_edid_map:
390                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
391             elif "off" not in options:
392                 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
393                                          "is not off in config file." % (options["output"], options["output"]))
394         output = options["output"]
395         del options["output"]
396
397         return XrandrOutput(output, edid, options)
398
399     def edid_equals(self, other):
400         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
401         if self.edid and other.edid:
402             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
403                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
404             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
405                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
406             if "*" in self.edid:
407                 return fnmatch.fnmatch(other.edid, self.edid)
408             elif "*" in other.edid:
409                 return fnmatch.fnmatch(self.edid, other.edid)
410         return self.edid == other.edid
411
412     def __ne__(self, other):
413         return not (self == other)
414
415     def __eq__(self, other):
416         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
417
418     def verbose_diff(self, other):
419         "Compare to another XrandrOutput and return a list of human readable differences"
420         diffs = []
421         if not self.edid_equals(other):
422             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
423         if self.output != other.output:
424             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
425         if "off" in self.options and "off" not in other.options:
426             diffs.append("The output is disabled currently, but active in the new configuration")
427         elif "off" in other.options and "off" not in self.options:
428             diffs.append("The output is currently enabled, but inactive in the new configuration")
429         else:
430             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
431                 if name not in other.options:
432                     diffs.append("Option --%s %sis not present in the new configuration" %
433                                  (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
434                 elif name not in self.options:
435                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
436                                  (name, other.options[name]))
437                 elif self.options[name] != other.options[name]:
438                     diffs.append("Option --%s %sis `%s' in the new configuration" %
439                                  (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
440         return diffs
441
442
443 def xrandr_version():
444     "Return the version of XRandR that this system uses"
445     if getattr(xrandr_version, "version", False) is False:
446         version_string = os.popen("xrandr -v").read()
447         try:
448             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
449             xrandr_version.version = Version(version)
450         except AttributeError:
451             xrandr_version.version = Version("1.3.0")
452
453     return xrandr_version.version
454
455
456 def debug_regexp(pattern, string):
457     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
458     try:
459         import regex
460         bounds = (0, len(string))
461         while bounds[0] != bounds[1]:
462             half = int((bounds[0] + bounds[1]) / 2)
463             if half == bounds[0]:
464                 break
465             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
466         partial_length = bounds[0]
467         return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
468                 (partial_length, string[max(0, partial_length - 20):partial_length],
469                  string[partial_length:partial_length + 10]))
470     except ImportError:
471         pass
472     return "Debug information would be available if the `regex' module was installed."
473
474
475 def parse_xrandr_output():
476     "Parse the output of `xrandr --verbose' into a list of outputs"
477     xrandr_output = os.popen("xrandr -q --verbose").read()
478     if not xrandr_output:
479         raise AutorandrException("Failed to run xrandr")
480
481     # We are not interested in screens
482     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
483
484     # Split at output boundaries and instanciate an XrandrOutput per output
485     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
486     if len(split_xrandr_output) < 2:
487         raise AutorandrException("No output boundaries found", report_bug=True)
488     outputs = OrderedDict()
489     modes = OrderedDict()
490     for i in range(1, len(split_xrandr_output), 2):
491         output_name = split_xrandr_output[i].split()[0]
492         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
493         outputs[output_name] = output
494         if output_modes:
495             modes[output_name] = output_modes
496
497     return outputs, modes
498
499
500 def load_profiles(profile_path):
501     "Load the stored profiles"
502
503     profiles = {}
504     for profile in os.listdir(profile_path):
505         config_name = os.path.join(profile_path, profile, "config")
506         setup_name = os.path.join(profile_path, profile, "setup")
507         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
508             continue
509
510         edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
511
512         config = {}
513         buffer = []
514         for line in chain(open(config_name).readlines(), ["output"]):
515             if line[:6] == "output" and buffer:
516                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
517                 buffer = [line]
518             else:
519                 buffer.append(line)
520
521         for output_name in list(config.keys()):
522             if config[output_name].edid is None:
523                 del config[output_name]
524
525         profiles[profile] = {
526             "config": config,
527             "path": os.path.join(profile_path, profile),
528             "config-mtime": os.stat(config_name).st_mtime,
529         }
530
531     return profiles
532
533
534 def get_symlinks(profile_path):
535     "Load all symlinks from a directory"
536
537     symlinks = {}
538     for link in os.listdir(profile_path):
539         file_name = os.path.join(profile_path, link)
540         if os.path.islink(file_name):
541             symlinks[link] = os.readlink(file_name)
542
543     return symlinks
544
545
546 def find_profiles(current_config, profiles):
547     "Find profiles matching the currently connected outputs"
548     detected_profiles = []
549     for profile_name, profile in profiles.items():
550         config = profile["config"]
551         matches = True
552         for name, output in config.items():
553             if not output.edid:
554                 continue
555             if name not in current_config or not output.edid_equals(current_config[name]):
556                 matches = False
557                 break
558         if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
559             continue
560         if matches:
561             detected_profiles.append(profile_name)
562     return detected_profiles
563
564
565 def profile_blocked(profile_path, meta_information=None):
566     """Check if a profile is blocked.
567
568     meta_information is expected to be an dictionary. It will be passed to the block scripts
569     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
570     """
571     return not exec_scripts(profile_path, "block", meta_information)
572
573
574 def output_configuration(configuration, config):
575     "Write a configuration file"
576     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
577     for output in outputs:
578         print(configuration[output].option_string, file=config)
579
580
581 def output_setup(configuration, setup):
582     "Write a setup (fingerprint) file"
583     outputs = sorted(configuration.keys())
584     for output in outputs:
585         if configuration[output].edid:
586             print(output, configuration[output].edid, file=setup)
587
588
589 def save_configuration(profile_path, configuration):
590     "Save a configuration into a profile"
591     if not os.path.isdir(profile_path):
592         os.makedirs(profile_path)
593     with open(os.path.join(profile_path, "config"), "w") as config:
594         output_configuration(configuration, config)
595     with open(os.path.join(profile_path, "setup"), "w") as setup:
596         output_setup(configuration, setup)
597
598
599 def update_mtime(filename):
600     "Update a file's mtime"
601     try:
602         os.utime(filename, None)
603         return True
604     except:
605         return False
606
607
608 def call_and_retry(*args, **kwargs):
609     """Wrapper around subprocess.call that retries failed calls.
610
611     This function calls subprocess.call and on non-zero exit states,
612     waits a second and then retries once. This mitigates #47,
613     a timing issue with some drivers.
614     """
615     if "dry_run" in kwargs:
616         dry_run = kwargs["dry_run"]
617         del kwargs["dry_run"]
618     else:
619         dry_run = False
620     kwargs_redirected = dict(kwargs)
621     if not dry_run:
622         if hasattr(subprocess, "DEVNULL"):
623             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
624         else:
625             kwargs_redirected["stdout"] = open(os.devnull, "w")
626         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
627     retval = subprocess.call(*args, **kwargs_redirected)
628     if retval != 0:
629         time.sleep(1)
630         retval = subprocess.call(*args, **kwargs)
631     return retval
632
633
634 def get_fb_dimensions(configuration):
635     width = 0
636     height = 0
637     for output in configuration.values():
638         if "off" in output.options or not output.edid:
639             continue
640         # This won't work with all modes -- but it's a best effort.
641         o_mode = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"]).group(0)
642         o_width, o_height = map(int, o_mode.split("x"))
643         if "transform" in output.options:
644             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
645             w = (g * o_width + h * o_height + i)
646             x = (a * o_width + b * o_height + c) / w
647             y = (d * o_width + e * o_height + f) / w
648             o_width, o_height = x, y
649         if "rotate" in output.options:
650             if output.options["rotate"] in ("left", "right"):
651                 o_width, o_height = o_height, o_width
652         if "pos" in output.options:
653             o_left, o_top = map(int, output.options["pos"].split("x"))
654             o_width += o_left
655             o_height += o_top
656         if "panning" in output.options:
657             match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
658             if match:
659                 detail = match.groupdict()
660                 o_width = int(detail.get("w")) + int(detail.get("x", "0"))
661                 o_height = int(detail.get("h")) + int(detail.get("y", "0"))
662         width = max(width, o_width)
663         height = max(height, o_height)
664     return int(width), int(height)
665
666
667 def apply_configuration(new_configuration, current_configuration, dry_run=False):
668     "Apply a configuration"
669     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
670     if dry_run:
671         base_argv = ["echo", "xrandr"]
672     else:
673         base_argv = ["xrandr"]
674
675     # There are several xrandr / driver bugs we need to take care of here:
676     # - We cannot enable more than two screens at the same time
677     #   See https://github.com/phillipberndt/autorandr/pull/6
678     #   and commits f4cce4d and 8429886.
679     # - We cannot disable all screens
680     #   See https://github.com/phillipberndt/autorandr/pull/20
681     # - We should disable screens before enabling others, because there's
682     #   a limit on the number of enabled screens
683     # - We must make sure that the screen at 0x0 is activated first,
684     #   or the other (first) screen to be activated would be moved there.
685     # - If an active screen already has a transformation and remains active,
686     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
687     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
688     #   at least.)
689     # - Some implementations can not handle --transform at all, so avoid it unless
690     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
691     # - Some implementations can not handle --panning without specifying --fb
692     #   explicitly, so avoid it unless necessary.
693     #   (See https://github.com/phillipberndt/autorandr/issues/72)
694
695     fb_dimensions = get_fb_dimensions(new_configuration)
696     try:
697         base_argv += ["--fb", "%dx%d" % fb_dimensions]
698     except:
699         # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
700         pass
701
702     auxiliary_changes_pre = []
703     disable_outputs = []
704     enable_outputs = []
705     remain_active_count = 0
706     for output in outputs:
707         if not new_configuration[output].edid or "off" in new_configuration[output].options:
708             disable_outputs.append(new_configuration[output].option_vector)
709         else:
710             if "off" not in current_configuration[output].options:
711                 remain_active_count += 1
712
713             option_vector = new_configuration[output].option_vector
714             if xrandr_version() >= Version("1.3.0"):
715                 for option, off_value in (("transform", "none"), ("panning", "0x0")):
716                     if option in current_configuration[output].options:
717                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
718                     else:
719                         try:
720                             option_index = option_vector.index("--%s" % option)
721                             if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
722                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
723                         except ValueError:
724                             pass
725
726             enable_outputs.append(option_vector)
727
728     # Perform pe-change auxiliary changes
729     if auxiliary_changes_pre:
730         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
731         if call_and_retry(argv, dry_run=dry_run) != 0:
732             raise AutorandrException("Command failed: %s" % " ".join(argv))
733
734     # Disable unused outputs, but make sure that there always is at least one active screen
735     disable_keep = 0 if remain_active_count else 1
736     if len(disable_outputs) > disable_keep:
737         argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
738         if call_and_retry(argv, dry_run=dry_run) != 0:
739             # Disabling the outputs failed. Retry with the next command:
740             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
741             # This does not occur if simultaneously the primary screen is reset.
742             pass
743         else:
744             disable_outputs = disable_outputs[-1:] if disable_keep else []
745
746     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
747     # disable the last two screens. This is a problem, so if this would happen, instead disable only
748     # one screen in the first call below.
749     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
750         # In the context of a xrandr call that changes the display state, `--query' should do nothing
751         disable_outputs.insert(0, ['--query'])
752
753     # Enable the remaining outputs in pairs of two operations
754     operations = disable_outputs + enable_outputs
755     for index in range(0, len(operations), 2):
756         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
757         if call_and_retry(argv, dry_run=dry_run) != 0:
758             raise AutorandrException("Command failed: %s" % " ".join(argv))
759
760
761 def is_equal_configuration(source_configuration, target_configuration):
762     """
763         Check if all outputs from target are already configured correctly in source and
764         that no other outputs are active.
765     """
766     for output in target_configuration.keys():
767         if "off" in target_configuration[output].options:
768             if (output in source_configuration and "off" not in source_configuration[output].options):
769                 return False
770         else:
771             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
772                 return False
773     for output in source_configuration.keys():
774         if "off" in source_configuration[output].options:
775             if output in target_configuration and "off" not in target_configuration[output].options:
776                 return False
777         else:
778             if output not in target_configuration:
779                 return False
780     return True
781
782
783 def add_unused_outputs(source_configuration, target_configuration):
784     "Add outputs that are missing in target to target, in 'off' state"
785     for output_name, output in source_configuration.items():
786         if output_name not in target_configuration:
787             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
788
789
790 def remove_irrelevant_outputs(source_configuration, target_configuration):
791     "Remove outputs from target that ought to be 'off' and already are"
792     for output_name, output in source_configuration.items():
793         if "off" in output.options:
794             if output_name in target_configuration:
795                 if "off" in target_configuration[output_name].options:
796                     del target_configuration[output_name]
797
798
799 def generate_virtual_profile(configuration, modes, profile_name):
800     "Generate one of the virtual profiles"
801     configuration = copy.deepcopy(configuration)
802     if profile_name == "common":
803         mode_sets = []
804         for output, output_modes in modes.items():
805             mode_set = set()
806             if configuration[output].edid:
807                 for mode in output_modes:
808                     mode_set.add((mode["width"], mode["height"]))
809             mode_sets.append(mode_set)
810         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
811         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
812         if common_resolution:
813             for output in configuration:
814                 configuration[output].options = {}
815                 if output in modes and configuration[output].edid:
816                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
817                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
818                     mode = modes_filtered[0]
819                     configuration[output].options["mode"] = mode['name']
820                     configuration[output].options["pos"] = "0x0"
821                 else:
822                     configuration[output].options["off"] = None
823     elif profile_name in ("horizontal", "vertical"):
824         shift = 0
825         if profile_name == "horizontal":
826             shift_index = "width"
827             pos_specifier = "%sx0"
828         else:
829             shift_index = "height"
830             pos_specifier = "0x%s"
831
832         for output in configuration:
833             configuration[output].options = {}
834             if output in modes and configuration[output].edid:
835                 def key(a):
836                     score = int(a["width"]) * int(a["height"])
837                     if a["preferred"]:
838                         score += 10**6
839                     return score
840                 output_modes = sorted(modes[output], key=key)
841                 mode = output_modes[-1]
842                 configuration[output].options["mode"] = mode["name"]
843                 configuration[output].options["rate"] = mode["rate"]
844                 configuration[output].options["pos"] = pos_specifier % shift
845                 shift += int(mode[shift_index])
846             else:
847                 configuration[output].options["off"] = None
848     elif profile_name == "clone-largest":
849         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
850         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
851         biggest_resolution = modes_sorted[0]
852         for output in configuration:
853             configuration[output].options = {}
854             if output in modes and configuration[output].edid:
855                 def key(a):
856                     score = int(a["width"]) * int(a["height"])
857                     if a["preferred"]:
858                         score += 10**6
859                     return score
860                 output_modes = sorted(modes[output], key=key)
861                 mode = output_modes[-1]
862                 configuration[output].options["mode"] = mode["name"]
863                 configuration[output].options["rate"] = mode["rate"]
864                 configuration[output].options["pos"] = "0x0"
865                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
866                             float(biggest_resolution["height"]) / float(mode["height"]))
867                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
868                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
869                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
870             else:
871                 configuration[output].options["off"] = None
872     elif profile_name == "off":
873         for output in configuration:
874             for key in list(configuration[output].options.keys()):
875                 del configuration[output].options[key]
876             configuration[output].options["off"] = None
877     return configuration
878
879
880 def print_profile_differences(one, another):
881     "Print the differences between two profiles for debugging"
882     if one == another:
883         return
884     print("| Differences between the two profiles:")
885     for output in set(chain.from_iterable((one.keys(), another.keys()))):
886         if output not in one:
887             if "off" not in another[output].options:
888                 print("| Output `%s' is missing from the active configuration" % output)
889         elif output not in another:
890             if "off" not in one[output].options:
891                 print("| Output `%s' is missing from the new configuration" % output)
892         else:
893             for line in one[output].verbose_diff(another[output]):
894                 print("| [Output %s] %s" % (output, line))
895     print("\\-")
896
897
898 def exit_help():
899     "Print help and exit"
900     print(help_text)
901     for profile in virtual_profiles:
902         name, description = profile[:2]
903         description = [description]
904         max_width = 78 - 18
905         while len(description[0]) > max_width + 1:
906             left_over = description[0][max_width:]
907             description[0] = description[0][:max_width] + "-"
908             description.insert(1, "  %-15s %s" % ("", left_over))
909         description = "\n".join(description)
910         print("  %-15s %s" % (name, description))
911     sys.exit(0)
912
913
914 def exec_scripts(profile_path, script_name, meta_information=None):
915     """"Run userscripts
916
917     This will run all executables from the profile folder, and global per-user
918     and system-wide configuration folders, named script_name or residing in
919     subdirectories named script_name.d.
920
921     If profile_path is None, only global scripts will be invoked.
922
923     meta_information is expected to be an dictionary. It will be passed to the block scripts
924     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
925
926     Returns True unless any of the scripts exited with non-zero exit status.
927     """
928     all_ok = True
929     env = os.environ.copy()
930     if meta_information:
931         for key, value in meta_information.items():
932             env["AUTORANDR_{}".format(key.upper())] = str(value)
933
934     # If there are multiple candidates, the XDG spec tells to only use the first one.
935     ran_scripts = set()
936
937     user_profile_path = os.path.expanduser("~/.autorandr")
938     if not os.path.isdir(user_profile_path):
939         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
940
941     candidate_directories = [user_profile_path]
942     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
943         candidate_directories.append(os.path.join(config_dir, "autorandr"))
944     if profile_path:
945         candidate_directories.append(profile_path)
946
947     for folder in candidate_directories:
948         if script_name not in ran_scripts:
949             script = os.path.join(folder, script_name)
950             if os.access(script, os.X_OK | os.F_OK):
951                 try:
952                     all_ok &= subprocess.call(script, env=env) != 0
953                 except:
954                     raise AutorandrException("Failed to execute user command: %s" % (script,))
955                 ran_scripts.add(script_name)
956
957         script_folder = os.path.join(folder, "%s.d" % script_name)
958         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
959             for file_name in os.listdir(script_folder):
960                 check_name = "d/%s" % (file_name,)
961                 if check_name not in ran_scripts:
962                     script = os.path.join(script_folder, file_name)
963                     if os.access(script, os.X_OK | os.F_OK):
964                         try:
965                             all_ok &= subprocess.call(script, env=env) != 0
966                         except:
967                             raise AutorandrException("Failed to execute user command: %s" % (script,))
968                         ran_scripts.add(check_name)
969
970     return all_ok
971
972
973 def dispatch_call_to_sessions(argv):
974     """Invoke autorandr for each open local X11 session with the given options.
975
976     The function iterates over all processes not owned by root and checks
977     whether they have DISPLAY and XAUTHORITY variables set. It strips the
978     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
979     this display has been handled already. If it has not, it forks, changes
980     uid/gid to the user owning the process, reuses the process's environment
981     and runs autorandr with the parameters from argv.
982
983     This function requires root permissions. It only works for X11 servers that
984     have at least one non-root process running. It is susceptible for attacks
985     where one user runs a process with another user's DISPLAY variable - in
986     this case, it might happen that autorandr is invoked for the other user,
987     which won't work. Since no other harm than prevention of automated
988     execution of autorandr can be done this way, the assumption is that in this
989     situation, the local administrator will handle the situation."""
990
991     X11_displays_done = set()
992
993     autorandr_binary = os.path.abspath(argv[0])
994     backup_candidates = {}
995
996     def fork_child_autorandr(pwent, process_environ):
997         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
998         child_pid = os.fork()
999         if child_pid == 0:
1000             # This will throw an exception if any of the privilege changes fails,
1001             # so it should be safe. Also, note that since the environment
1002             # is taken from a process owned by the user, reusing it should
1003             # not leak any information.
1004             os.setgroups([])
1005             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1006             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1007             os.chdir(pwent.pw_dir)
1008             os.environ.clear()
1009             os.environ.update(process_environ)
1010             os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1011             os.exit(1)
1012         os.waitpid(child_pid, 0)
1013
1014     for directory in os.listdir("/proc"):
1015         directory = os.path.join("/proc/", directory)
1016         if not os.path.isdir(directory):
1017             continue
1018         environ_file = os.path.join(directory, "environ")
1019         if not os.path.isfile(environ_file):
1020             continue
1021         uid = os.stat(environ_file).st_uid
1022
1023         # The following line assumes that user accounts start at 1000 and that
1024         # no one works using the root or another system account. This is rather
1025         # restrictive, but de facto default. Alternatives would be to use the
1026         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1027         # but effectively, both values aren't binding in any way.
1028         # If this breaks your use case, please file a bug on Github.
1029         if uid < 1000:
1030             continue
1031
1032         process_environ = {}
1033         for environ_entry in open(environ_file).read().split("\0"):
1034             name, sep, value = environ_entry.partition("=")
1035             if name and sep:
1036                 if name == "DISPLAY" and "." in value:
1037                     value = value[:value.find(".")]
1038                 process_environ[name] = value
1039
1040         if "DISPLAY" not in process_environ:
1041             # Cannot work with this environment, skip.
1042             continue
1043
1044         # To allow scripts to detect batch invocation (especially useful for predetect)
1045         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1046         process_environ["UID"] = str(uid)
1047
1048         display = process_environ["DISPLAY"]
1049
1050         if "XAUTHORITY" not in process_environ:
1051             # It's very likely that we cannot work with this environment either,
1052             # but keep it as a backup just in case we don't find anything else.
1053             backup_candidates[display] = process_environ
1054             continue
1055
1056         if display not in X11_displays_done:
1057             try:
1058                 pwent = pwd.getpwuid(uid)
1059             except KeyError:
1060                 # User has no pwd entry
1061                 continue
1062
1063             fork_child_autorandr(pwent, process_environ)
1064             X11_displays_done.add(display)
1065
1066     # Run autorandr for any users/displays which didn't have a process with
1067     # XAUTHORITY set.
1068     for display, process_environ in backup_candidates.items():
1069         if display not in X11_displays_done:
1070             try:
1071                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1072             except KeyError:
1073                 # User has no pwd entry
1074                 continue
1075
1076             fork_child_autorandr(pwent, process_environ)
1077             X11_displays_done.add(display)
1078
1079
1080 def enabled_monitors(config):
1081     monitors = []
1082     for monitor in config:
1083         if "--off" in config[monitor].option_vector:
1084             continue
1085         monitors.append(monitor)
1086     return monitors
1087
1088
1089 def read_config(options, directory):
1090     """Parse a configuration config.ini from directory and merge it into
1091     the options dictionary"""
1092     config = configparser.ConfigParser()
1093     config.read(os.path.join(directory, "settings.ini"))
1094     if config.has_section("config"):
1095         for key, value in config.items("config"):
1096             options.setdefault("--%s" % key, value)
1097
1098 def main(argv):
1099     try:
1100         opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1101                                    ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1102                                     "force", "fingerprint", "config", "debug", "skip-options=", "help",
1103                                     "current", "detected", "version"])
1104     except getopt.GetoptError as e:
1105         print("Failed to parse options: {0}.\n"
1106               "Use --help to get usage information.".format(str(e)),
1107               file=sys.stderr)
1108         sys.exit(posix.EX_USAGE)
1109
1110     options = dict(opts)
1111
1112     if "-h" in options or "--help" in options:
1113         exit_help()
1114
1115     if "--version" in options:
1116         print("autorandr " + __version__)
1117         sys.exit(0)
1118
1119     if "--current" in options and "--detected" in options:
1120         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1121         sys.exit(posix.EX_USAGE)
1122
1123     # Batch mode
1124     if "--batch" in options:
1125         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1126             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1127         else:
1128             print("--batch mode can only be used by root and if $DISPLAY is unset")
1129         return
1130     if "AUTORANDR_BATCH_PID" in os.environ:
1131         user = pwd.getpwuid(os.getuid())
1132         user = user.pw_name if user else "#%d" % os.getuid()
1133         print("autorandr running as user %s (started from batch instance)" % user)
1134
1135     profiles = {}
1136     profile_symlinks = {}
1137     try:
1138         # Load profiles from each XDG config directory
1139         # The XDG spec says that earlier entries should take precedence, so reverse the order
1140         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1141             system_profile_path = os.path.join(directory, "autorandr")
1142             if os.path.isdir(system_profile_path):
1143                 profiles.update(load_profiles(system_profile_path))
1144                 profile_symlinks.update(get_symlinks(system_profile_path))
1145                 read_config(options, system_profile_path)
1146         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1147         # profile_path is also used later on to store configurations
1148         profile_path = os.path.expanduser("~/.autorandr")
1149         if not os.path.isdir(profile_path):
1150             # Elsewise, follow the XDG specification
1151             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1152         if os.path.isdir(profile_path):
1153             profiles.update(load_profiles(profile_path))
1154             profile_symlinks.update(get_symlinks(profile_path))
1155             read_config(options, profile_path)
1156         # Sort by descending mtime
1157         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1158     except Exception as e:
1159         raise AutorandrException("Failed to load profiles", e)
1160
1161     profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
1162
1163     exec_scripts(None, "predetect")
1164     config, modes = parse_xrandr_output()
1165
1166     if "--fingerprint" in options:
1167         output_setup(config, sys.stdout)
1168         sys.exit(0)
1169
1170     if "--config" in options:
1171         output_configuration(config, sys.stdout)
1172         sys.exit(0)
1173
1174     if "--skip-options" in options:
1175         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1176         for profile in profiles.values():
1177             for output in profile["config"].values():
1178                 output.set_ignored_options(skip_options)
1179         for output in config.values():
1180             output.set_ignored_options(skip_options)
1181
1182     if "-s" in options:
1183         options["--save"] = options["-s"]
1184     if "--save" in options:
1185         if options["--save"] in (x[0] for x in virtual_profiles):
1186             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1187                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1188         try:
1189             profile_folder = os.path.join(profile_path, options["--save"])
1190             save_configuration(profile_folder, config)
1191             exec_scripts(profile_folder, "postsave", {
1192                 "CURRENT_PROFILE": options["--save"],
1193                 "PROFILE_FOLDER": profile_folder,
1194                 "MONITORS": ":".join(enabled_monitors(config)),
1195             })
1196         except Exception as e:
1197             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1198         print("Saved current configuration as profile '%s'" % options["--save"])
1199         sys.exit(0)
1200
1201     if "-r" in options:
1202         options["--remove"] = options["-r"]
1203     if "--remove" in options:
1204         if options["--remove"] in (x[0] for x in virtual_profiles):
1205             raise AutorandrException("Cannot remove profile '%s':\n"
1206                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1207         if options["--remove"] not in profiles.keys():
1208             raise AutorandrException("Cannot remove profile '%s':\n"
1209                                      "This profile does not exist." % options["--remove"])
1210         try:
1211             remove = True
1212             profile_folder = os.path.join(profile_path, options["--remove"])
1213             profile_dirlist = os.listdir(profile_folder)
1214             profile_dirlist.remove("config")
1215             profile_dirlist.remove("setup")
1216             if profile_dirlist:
1217                 print("Profile folder '%s' contains the following additional files:\n"
1218                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1219                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1220                 if response != "yes":
1221                     remove = False
1222             if remove is True:
1223                 shutil.rmtree(profile_folder)
1224                 print("Removed profile '%s'" % options["--remove"])
1225             else:
1226                 print("Profile '%s' was not removed" % options["--remove"])
1227         except Exception as e:
1228             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1229         sys.exit(0)
1230
1231     detected_profiles = find_profiles(config, profiles)
1232     load_profile = False
1233
1234     if "-l" in options:
1235         options["--load"] = options["-l"]
1236     if "--load" in options:
1237         load_profile = options["--load"]
1238     elif len(args) == 1:
1239         load_profile = args[0]
1240     else:
1241         # Find the active profile(s) first, for the block script (See #42)
1242         current_profiles = []
1243         for profile_name in profiles.keys():
1244             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1245             if configs_are_equal:
1246                 current_profiles.append(profile_name)
1247         block_script_metadata = {
1248             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1249             "CURRENT_PROFILES": ":".join(current_profiles)
1250         }
1251
1252         for profile_name in profiles.keys():
1253             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1254                 if "--current" not in options and "--detected" not in options:
1255                     print("%s (blocked)" % profile_name)
1256                 continue
1257             props = []
1258             if profile_name in detected_profiles:
1259                 props.append("(detected)")
1260                 if ("-c" in options or "--change" in options) and not load_profile:
1261                     load_profile = profile_name
1262             elif "--detected" in options:
1263                 continue
1264             if profile_name in current_profiles:
1265                 props.append("(current)")
1266             elif "--current" in options:
1267                 continue
1268             if "--current" in options or "--detected" in options:
1269                 print("%s" % (profile_name, ))
1270             else:
1271                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1272             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1273                 print_profile_differences(config, profiles[profile_name]["config"])
1274
1275     if "-d" in options:
1276         options["--default"] = options["-d"]
1277     if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
1278         load_profile = options["--default"]
1279
1280     if load_profile:
1281         if load_profile in profile_symlinks:
1282             if "--debug" in options:
1283                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1284             load_profile = profile_symlinks[load_profile]
1285
1286         if load_profile in (x[0] for x in virtual_profiles):
1287             load_config = generate_virtual_profile(config, modes, load_profile)
1288             scripts_path = os.path.join(profile_path, load_profile)
1289         else:
1290             try:
1291                 profile = profiles[load_profile]
1292                 load_config = profile["config"]
1293                 scripts_path = profile["path"]
1294             except KeyError:
1295                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1296             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1297                 update_mtime(os.path.join(scripts_path, "config"))
1298         add_unused_outputs(config, load_config)
1299         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1300             print("Config already loaded", file=sys.stderr)
1301             sys.exit(0)
1302         if "--debug" in options and load_config != dict(config):
1303             print("Loading profile '%s'" % load_profile)
1304             print_profile_differences(config, load_config)
1305
1306         remove_irrelevant_outputs(config, load_config)
1307
1308         try:
1309             if "--dry-run" in options:
1310                 apply_configuration(load_config, config, True)
1311             else:
1312                 script_metadata = {
1313                     "CURRENT_PROFILE": load_profile,
1314                     "PROFILE_FOLDER": scripts_path,
1315                     "MONITORS": ":".join(enabled_monitors(load_config)),
1316                 }
1317                 exec_scripts(scripts_path, "preswitch", script_metadata)
1318                 if "--debug" in options:
1319                     print("Going to run:")
1320                     apply_configuration(load_config, config, True)
1321                 apply_configuration(load_config, config, False)
1322                 exec_scripts(scripts_path, "postswitch", script_metadata)
1323         except AutorandrException as e:
1324             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1325         except Exception as e:
1326             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1327
1328         if "--dry-run" not in options and "--debug" in options:
1329             new_config, _ = parse_xrandr_output()
1330             if not is_equal_configuration(new_config, load_config):
1331                 print("The configuration change did not go as expected:")
1332                 print_profile_differences(new_config, load_config)
1333
1334     sys.exit(0)
1335
1336
1337 def exception_handled_main(argv=sys.argv):
1338     try:
1339         main(sys.argv)
1340     except AutorandrException as e:
1341         print(e, file=sys.stderr)
1342         sys.exit(1)
1343     except Exception as e:
1344         if not len(str(e)):  # BdbQuit
1345             print("Exception: {0}".format(e.__class__.__name__))
1346             sys.exit(2)
1347
1348         print("Unhandled exception ({0}). Please report this as a bug at "
1349               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1350               file=sys.stderr)
1351         raise
1352
1353
1354 if __name__ == '__main__':
1355     exception_handled_main()