Skip to content

Commit 839c9fd

Browse files
authored
Merge pull request #56 from xylix/dev/improved-module-detection
2 parents 20261da + 157a546 commit 839c9fd

8 files changed

Lines changed: 1150 additions & 896 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ test:
1717
test-integration:
1818
python ./tests/integration_tests.py --no-modules
1919

20+
lint:
21+
poetry run flake8 aw_qt --ignore=E501,E302,E305,E231 --per-file-ignores="__init__.py:F401"
22+
2023
typecheck:
21-
mypy aw_qt --ignore-missing-imports
24+
poetry run mypy aw_qt --strict --pretty
2225

2326
precommit:
2427
make typecheck

aw_qt/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from configparser import ConfigParser
2+
from typing import List
3+
4+
from aw_core.config import load_config
5+
import json
6+
7+
# NOTE: Updating this won't update the defaults for users, this is an issue with how aw_core.config works
8+
default_settings = {
9+
"autostart_modules": json.dumps(
10+
["aw-server", "aw-watcher-afk", "aw-watcher-window",]
11+
),
12+
}
13+
14+
default_config = ConfigParser()
15+
default_config["aw-qt"] = default_settings
16+
# Currently there's no reason to make them differ
17+
default_config["aw-qt-testing"] = default_settings
18+
19+
20+
class AwQtSettings:
21+
def __init__(self, testing: bool):
22+
"""
23+
An instance of loaded settings, containing a list of modules to autostart.
24+
Constructor takes a `testing` boolean as an argument
25+
"""
26+
qt_config = load_config("aw-qt", default_config)
27+
config_section = qt_config["aw-qt" if not testing else "aw-qt-testing"]
28+
29+
self.autostart_modules: List[str] = json.loads(
30+
config_section["autostart_modules"]
31+
)

aw_qt/main.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,38 @@
11
import sys
22
import logging
3-
import argparse
3+
import click
4+
from typing import Optional
45

56
from aw_core.log import setup_logging
67

78
from .manager import Manager
8-
99
from . import trayicon
10+
from .config import AwQtSettings
1011

1112
logger = logging.getLogger(__name__)
1213

1314

14-
def main():
15-
args = parse_args()
16-
setup_logging("aw-qt", testing=args.testing, verbose=args.testing, log_file=True)
17-
18-
_manager = Manager(testing=args.testing)
19-
_manager.autostart(args.autostart_modules)
20-
21-
error_code = trayicon.run(_manager, testing=args.testing)
15+
@click.command("aw-qt", help="A trayicon and service manager for ActivityWatch")
16+
@click.option(
17+
"--testing", is_flag=True, help="Run the trayicon and services in testing mode"
18+
)
19+
@click.option(
20+
"--autostart-modules",
21+
help="A comma-separated list of modules to autostart, or just `none` to not autostart anything.",
22+
)
23+
def main(testing: bool, autostart_modules: Optional[str]) -> None:
24+
config = AwQtSettings(testing=testing)
25+
_autostart_modules = (
26+
[m.strip() for m in autostart_modules.split(",") if m and m.lower() != "none"]
27+
if autostart_modules
28+
else config.autostart_modules
29+
)
30+
setup_logging("aw-qt", testing=testing, verbose=testing, log_file=True)
31+
32+
_manager = Manager(testing=testing)
33+
_manager.autostart(_autostart_modules)
34+
35+
error_code = trayicon.run(_manager, testing=testing)
2236
_manager.stop_all()
2337

2438
sys.exit(error_code)
25-
26-
27-
def parse_args():
28-
parser = argparse.ArgumentParser(prog="aw-qt", description='A trayicon and service manager for ActivityWatch')
29-
parser.add_argument('--testing', action='store_true',
30-
help='Run the trayicon and services in testing mode')
31-
parser.add_argument('--autostart-modules', dest='autostart_modules',
32-
type=lambda s: [m for m in s.split(',') if m and m.lower() != "none"],
33-
default=["aw-server", "aw-watcher-afk", "aw-watcher-window"],
34-
help='A comma-separated list of modules to autostart, or just `none` to not autostart anything')
35-
36-
return parser.parse_args()

