Skip to content

Commit 2a05ba1

Browse files
committed
Merge remote-tracking branch 'xylix/dev/autodetect-modules' into dev/improved-module-detection
2 parents 7a67423 + 6db5435 commit 2a05ba1

File tree

2 files changed

+114
-36
lines changed

2 files changed

+114
-36
lines changed

aw_qt/manager.py

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
22
import platform
3+
from glob import glob
34
from time import sleep
45
import logging
56
import subprocess
7+
import shutil
68
from typing import Optional, List, Dict, Set
79

810
import aw_core
@@ -11,53 +13,100 @@
1113

1214
logger = logging.getLogger(__name__)
1315

16+
_module_dir = os.path.dirname(os.path.realpath(__file__))
17+
_parent_dir = os.path.abspath(os.path.join(_module_dir, os.pardir))
18+
_search_paths = [_module_dir, _parent_dir]
1419

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

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

30-
for exec_path in exec_paths:
25+
# Look for it in the installation path
26+
for exec_path in _exec_paths:
3127
if os.path.isfile(exec_path):
3228
# logger.debug("Found executable for {} in: {}".format(name, exec_path))
33-
return [exec_path]
34-
break # this break is redundant, but kept due to for-else semantics
29+
return exec_path
30+
return None
31+
32+
33+
def _is_system_module(name) -> bool:
34+
"""Checks if a module with a particular name exists in PATH"""
35+
return shutil.which(name) is not None
36+
37+
38+
def _locate_executable(name: str) -> Optional[str]:
39+
"""
40+
Will return the path to the executable if bundled,
41+
otherwise returns the name if it is available in PATH.
42+
43+
Used when calling Popen.
44+
"""
45+
exec_path = _locate_bundled_executable(name)
46+
if exec_path is not None: # Check if it exists in bundle
47+
return exec_path
48+
elif _is_system_module(name): # Check if it's in PATH
49+
return name
3550
else:
36-
# TODO: Actually check if it is in PATH
37-
logger.debug("Trying to start {} using PATH (executable not found in: {})"
38-
.format(name, exec_paths))
39-
return [name]
51+
logger.warning("Could not find module '{}' in installation directory or PATH".format(name))
52+
return None
53+
54+
55+
def _discover_modules_bundled() -> List[str]:
56+
# Look for modules in source dir and parent dir
57+
modules = []
58+
for path in _search_paths:
59+
matches = glob(os.path.join(path, "aw-*"))
60+
for match in matches:
61+
if os.path.isfile(match) and os.access(match, os.X_OK):
62+
name = os.path.basename(match)
63+
modules.append(name)
64+
else:
65+
logger.warning("Found matching file but was not executable: {}".format(path))
66+
67+
logger.info("Found bundled modules: {}".format(set(modules)))
68+
return modules
69+
70+
71+
def _discover_modules_system() -> List[str]:
72+
search_paths = os.environ["PATH"].split(":")
73+
modules = []
74+
for path in search_paths:
75+
if os.path.isdir(path):
76+
files = os.listdir(path)
77+
for filename in files:
78+
if "aw-" in filename:
79+
modules.append(filename)
80+
81+
logger.info("Found system modules: {}".format(set(modules)))
82+
return modules
4083

4184

4285
class Module:
4386
def __init__(self, name: str, testing: bool = False) -> None:
4487
self.name = name
45-
self.started = False
88+
self.started = False # Should be True if module is supposed to be running, else False
4689
self.testing = testing
90+
self.location = "system" if _is_system_module(name) else "bundled"
4791
self._process = None # type: Optional[subprocess.Popen]
4892
self._last_process = None # type: Optional[subprocess.Popen]
4993

5094
def start(self) -> None:
5195
logger.info("Starting module {}".format(self.name))
5296

5397
# Create a process group, become its leader
98+
# TODO: This shouldn't go here
5499
if platform.system() != "Windows":
55100
os.setpgrp() # type: ignore
56101

57-
exec_cmd = _locate_executable(self.name)
58-
if self.testing:
59-
exec_cmd.append("--testing")
60-
# logger.debug("Running: {}".format(exec_cmd))
102+
exec_path = _locate_executable(self.name)
103+
if exec_path is None:
104+
return
105+
else:
106+
exec_cmd = [exec_path]
107+
if self.testing:
108+
exec_cmd.append("--testing")
109+
# logger.debug("Running: {}".format(exec_cmd))
61110

