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