aw_qt/manager.py

Lines changed: 155 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,143 @@
11
import os
2-
import platform
3-
from time import sleep
2+
import sys
43
import logging
54
import subprocess
6-
from typing import Optional, List
5+
import shutil
6+
from glob import glob
7+
from time import sleep
8+
from typing import Optional, List, Dict, Tuple
79

810
import aw_core
911

1012
logger = logging.getLogger(__name__)
1113

14+
_module_dir = os.path.dirname(os.path.realpath(__file__))
15+
_parent_dir = os.path.abspath(os.path.join(_module_dir, os.pardir))
16+
_search_paths = [_module_dir, _parent_dir]
1217

13-
def _locate_executable(name: str) -> List[str]:
14-
"""
15-
Will start module from localdir if present there,
16-
otherwise will try to call what is available in PATH.
17-
18-
Returns it as a Popen cmd list.
19-
"""
20-
curr_filepath = os.path.realpath(__file__)
21-
curr_dir = os.path.dirname(curr_filepath)
22-
program_dir = os.path.abspath(os.path.join(curr_dir, os.pardir))
23-
search_paths = [curr_dir, program_dir, os.path.join(program_dir, name)]
2418

25-
exec_end = ".exe" if platform.system() == "Windows" else ""
26-
exec_paths = [os.path.join(path, name + exec_end) for path in search_paths]
19+
def _locate_bundled_executable(name: str) -> Optional[str]:
20+
"""Returns the path to the module executable if it exists in the bundle, else None."""
21+
_exec_paths = [os.path.join(path, name) for path in _search_paths]
2722

28-
for exec_path in exec_paths:
23+
# Look for it in the installation path
24+
for exec_path in _exec_paths:
2925
if os.path.isfile(exec_path):
3026
# logger.debug("Found executable for {} in: {}".format(name, exec_path))
31-
return [exec_path]
32-
break # this break is redundant, but kept due to for-else semantics
27+
return exec_path
28+
return None
29+
30+
31+
def _is_system_module(name: str) -> bool:
32+
"""Checks if a module with a particular name exists in PATH"""
33+
return shutil.which(name) is not None
34+
35+
36+
def _locate_executable(name: str) -> Tuple[Optional[str], Optional[str]]:
37+
"""
38+
Will return the path to the executable if bundled,
39+
otherwise returns the name if it is available in PATH.
40+
41+
Used when calling Popen.
42+
"""
43+
exec_path = _locate_bundled_executable(name)
44+
if exec_path is not None: # Check if it exists in bundle
45+
return exec_path, "bundle"
46+
elif _is_system_module(name): # Check if it's in PATH
47+
return name, "system"
3348
else:
34-
# TODO: Actually check if it is in PATH
35-
logger.debug("Trying to start {} using PATH (executable not found in: {})"
36-
.format(name, exec_paths))
37-
return [name]
49+
logger.warning(
50+
"Could not find module '{}' in installation directory or PATH".format(name)
51+
)
52+
return None, None
53+
54+
55+
def _discover_modules_in_directory(search_path: str) -> List[str]:
56+
"""Look for modules in given directory path and recursively in subdirs matching aw-*"""
57+
modules = []
58+
matches = glob(os.path.join(search_path, "aw-*"))
59+
for match in matches:
60+
if os.path.isfile(match) and os.access(match, os.X_OK):
61+
name = os.path.basename(match)
62+
modules.append(name)
63+
elif os.path.isdir(match) and os.access(match, os.X_OK):
64+
modules.extend(_discover_modules_in_directory(match))
65+
else:
66+
logger.warning(
67+
"Found matching file but was not executable: {}".format(match)
68+
)
69+
return modules
70+
71+
72+
def _discover_modules_bundled() -> List[str]:
73+
"""Use ``_discover_modules_in_directory`` to find all bundled modules """
74+
cwd = os.getcwd()
75+
modules = _discover_modules_in_directory(cwd)
76+
logger.info("Found bundled modules: {}".format(set(modules)))
77+
return modules
78+
79+
80+
def _discover_modules_system() -> List[str]:
81+
"""Find all aw- modules in PATH"""
82+
search_paths = os.get_exec_path()
83+
modules = []
84+
for path in search_paths:
85+
if os.path.isdir(path):
86+
files = os.listdir(path)
87+
for filename in files:
88+
if filename.startswith("aw-"):
89+
modules.append(filename)
90+
91+
logger.info("Found system modules: {}".format(set(modules)))
92+
return modules
3893

