Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved module detection, configuration, and added support for non-standard modules #56

Merged
merged 27 commits into from
Jul 25, 2020
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
53f7dd0
added basic module autodetection
ErikBjare Aug 30, 2017
fbe3519
fixed bundled modules
ErikBjare Aug 30, 2017
ba88995
fixed crash when module couldn't be started
ErikBjare Aug 30, 2017
4078647
fixed issue when location in PATH does not exist
ErikBjare Aug 31, 2017
70cca98
categorized module menu by location of module
ErikBjare Sep 1, 2017
6db5435
fixed typechecking and enabled on Travis
ErikBjare Sep 1, 2017
b7ed55a
actually check for module existence in manager
xylix Mar 2, 2020
a974e2c
Store available and autostart modules lists as configuration. See #47
wolferCZ Nov 4, 2019
9dfea4b
rename QTSettings to AwQtSettings and add aw-server-rust
xylix Mar 2, 2020
94dc1df
Add type hints to new code
xylix Mar 3, 2020
7a67423
remove 1 unused import, don't treat aw-server as a special case in cr…
xylix Mar 4, 2020
2a05ba1
Merge remote-tracking branch 'xylix/dev/autodetect-modules' into dev/…
xylix Mar 4, 2020
4403d19
Improve mypy typing
xylix Mar 4, 2020
d4e33cf
Improve type safety and add some info logs / comments
xylix Mar 4, 2020
ea9d196
Fix lambda parameter amount to allow termination on sigint / sigkill
xylix Mar 4, 2020
a7c098c
Add pyqt type stubs to dev dependencies, nicer mypy output by default
xylix Mar 4, 2020
8808f7c
Improve autostart() input typing
xylix Mar 4, 2020
b14b1be
Add pip cache for macOS, should make pyobjc etc. installation signifi…
xylix Mar 4, 2020
9e4b15e
Remove unnecessary possible_modules config option
xylix Mar 5, 2020
9a849ca
Recursively find submodules in subdirectories
xylix Mar 5, 2020
2a6a8da
Add some informative pydoc comments
xylix Mar 5, 2020
68314f0
Get PATH using python's os module instead of getting environment
xylix Apr 25, 2020
0a359bd
Merge branch 'master' into dev/improved-module-detection
ErikBjare Jul 25, 2020
f6d26c7
fix: minor cleanup
ErikBjare Jul 25, 2020
682a73f
feat: switched to using click for the CLI, fixed bugs
ErikBjare Jul 25, 2020
01378f1
fix: fixed typing checks
ErikBjare Jul 25, 2020
157a546
style: removed unused imports
ErikBjare Jul 25, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
dist: xenial
language: python

cache:
directories:
- $HOME/.cache/pip

matrix:
include:
- python: "3.6"
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test-integration:
python3 ./tests/integration_tests.py --no-modules

typecheck:
mypy aw_qt --ignore-missing-imports
mypy aw_qt --strict --pretty

precommit:
make typecheck
Expand Down
28 changes: 28 additions & 0 deletions aw_qt/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from configparser import ConfigParser
from typing import List

from aw_core.config import load_config
import json

default_settings = {
"autostart_modules": json.dumps(["aw-server-rust",
"aw-server",
"aw-watcher-afk",
"aw-watcher-window", ]),
}

default_config = ConfigParser()
default_config['aw-qt'] = default_settings
# Currently there's no reason to make them differ
default_config['aw-qt-testing'] = default_settings
qt_config = load_config("aw-qt", default_config)

"""
An instance of loaded settings, containing a list of modules to autostart.
Constructor takes a `testing` boolean as an argument
"""
class AwQtSettings:
def __init__(self, testing: bool):
config_section = qt_config["aw-qt" if not testing else "aw-qt-testing"]

self.autostart_modules: List[str] = json.loads(config_section["autostart_modules"])
25 changes: 15 additions & 10 deletions aw_qt/main.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
import sys
import logging
import argparse
from typing import List
from typing_extensions import TypedDict

from aw_core.log import setup_logging

from .manager import Manager

from . import trayicon

logger = logging.getLogger(__name__)


def main():
def main() -> None:
args = parse_args()
setup_logging("aw-qt", testing=args.testing, verbose=args.testing, log_file=True)
setup_logging("aw-qt", testing=args['testing'], verbose=args['testing'], log_file=True)

_manager = Manager(testing=args.testing)
_manager.autostart(args.autostart_modules)
_manager = Manager(testing=args['testing'])
_manager.autostart(args['autostart_modules'])

error_code = trayicon.run(_manager, testing=args.testing)
error_code = trayicon.run(_manager, testing=args['testing'])
_manager.stop_all()

