1# Copyright (c) 2020, Tycho Andersen. All rights reserved.
2#
3# Permission is hereby granted, free of charge, to any person obtaining a copy
4# of this software and associated documentation files (the "Software"), to deal
5# in the Software without restriction, including without limitation the rights
6# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7# copies of the Software, and to permit persons to whom the Software is
8# furnished to do so, subject to the following conditions:
9#
10# The above copyright notice and this permission notice shall be included in
11# all copies or substantial portions of the Software.
12#
13# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19# SOFTWARE.
20
21# Set the locale before any widgets or anything are imported, so any widget
22# whose defaults depend on a reasonable locale sees something reasonable.
23import shutil
24import subprocess
25import sys
26import tempfile
27from os import environ, getenv, path
28
29from libqtile import confreader
30
31
32def type_check_config_vars(tempdir, config_name):
33    if shutil.which("stubtest") is None:
34        print("stubtest not found, can't type check config file\n"
35              "install it and try again")
36        return
37
38    # write a .pyi file to tempdir:
39    f = open(path.join(tempdir, config_name+".pyi"), "w")
40    f.write(confreader.config_pyi_header)
41    for name, type_ in confreader.Config.__annotations__.items():
42        f.write(name)
43        f.write(": ")
44        f.write(type_)
45        f.write("\n")
46    f.close()
47
48    # need to tell python to look in pwd for modules
49    newenv = environ.copy()
50    newenv["PYTHONPATH"] = newenv.get("PYTHONPATH", "") + ":"
51
52    p = subprocess.Popen(
53        ["stubtest", "--concise", config_name],
54        stdout=subprocess.PIPE,
55        stderr=subprocess.PIPE,
56        cwd=tempdir,
57        text=True,
58        env=newenv,
59    )
60    stdout, stderr = p.communicate()
61    missing_vars = []
62    for line in (stdout+stderr).split("\n"):
63        # filter out stuff that users didn't specify; they'll be imported from
64        # the default config
65        if "is not present at runtime" in line:
66            missing_vars.append(line.split()[0])
67
68    # write missing vars to a tempfile
69    whitelist = open(path.join(tempdir, "stubtest_whitelist"), "w")
70    for var in missing_vars:
71        whitelist.write(var)
72        whitelist.write("\n")
73    whitelist.close()
74
75    p = subprocess.Popen([
76            "stubtest",
77            # ignore variables that the user creates in their config that
78            # aren't in our default config list
79            "--ignore-missing-stub",
80            # use our whitelist to ignore stuff users didn't specify
81            "--whitelist", whitelist.name,
82            config_name,
83        ],
84        cwd=tempdir,
85        text=True,
86        env=newenv,
87    )
88    p.wait()
89    if p.returncode != 0:
90        sys.exit(1)
91
92
93def type_check_config_args(config_file):
94    if shutil.which("mypy") is None:
95        print("mypy not found, can't type check config file"
96              "install it and try again")
97        return
98    try:
99        # we want to use Literal, which is in 3.8. If people have a mypy that
100        # is too old, they can upgrade; this is an optional check anyways.
101        subprocess.check_call(["mypy", "--python-version=3.8", config_file])
102        print("config file type checking succeeded")
103    except subprocess.CalledProcessError as e:
104        print("config file type checking failed: {}".format(e))
105        sys.exit(1)
106
107
108def check_config(args):
109    print("checking qtile config file {}".format(args.configfile))
110
111    # need to do all the checking in a tempdir because we need to write stuff
112    # for stubtest
113    with tempfile.TemporaryDirectory() as tempdir:
114        shutil.copytree(path.dirname(args.configfile), tempdir, dirs_exist_ok=True)
115        tmp_path = path.join(tempdir, path.basename(args.configfile))
116
117        # are the top level config variables the right type?
118        module_name = path.splitext(path.basename(args.configfile))[0]
119        type_check_config_vars(tempdir, module_name)
120
121        # are arguments passed to qtile APIs correct?
122        type_check_config_args(tmp_path)
123
124    # can we load the config?
125    config = confreader.Config(args.configfile)
126    config.load()
127    config.validate()
128    print("config file can be loaded by qtile")
129
130
131def add_subcommand(subparsers, parents):
132    parser = subparsers.add_parser(
133        "check",
134        parents=parents,
135        help="Check a configuration file for errors"
136    )
137    parser.add_argument(
138        "-c", "--config",
139        action="store",
140        default=path.expanduser(path.join(
141            getenv('XDG_CONFIG_HOME', '~/.config'), 'qtile', 'config.py')),
142        dest="configfile",
143        help='Use the specified configuration file',
144    )
145    parser.set_defaults(func=check_config)
146