3994

4095
class Module:
4196
def __init__(self, name: str, testing: bool = False) -> None:
4297
self.name = name
43-
self.started = False
98+
self.started = (
99+
False # Should be True if module is supposed to be running, else False
100+
)
44101
self.testing = testing
45-
self._process = None # type: Optional[subprocess.Popen]
46-
self._last_process = None # type: Optional[subprocess.Popen]
102+
self.location = "system" if _is_system_module(name) else "bundled"
103+
self._process: Optional[subprocess.Popen[str]] = None
104+
self._last_process: Optional[subprocess.Popen[str]] = None
47105

48106
def start(self) -> None:
49107
logger.info("Starting module {}".format(self.name))
50108

51109
# Create a process group, become its leader
52-
if platform.system() != "Windows":
53-
os.setpgrp() # type: ignore
110+
# TODO: This shouldn't go here
111+
if sys.platform != "win32":
112+
os.setpgrp()
54113

55-
exec_cmd = _locate_executable(self.name)
56-
if self.testing:
57-
exec_cmd.append("--testing")
58-
# logger.debug("Running: {}".format(exec_cmd))
114+
exec_path, location = _locate_executable(self.name)
115+
if exec_path is None:
116+
logger.error("Tried to start nonexistent module {}".format(self.name))
117+
return
118+
else:
119+
exec_cmd = [exec_path]
120+
if self.testing:
121+
exec_cmd.append("--testing")
122+
# logger.debug("Running: {}".format(exec_cmd))
59123

60124
# Don't display a console window on Windows
61125
# See: https://github.com/ActivityWatch/activitywatch/issues/212
62126
startupinfo = None
63-
if platform.system() == "Windows":
64-
startupinfo = subprocess.STARTUPINFO() # type: ignore
65-
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
66-
elif platform.system() == "Darwin":
67-
logger.info("Macos: Disable dock icon")
127+
if sys.platform == "win32" or sys.platform == "cygwin":
128+
startupinfo = subprocess.STARTUPINFO()
129+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
130+
elif sys.platform == "darwin":
131+
logger.info("macOS: Disable dock icon")
68132
import AppKit
133+
69134
AppKit.NSBundle.mainBundle().infoDictionary()["LSBackgroundOnly"] = "1"
70135

71136
# There is a very good reason stdout and stderr is not PIPE here
72137
# See: https://github.com/ActivityWatch/aw-server/issues/27
73-
self._process = subprocess.Popen(exec_cmd, universal_newlines=True, startupinfo=startupinfo)
74-
75-
# Should be True if module is supposed to be running, else False
138+
self._process = subprocess.Popen(
139+
exec_cmd, universal_newlines=True, startupinfo=startupinfo
140+
)
76141
self.started = True
77142