sys.exit(error_code)


def parse_args():
CommandLineArgs = TypedDict('CommandLineArgs', {'testing': bool, 'autostart_modules': List[str]}, total=False)


def parse_args() -> CommandLineArgs:
parser = argparse.ArgumentParser(prog="aw-qt", description='A trayicon and service manager for ActivityWatch')
parser.add_argument('--testing', action='store_true',
help='Run the trayicon and services in testing mode')
parser.add_argument('--autostart-modules', dest='autostart_modules',
type=lambda s: [m for m in s.split(',') if m and m.lower() != "none"],
default=["aw-server", "aw-watcher-afk", "aw-watcher-window"],
default=None,
help='A comma-separated list of modules to autostart, or just `none` to not autostart anything')

return parser.parse_args()
parsed_args = parser.parse_args()
dict: CommandLineArgs = {'autostart_modules': parsed_args.autostart_modules, 'testing': parsed_args.testing}
return dict
189 changes: 129 additions & 60 deletions aw_qt/manager.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,128 @@
import os
import platform
from glob import glob
from time import sleep
import logging
import subprocess
from typing import Optional, List
import shutil
from typing import Optional, List, Dict, Set

import aw_core

from .config import AwQtSettings

logger = logging.getLogger(__name__)

_module_dir = os.path.dirname(os.path.realpath(__file__))
_parent_dir = os.path.abspath(os.path.join(_module_dir, os.pardir))
_search_paths = [_module_dir, _parent_dir]


def _locate_bundled_executable(name: str) -> Optional[str]:
"""Returns the path to the module executable if it exists in the bundle, else None."""
_exec_paths = [os.path.join(path, name) for path in _search_paths]

# Look for it in the installation path
for exec_path in _exec_paths:
if os.path.isfile(exec_path):
# logger.debug("Found executable for {} in: {}".format(name, exec_path))
return exec_path
return None


def _locate_executable(name: str) -> List[str]:
def _is_system_module(name: str) -> bool:
"""Checks if a module with a particular name exists in PATH"""
return shutil.which(name) is not None


def _locate_executable(name: str) -> Optional[str]:
"""
Will start module from localdir if present there,
otherwise will try to call what is available in PATH.
Will return the path to the executable if bundled,
otherwise returns the name if it is available in PATH.

Returns it as a Popen cmd list.
Used when calling Popen.
"""
curr_filepath = os.path.realpath(__file__)
curr_dir = os.path.dirname(curr_filepath)
program_dir = os.path.abspath(os.path.join(curr_dir, os.pardir))
search_paths = [curr_dir, program_dir, os.path.join(program_dir, name)]
exec_path = _locate_bundled_executable(name)
if exec_path is not None: # Check if it exists in bundle
return exec_path
elif _is_system_module(name): # Check if it's in PATH
return name
else:
logger.warning("Could not find module '{}' in installation directory or PATH".format(name))
return None


""" Look for modules in given directory path and recursively in subdirs matching aw-* """
def _discover_modules_in_directory(modules: List[str], search_path: str) -> None:
matches = glob(os.path.join(search_path, "aw-*"))
for match in matches:
if os.path.isfile(match) and os.access(match, os.X_OK):
name = os.path.basename(match)
modules.append(name)
elif os.path.isdir(match) and os.access(match, os.X_OK):
_discover_modules_in_directory(modules, match)
else:
logger.warning("Found matching file but was not executable: {}".format(match))

exec_end = ".exe" if platform.system() == "Windows" else ""
exec_paths = [os.path.join(path, name + exec_end) for path in search_paths]
""" Use _discover_modules_in_directory to find all bundled modules """
def _discover_modules_bundled() -> List[str]:
modules: List[str] = []
cwd = os.getcwd()
_discover_modules_in_directory(modules, cwd)

for exec_path in exec_paths:
if os.path.isfile(exec_path):
# logger.debug("Found executable for {} in: {}".format(name, exec_path))
return [exec_path]
break # this break is redundant, but kept due to for-else semantics
if len(modules) > 0:
logger.info("Found bundled modules: {}".format(set(modules)))
else:
# TODO: Actually check if it is in PATH
logger.debug("Trying to start {} using PATH (executable not found in: {})"
.format(name, exec_paths))
return [name]
logger.info("Found no bundles modules")
return modules

""" Find all aw- modules in PATH """
def _discover_modules_system() -> List[str]:
search_paths = os.environ["PATH"].split(":")
ErikBjare marked this conversation as resolved.
Show resolved Hide resolved
modules = []
for path in search_paths:
if os.path.isdir(path):
files = os.listdir(path)
for filename in files:
if "aw-" in filename:
modules.append(filename)