62111
# Don't display a console window on Windows
63112
# See: https://github.com/ActivityWatch/activitywatch/issues/212
@@ -74,7 +123,6 @@ def start(self) -> None:
74123
# See: https://github.com/ActivityWatch/aw-server/issues/27
75124
self._process = subprocess.Popen(exec_cmd, universal_newlines=True, startupinfo=startupinfo)
76125

77-
# Should be True if module is supposed to be running, else False
78126
self.started = True
79127

80128
def stop(self) -> None:
@@ -138,6 +186,18 @@ def __init__(self, testing: bool = False) -> None:
138186
self.modules[name] = Module(name, testing=testing)
139187
else:
140188
logger.warning("Module '{}' not found but was in possible modules".format(name))
189+
# Is this actually a good way to do this? merged from dev/autodetect-modules
190+
self.discover_modules()
191+
192+
def discover_modules(self):
193+
# These should always be bundled with aw-qt
194+
found_modules = set(_discover_modules_bundled())
195+
found_modules |= set(_discover_modules_system())
196+
found_modules ^= {"aw-qt"} # Exclude self
197+
198+
for m_name in found_modules:
199+
if m_name not in self.modules:
200+
self.modules[m_name] = Module(m_name, testing=self.testing)
141201

142202
def get_unexpected_stops(self):
143203
return list(filter(lambda x: x.started and not x.is_alive(), self.modules.values()))

aw_qt/trayicon.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import webbrowser
55
import os
66
import subprocess
7+
from collections import defaultdict
78

89
from PyQt5 import QtCore
910
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMessageBox, QMenu, QWidget, QPushButton
@@ -93,12 +94,18 @@ def show_module_failed_dialog(module):
9394
box.show()
9495

9596
def rebuild_modules_menu():
96-
for module in modulesMenu.actions():
97-
name = module.text()
98-
alive = self.manager.modules[name].is_alive()
99-
module.setChecked(alive)
100-
# print(module.text(), alive)
97+
for action in modulesMenu.actions():
98+
if action.isEnabled():
99+
name = action.module.name
100+
alive = self.manager.modules[name].is_alive()
101+
action.setChecked(alive)
102+
# print(module.text(), alive)
101103

104+
# TODO: Do it in a better way, singleShot isn't pretty...
105+
QtCore.QTimer.singleShot(2000, rebuild_modules_menu)
106+
QtCore.QTimer.singleShot(2000, rebuild_modules_menu)
107+
108+
def check_module_status():
102109
unexpected_exits = self.manager.get_unexpected_stops()
103110
if unexpected_exits:
104111
for module in unexpected_exits:
@@ -107,19 +114,30 @@ def rebuild_modules_menu():
107114

108115
# TODO: Do it in a better way, singleShot isn't pretty...
109116
QtCore.QTimer.singleShot(2000, rebuild_modules_menu)
110-
111-
QtCore.QTimer.singleShot(2000, rebuild_modules_menu)
117+
QtCore.QTimer.singleShot(2000, check_module_status)
112118

113119
def _build_modulemenu(self, moduleMenu):
114120
moduleMenu.clear()
115121

116122
def add_module_menuitem(module):
117-
ac = moduleMenu.addAction(module.name, lambda: module.toggle())
123+
title = module.name
124+
ac = moduleMenu.addAction(title, lambda: module.toggle())
125+
# Kind of nasty, but a quick way to affiliate the module to the menu action for when it needs updating
126+
ac.module = module
118127
ac.setCheckable(True)
119128
ac.setChecked(module.is_alive())
120129

121-
for module_name in sorted(self.manager.modules.keys()):
122-
add_module_menuitem(self.manager.modules[module_name])
130+
# Merged from branch dev/autodetect-modules, still kind of in progress with making this actually work
131+
modules_by_location = defaultdict(lambda: list())
132+
for module in sorted(self.manager.modules.values(), key=lambda m: m.name):
133+
modules_by_location[module.location].append(module)
134+
135+
for location, modules in sorted(modules_by_location.items(), key=lambda kv: kv[0]):
136+
header = moduleMenu.addAction(location)
137+
header.setEnabled(False)
138+
139+
for module in sorted(modules, key=lambda m: m.name):
140+
add_module_menuitem(self.manager.modules[module.name])
123141

124142

125143
def exit(manager: Manager):

0 commit comments

Comments
 (0)