78143
def stop(self) -> None:
@@ -81,10 +146,14 @@ def stop(self) -> None:
81146
"""
82147
# TODO: What if a module doesn't stop? Add timeout to p.wait() and then do a p.kill() if timeout is hit
83148
if not self.started:
84-
logger.warning("Tried to stop module {}, but it hasn't been started".format(self.name))
149+
logger.warning(
150+
"Tried to stop module {}, but it hasn't been started".format(self.name)
151+
)
85152
return
86153
elif not self.is_alive():
87-
logger.warning("Tried to stop module {}, but it wasn't running".format(self.name))
154+
logger.warning(
155+
"Tried to stop module {}, but it wasn't running".format(self.name)
156+
)
88157
else:
89158
if not self._process:
90159
logger.error("No reference to process object")
@@ -126,42 +195,57 @@ def read_log(self) -> str:
126195

127196

128197
class Manager:
129-
def __init__(self, testing: bool=False) -> None:
130-
# TODO: Fetch these from somewhere appropriate (auto detect or a config file)
131-
# Save to config wether they should autostart or not.
132-
_possible_modules = [
133-
"aw-server",
134-
"aw-server-rust",
135-
"aw-watcher-afk",
136-
"aw-watcher-window",
137-
# "aw-watcher-spotify",
138-
# "aw-watcher-network"
139-
]
140-
141-
# TODO: Filter away all modules not available on system
142-
self.modules = {name: Module(name, testing=testing) for name in _possible_modules}
143-
144-
def get_unexpected_stops(self):
145-
return list(filter(lambda x: x.started and not x.is_alive(), self.modules.values()))
146-
147-
def start(self, module_name):
198+
def __init__(self, testing: bool = False) -> None:
199+
self.modules: Dict[str, Module] = {}
200+
self.testing = testing
201+
202+
self.discover_modules()
203+
204+
def discover_modules(self) -> None:
205+
# These should always be bundled with aw-qt
206+
found_modules = set(_discover_modules_bundled())
207+
found_modules |= set(_discover_modules_system())
208+
found_modules ^= {"aw-qt", "aw-client"} # Exclude self
209+
210+
for m_name in found_modules:
211+
if m_name not in self.modules:
212+
self.modules[m_name] = Module(m_name, testing=self.testing)
213+
214+
def get_unexpected_stops(self) -> List[Module]:
215+
return list(
216+
filter(lambda x: x.started and not x.is_alive(), self.modules.values())
217+
)
218+
219+
def start(self, module_name: str) -> None:
148220
if module_name in self.modules.keys():
149221
self.modules[module_name].start()
150222
else:
151-
logger.error("Unable to start module '{}': No such module".format(module_name))
223+
logger.debug(
224+
"Manager tried to start nonexistent module {}".format(module_name)
225+
)
152226

153-
def autostart(self, autostart_modules):
154-
# Always start aw-server first
155-
if "aw-server" in autostart_modules:
156-
self.start("aw-server")
157-
elif "aw-server-rust" in autostart_modules:
227+
def autostart(self, autostart_modules: List[str]) -> None:
228+
# We only want to autostart modules that are both in found modules and are asked to autostart.
229+
not_found = []
230+
for name in autostart_modules:
231+
if name not in self.modules.keys():
232+
logger.error(f"Module {name} not found")
233+
not_found.append(name)
234+
autostart_modules = list(set(autostart_modules) - set(not_found))
235+
236+
# Start aw-server-rust first
237+
if "aw-server-rust" in autostart_modules:
158238
self.start("aw-server-rust")
239+
elif "aw-server" in autostart_modules:
240+
self.start("aw-server")
159241

160-
autostart_modules = set(autostart_modules) - {"aw-server", "aw-server-rust"}
161-
for module_name in autostart_modules:
162-
self.start(module_name)
242+
autostart_modules = list(
243+
set(autostart_modules) - {"aw-server", "aw-server-rust"}
244+
)
245+
for name in autostart_modules:
246+
self.start(name)
163247

164-
def stop_all(self):
248+
def stop_all(self) -> None:
165249
for module in filter(lambda m: m.is_alive(), self.modules.values()):
166250
module.stop()
167251

0 commit comments

Comments
 (0)