logger.info("Found system modules: {}".format(set(modules)))
return modules


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

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

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

exec_cmd = _locate_executable(self.name)
if self.testing:
exec_cmd.append("--testing")
# logger.debug("Running: {}".format(exec_cmd))
exec_path = _locate_executable(self.name)
if exec_path is None:
logger.error("Tried to start nonexistent module {}".format(self.name))
else:
exec_cmd = [exec_path]
if self.testing:
exec_cmd.append("--testing")
# logger.debug("Running: {}".format(exec_cmd))

# Don't display a console window on Windows
# See: https://github.com/ActivityWatch/activitywatch/issues/212
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO() # type: ignore
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
startupinfo = subprocess.STARTUPINFO() #type: ignore
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW #type: ignore
elif platform.system() == "Darwin":
logger.info("Macos: Disable dock icon")
import AppKit
Expand All @@ -71,8 +131,6 @@ def start(self) -> None:
# There is a very good reason stdout and stderr is not PIPE here
# See: https://github.com/ActivityWatch/aw-server/issues/27
self._process = subprocess.Popen(exec_cmd, universal_newlines=True, startupinfo=startupinfo)

# Should be True if module is supposed to be running, else False
ErikBjare marked this conversation as resolved.
Show resolved Hide resolved
self.started = True

def stop(self) -> None:
Expand All @@ -87,7 +145,7 @@ def stop(self) -> None:
logger.warning("Tried to stop module {}, but it wasn't running".format(self.name))
else:
if not self._process:
logger.error("No reference to process object")
logger.error("")
ErikBjare marked this conversation as resolved.
Show resolved Hide resolved
logger.debug("Stopping module {}".format(self.name))
if self._process:
self._process.terminate()
Expand Down Expand Up @@ -126,42 +184,53 @@ def read_log(self) -> str:


class Manager:
def __init__(self, testing: bool=False) -> None:
# TODO: Fetch these from somewhere appropriate (auto detect or a config file)
# Save to config wether they should autostart or not.
_possible_modules = [
"aw-server",
"aw-server-rust",
"aw-watcher-afk",
"aw-watcher-window",
# "aw-watcher-spotify",
# "aw-watcher-network"
]

# TODO: Filter away all modules not available on system
self.modules = {name: Module(name, testing=testing) for name in _possible_modules}

def get_unexpected_stops(self):
def __init__(self, testing: bool = False) -> None:
self.settings: AwQtSettings = AwQtSettings(testing)
self.modules: Dict[str, Module] = {}
self.autostart_modules: Set[str] = set(self.settings.autostart_modules)
self.testing = testing

self.discover_modules()

def discover_modules(self) -> None:
# These should always be bundled with aw-qt
found_modules = set(_discover_modules_bundled())
found_modules |= set(_discover_modules_system())
found_modules ^= {"aw-qt"} # Exclude self

for m_name in found_modules:
if m_name not in self.modules:
self.modules[m_name] = Module(m_name, testing=self.testing)

def get_unexpected_stops(self) -> List[Module]:
return list(filter(lambda x: x.started and not x.is_alive(), self.modules.values()))

def start(self, module_name):
def start(self, module_name: str) -> None:
if module_name in self.modules.keys():
self.modules[module_name].start()
else:
logger.error("Unable to start module '{}': No such module".format(module_name))

def autostart(self, autostart_modules):
# Always start aw-server first
if "aw-server" in autostart_modules:
self.start("aw-server")
elif "aw-server-rust" in autostart_modules:
logger.debug("Manager tried to start nonexistent module {}".format(module_name))

def autostart(self, autostart_modules: Optional[List[str]]) -> None:
if autostart_modules is None:
autostart_modules = []
if len(autostart_modules) > 0:
logger.info("Modules to start weren't specified in CLI arguments. Falling back to configuration.")
autostart_modules = self.settings.autostart_modules
# We only want to autostart modules that are both in found modules and are asked to autostart.
modules_to_start = set(autostart_modules).intersection(set(self.modules.keys()))

# Start aw-server-rust first
if "aw-server-rust" in modules_to_start:
self.start("aw-server-rust")
elif "aw-server" in modules_to_start:
self.start("aw-server")

autostart_modules = set(autostart_modules) - {"aw-server", "aw-server-rust"}
for module_name in autostart_modules:
modules_to_start = set(autostart_modules) - {"aw-server", "aw-server-rust"}
for module_name in modules_to_start:
self.start(module_name)

def stop_all(self):
def stop_all(self) -> None:
for module in filter(lambda m: m.is_alive(), self.modules.values()):
module.stop()

Expand Down
Loading