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