Browse Source

Merge remote-tracking branch 'tooncher/master'

Fabian Peter Hammerle 4 years ago
parent
commit
ef403928b1
13 changed files with 603 additions and 310 deletions
  1. 6 12
      .gitignore
  2. 4 0
      .pylintrc
  3. 36 2
      CHANGELOG.md
  4. 2 0
      Pipfile
  5. 45 1
      Pipfile.lock
  6. 30 9
      README.md
  7. 0 125
      scripts/tooncher
  8. 15 5
      setup.py
  9. 0 27
      tests/test_.py
  10. 74 0
      tests/test_api.py
  11. 155 0
      tests/test_cli.py
  12. 90 129
      tooncher/__init__.py
  13. 146 0
      tooncher/_cli.py

+ 6 - 12
.gitignore

@@ -1,13 +1,7 @@
-/build
-/env
-
-# Compiled python modules.
+build/
+.eggs/
+.pytest_cache/
 *.pyc
-
-# Setuptools distribution folder.
-/dist/
-
-# Python egg metadata, regenerated from source files by setuptools.
-/*.egg-info
-
-/.cache
+.coverage
+dist/
+*.egg-info/

+ 4 - 0
.pylintrc

@@ -0,0 +1,4 @@
+[MESSAGES CONTROL]
+
+disable=bad-continuation,
+        missing-docstring

+ 36 - 2
CHANGELOG.md

@@ -5,8 +5,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## Unreleased
+### Added
+- package: use readme as long description for pypi.org
+
+## [1.0.0] - 2019-12-23
+### Added
+- path to tootown engine may be provided via env var `$TOONCHER_ENGINE_PATH`
+
+### Changed
+- command line interface:
+  - fail if selected username was not found in config
+  - fail if selected username has multiple entries in config
+  - install via `setuptools.setup(entry_points=…)`
+- python interface:
+  - now private:
+    - `tooncher.LOGIN_API_URL`
+    - `tooncher.LoginDelayed`
+    - `tooncher.LoginSuccessful`
+    - `tooncher.TOONTOWN_ENGINE_DEFAULT_PATH`
+    - `tooncher.api_request`
+    - `tooncher.login`
+  - `start_engine` & `launch`: expects `isinstance(engine_path, pathlib.Path)`
+    (instead of `str`)
+- pass all env vars to engine
+  (e.g., enables use of `optirun tooncher …`)
+
 ### Removed
-- `argcomplete`
+- python interface:
+  - `argcomplete`
+  - `tooncher.INVASIONS_API_URL`
+  - `tooncher.InvasionProgress`
+  - `tooncher.TOONTOWN_LIBRARY_PATH`
+  - `tooncher.request_active_invasions`
+
+### Fixed
+- mac: `$DYLD_LIBRARY_PATH` & `$DYLD_FRAMEWORK_PATH` relative to engine path
 
 ## [0.4.1] - 2019-12-22
 ### Fixed
@@ -14,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [0.4.0] - 2017-10-31
 
-[Unreleased]: https://github.com/fphammerle/tooncher/compare/0.4.1...HEAD
+[Unreleased]: https://github.com/fphammerle/tooncher/compare/1.0.0...HEAD
+[1.0.0]: https://github.com/fphammerle/tooncher/compare/0.4.1...1.0.0
 [0.4.1]: https://github.com/fphammerle/tooncher/compare/0.4.0...0.4.1
 [0.4.0]: https://github.com/fphammerle/tooncher/compare/0.3.1...0.4.0

+ 2 - 0
Pipfile

@@ -9,6 +9,8 @@ tooncher = {editable = true, extras = ["extended-controls"], path = "."}
 [dev-packages]
 black = "==19.10b0"
 pytest = "*"
+# pipenv run pytest --cov=tooncher --cov-report=term-missing
+pytest-cov = "*"
 
 [requires]
 python_version = "3"

+ 45 - 1
Pipfile.lock

@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "d809da1b5a943e1baa8d6f0b7afe9be726572a4f21b5dcf0f0f126ac85caef91"
+            "sha256": "1a48e7d2ac3b9a9b02795042ef0fa6c8aa02089cb3a18c38621f4fc165de6d4b"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -100,6 +100,42 @@
             ],
             "version": "==7.0"
         },
+        "coverage": {
+            "hashes": [
+                "sha256:0101888bd1592a20ccadae081ba10e8b204d20235d18d05c6f7d5e904a38fc10",
+                "sha256:04b961862334687549eb91cd5178a6fbe977ad365bddc7c60f2227f2f9880cf4",
+                "sha256:1ca43dbd739c0fc30b0a3637a003a0d2c7edc1dd618359d58cc1e211742f8bd1",
+                "sha256:1cbb88b34187bdb841f2599770b7e6ff8e259dc3bb64fc7893acf44998acf5f8",
+                "sha256:232f0b52a5b978288f0bbc282a6c03fe48cd19a04202df44309919c142b3bb9c",
+                "sha256:24bcfa86fd9ce86b73a8368383c39d919c497a06eebb888b6f0c12f13e920b1a",
+                "sha256:25b8f60b5c7da71e64c18888f3067d5b6f1334b9681876b2fb41eea26de881ae",
+                "sha256:2714160a63da18aed9340c70ed514973971ee7e665e6b336917ff4cca81a25b1",
+                "sha256:2ca2cd5264e84b2cafc73f0045437f70c6378c0d7dbcddc9ee3fe192c1e29e5d",
+                "sha256:2cc707fc9aad2592fc686d63ef72dc0031fc98b6fb921d2f5395d9ab84fbc3ef",
+                "sha256:348630edea485f4228233c2f310a598abf8afa5f8c716c02a9698089687b6085",
+                "sha256:40fbfd6b044c9db13aeec1daf5887d322c710d811f944011757526ef6e323fd9",
+                "sha256:46c9c6a1d1190c0b75ec7c0f339088309952b82ae8d67a79ff1319eb4e749b96",
+                "sha256:591506e088901bdc25620c37aec885e82cc896528f28c57e113751e3471fc314",
+                "sha256:5ac71bba1e07eab403b082c4428f868c1c9e26a21041436b4905c4c3d4e49b08",
+                "sha256:5f622f19abda4e934938e24f1d67599249abc201844933a6f01aaa8663094489",
+                "sha256:65bead1ac8c8930cf92a1ccaedcce19a57298547d5d1db5c9d4d068a0675c38b",
+                "sha256:7362a7f829feda10c7265b553455de596b83d1623b3d436b6d3c51c688c57bf6",
+                "sha256:7f2675750c50151f806070ec11258edf4c328340916c53bac0adbc465abd6b1e",
+                "sha256:960d7f42277391e8b1c0b0ae427a214e1b31a1278de6b73f8807b20c2e913bba",
+                "sha256:a50b0888d8a021a3342d36a6086501e30de7d840ab68fca44913e97d14487dc1",
+                "sha256:b7dbc5e8c39ea3ad3db22715f1b5401cd698a621218680c6daf42c2f9d36e205",
+                "sha256:bb3d29df5d07d5399d58a394d0ef50adf303ab4fbf66dfd25b9ef258effcb692",
+                "sha256:c0fff2733f7c2950f58a4fd09b5db257b00c6fec57bf3f68c5bae004d804b407",
+                "sha256:c792d3707a86c01c02607ae74364854220fb3e82735f631cd0a345dea6b4cee5",
+                "sha256:c90bda74e16bcd03861b09b1d37c0a4158feda5d5a036bb2d6e58de6ff65793e",
+                "sha256:cfce79ce41cc1a1dc7fc85bb41eeeb32d34a4cf39a645c717c0550287e30ff06",
+                "sha256:eeafb646f374988c22c8e6da5ab9fb81367ecfe81c70c292623373d2a021b1a1",
+                "sha256:f425f50a6dd807cb9043d15a4fcfba3b5874a54d9587ccbb748899f70dc18c47",
+                "sha256:fcd4459fe35a400b8f416bc57906862693c9f88b66dc925e7f2a933e77f6b18b",
+                "sha256:ff3936dd5feaefb4f91c8c1f50a06c588b5dc69fba4f7d9c79a6617ad80bb7df"
+            ],
+            "version": "==5.0.1"
+        },
         "importlib-metadata": {
             "hashes": [
                 "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45",
@@ -157,6 +193,14 @@
             "index": "pypi",
             "version": "==5.3.2"
         },
+        "pytest-cov": {
+            "hashes": [
+                "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
+                "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
+            ],
+            "index": "pypi",
+            "version": "==2.8.1"
+        },
         "regex": {
             "hashes": [
                 "sha256:032fdcc03406e1a6485ec09b826eac78732943840c4b29e503b789716f051d8d",

+ 30 - 9
README.md

@@ -2,28 +2,36 @@
 automates toontown rewritten's login process
 
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+[![Last Release](https://img.shields.io/pypi/v/tooncher.svg)](https://pypi.org/project/tooncher/#history)
+[![Python Version](https://img.shields.io/pypi/pyversions/tooncher.svg)](https://pypi.org/project/tooncher/)
 
 ## Installation
 
-    $ pip3 install --user --upgrade tooncher
+```sh
+$ pip3 install --user --upgrade tooncher
+```
 
 or:
 
-    $ pip3 install --user --upgrade git+https://github.com/fphammerle/tooncher@master
+```sh
+$ pip3 install --user --upgrade git+https://github.com/fphammerle/tooncher@master
+```
 
 Optional: Install cpulimit to enable use of parameter `--cpu-limit`
 
-    $ sudo apt-get install cpulimit
+```sh
+$ sudo apt-get install cpulimit
+```
 
 Optional: Install xlib and psutil for python to enable extended controls:
 
-```{sh}
+```sh
 $ sudo apt-get install python3-xlib python3-psutil
 ```
 
 or
 
-```{sh}
+```sh
 $ pip3 install --user xlib psutil
 ```
 
@@ -43,7 +51,7 @@ engine_path: '/opt/Toontown Rewritten/TTREngine'
 
 ## Usage
 
-```
+```sh
 $ tooncher [username]
 ```
 
@@ -54,7 +62,7 @@ $ tooncher [username]
 To enable the use of WASD keys for walking,
 launch tooncher with the `--extended-controls` option:
 
-```{sh}
+```sh
 $ tooncher --extended-controls username
 ```
 
@@ -64,7 +72,7 @@ Press `` ` `` again to disable.
 The command line parameter `--extended-controls-toggle` may be used
 to change the toggling key:
 
-```{sh}
+```sh
 $ tooncher --extended-controls --extended-controls-toggle slash username
 ```
 
@@ -72,12 +80,25 @@ Extended controls require the X Window System (X11).
 
 ### Examples
 
-```
+```sh
 $ tooncher toon
 $ tooncher --extended-controls ceo
 $ tooncher --cpu-limit 70 cfo
 ```
 
+### Python Interface
+
+```python
+import pathlib
+import tooncher
+
+tooncher.launch(
+    engine_path=pathlib.Path('/somewhere/toontown-rewritten/TTREngine'),
+    username='toon',
+    password='secret',
+)
+```
+
 ## Extended Controls Default Mappings
 
 ![default mapping](docs/extended-controls/default-mapping.svg)

+ 0 - 125
scripts/tooncher

@@ -1,125 +0,0 @@
-#!/usr/bin/env python3
-
-import os
-import sys
-
-import yaml
-
-import tooncher
-import tooncher.controls
-
-
-def run(username, config_path, engine_path=None, validate_ssl_certs=True,
-        cpu_limit_percent=None, enable_extended_keyboard_controls=False,
-        extended_keyboard_control_toggle_keysym_name=None,
-        engine_window_name=None):
-
-    if os.path.exists(config_path):
-        with open(config_path) as f:
-            config = yaml.safe_load(f.read())
-    else:
-        config = {}
-
-    if engine_path is None:
-        if 'engine_path' in config:
-            engine_path = config['engine_path']
-        else:
-            raise Exception('missing path to toontown engine')
-
-    accounts = config['accounts'] if 'accounts' in config else []
-
-    for account in accounts:
-        if account['username'] == username:
-            tooncher.launch(
-                engine_path=engine_path,
-                username=account['username'],
-                password=account['password'],
-                validate_ssl_certs=validate_ssl_certs,
-                cpu_limit_percent=cpu_limit_percent,
-                enable_extended_keyboard_controls=enable_extended_keyboard_controls,
-                extended_keyboard_control_toggle_keysym_name=extended_keyboard_control_toggle_keysym_name,
-                engine_window_name=engine_window_name,
-            )
-
-
-def _init_argparser():
-
-    import argparse
-    argparser = argparse.ArgumentParser(description=None)
-    argparser.add_argument('username')
-    argparser.add_argument(
-        '--config',
-        '-c',
-        metavar='path',
-        dest='config_path',
-        help='path to config file (default: %(default)s)',
-        default=os.path.join(os.path.expanduser('~'), '.tooncher'),
-    )
-    argparser.add_argument(
-        '--engine-path',
-        '-e',
-        metavar='path',
-        dest='engine_path',
-        default=tooncher.TOONTOWN_ENGINE_DEFAULT_PATH,
-        help='\n'.join([
-            'path to toontown engine.',
-            'this overrides the one specified in config file',
-            '(default: %(default)s)',
-        ]),
-    )
-    argparser.add_argument(
-        '--no-ssl-cert-validation',
-        '-k',
-        dest='validate_ssl_certs',
-        help='do not validate ssl certificates',
-        action='store_false',
-    )
-    argparser.add_argument(
-        '--cpu-limit',
-        dest='cpu_limit_percent',
-        type=int,
-        default=None,
-        help='maximally allowed cpu usage in percent'
-            + ' (requires cpulimit command, default: %(default)s)',
-    )
-    argparser.add_argument(
-        '--extended-controls',
-        dest='enable_extended_keyboard_controls',
-        action='store_true',
-        help='enable extended keyboard controls'
-            + ', e.g. walk with WASD'
-            + ' (requires xlib for python, default: %(default)s)',
-    )
-    argparser.add_argument(
-        '--extended-controls-toggle',
-        metavar='KEYSYM_NAME',
-        dest='extended_keyboard_control_toggle_keysym_name',
-        default=tooncher.controls.EXTENDED_CONTROLS_DEFAULT_TOGGLE_KEYSYM_NAME,
-        help='key to turn extended keyboard controls on / off.'
-            + ' any keysym name may be used'
-            + ' (see XStringToKeysym & X11/keysymdef.h, '
-            + ' default: %(default)s)',
-    )
-    argparser.add_argument(
-        '--change-window-name',
-        metavar='ENGINE_WINDOW_NAME',
-        dest='engine_window_name',
-        default=None,
-        help='Change window name of engine after launch.'
-            + ' This requires Extended Controls to be enabled.'
-            + ' (default: no change)',
-    )
-    return argparser
-
-
-def main(argv):
-
-    argparser = _init_argparser()
-    args = argparser.parse_args(argv)
-
-    run(**vars(args))
-
-    return 0
-
-if __name__ == "__main__":
-    sys.exit(main(sys.argv[1:]))

+ 15 - 5
setup.py

@@ -1,19 +1,29 @@
-import glob
+import pathlib
 
 import setuptools
 
 setuptools.setup(
     name="tooncher",
     use_scm_version=True,
-    packages=["tooncher"],
+    packages=setuptools.find_packages(),
     description="automates toontown rewritten's login process",
+    long_description=pathlib.Path(__file__).parent.joinpath("README.md").read_text(),
+    long_description_content_type="text/markdown",
     author="Fabian Peter Hammerle",
     author_email="fabian.hammerle@gmail.com",
     url="https://github.com/fphammerle/tooncher",
-    download_url="https://github.com/fphammerle/tooncher/tarball/0.4.0",
     keywords=["game", "launcher", "toontown rewritten", "ttr"],
-    classifiers=[],
-    scripts=glob.glob("scripts/*"),
+    classifiers=[
+        "Development Status :: 4 - Beta",
+        "Intended Audience :: End Users/Desktop",
+        "License :: OSI Approved :: MIT License",
+        "Operating System :: MacOS :: MacOS X",
+        "Operating System :: POSIX :: Linux",
+        "Programming Language :: Python :: 3",
+        "Topic :: Games/Entertainment",
+        "Topic :: Utilities",
+    ],
+    entry_points={"console_scripts": ["tooncher = tooncher._cli:main"]},
     install_requires=["pyyaml"],
     extras_require={"extended-controls": ["xlib", "psutil"],},
     setup_requires=["setuptools_scm"],

+ 0 - 27
tests/test_.py

@@ -1,27 +0,0 @@
-# -*- coding: utf-8 -*-
-import pytest
-
-import shutil
-import subprocess
-import tooncher
-
-
-def test_start_engine():
-    p = tooncher.start_engine(
-        engine_path=shutil.which("printenv"),
-        gameserver="gameserver",
-        playcookie="cookie",
-        stdout=subprocess.PIPE,
-        stderr=subprocess.PIPE,
-    )
-    assert isinstance(p, subprocess.Popen)
-    stdout, stderr = p.communicate()
-    assert b"" == stderr
-    env = stdout.strip().split(b"\n")
-    assert b"TTR_GAMESERVER=gameserver" in env
-    assert b"TTR_PLAYCOOKIE=cookie" in env
-
-
-def test_api_request_invasions():
-    resp_data = tooncher.api_request(tooncher.INVASIONS_API_URL)
-    assert "invasions" in resp_data

+ 74 - 0
tests/test_api.py

@@ -0,0 +1,74 @@
+import pathlib
+import shutil
+import subprocess
+import unittest.mock
+
+import tooncher
+
+
+def test_start_engine():
+    process = tooncher.start_engine(
+        engine_path=pathlib.Path(shutil.which("printenv")),
+        gameserver="gameserver",
+        playcookie="cookie",
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    assert isinstance(process, subprocess.Popen)
+    stdout, stderr = process.communicate()
+    assert not stderr
+    env = stdout.strip().split(b"\n")
+    assert b"TTR_GAMESERVER=gameserver" in env
+    assert b"TTR_PLAYCOOKIE=cookie" in env
+
+
+def test_start_engine_mac():
+    app_support_path = "/Users/me/Library/Application Support"
+    with unittest.mock.patch("subprocess.Popen") as popen_mock:
+        with unittest.mock.patch("sys.platform", "darwin"):
+            tooncher.start_engine(
+                engine_path=pathlib.PosixPath(
+                    app_support_path + "/Toontown Rewritten/Toontown Rewritten"
+                ),
+                gameserver="gameserver",
+                playcookie="cookie",
+                check=True,
+            )
+    popen_mock.assert_called_once_with(
+        args=[
+            "/Users/me/Library/Application Support/Toontown Rewritten/Toontown Rewritten"
+        ],
+        check=True,
+        cwd=pathlib.PosixPath(
+            "/Users/me/Library/Application Support/Toontown Rewritten"
+        ),
+        env={
+            "TTR_GAMESERVER": "gameserver",
+            "TTR_PLAYCOOKIE": "cookie",
+            "DYLD_LIBRARY_PATH": app_support_path
+            + "/Toontown Rewritten/Libraries.bundle",
+            "DYLD_FRAMEWORK_PATH": app_support_path + "/Toontown Rewritten/Frameworks",
+        },
+    )
+
+
+def test_start_engine_xorg():
+    with unittest.mock.patch("subprocess.Popen") as popen_mock:
+        with unittest.mock.patch("os.environ", {"XAUTHORITY": "/home/me/.Xauthority"}):
+            with unittest.mock.patch("sys.platform", "linux"):
+                tooncher.start_engine(
+                    engine_path=pathlib.PosixPath("/opt/toontown-rewritter/TTREngine"),
+                    gameserver="gameserver.tld",
+                    playcookie="cookie123",
+                    check=False,
+                )
+    popen_mock.assert_called_once_with(
+        args=["/opt/toontown-rewritter/TTREngine"],
+        check=False,
+        cwd=pathlib.PosixPath("/opt/toontown-rewritter"),
+        env={
+            "TTR_GAMESERVER": "gameserver.tld",
+            "TTR_PLAYCOOKIE": "cookie123",
+            "XAUTHORITY": "/home/me/.Xauthority",
+        },
+    )

+ 155 - 0
tests/test_cli.py

@@ -0,0 +1,155 @@
+import pathlib
+import subprocess
+from unittest.mock import patch
+
+import pytest
+import yaml
+
+# pylint: disable=protected-access
+import tooncher._cli
+
+
+def test_cli_help():
+    proc_info = subprocess.run(
+        ["tooncher", "--help"],
+        check=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    assert b"optional arguments:" in proc_info.stdout
+    assert not proc_info.stderr
+
+
+@patch("tooncher._cli.run")
+@patch("os.environ", {})
+def test_engine_path_arg(run_mock):
+    with patch("sys.argv", ["", "--engine-path", "/opt/ttr/TTREngine", "username"]):
+        tooncher._cli.main()
+    run_mock.assert_called_once()
+    args, kwargs = run_mock.call_args
+    assert not args
+    assert kwargs["engine_path"] == "/opt/ttr/TTREngine"
+
+
+@patch("tooncher._cli.run")
+@patch("os.environ", {"TOONCHER_ENGINE_PATH": "/opt/ttr/TTREnvine"})
+def test_engine_path_env(run_mock):
+    with patch("sys.argv", ["", "username"]):
+        tooncher._cli.main()
+    run_mock.assert_called_once()
+    args, kwargs = run_mock.call_args
+    assert not args
+    assert kwargs["engine_path"] == "/opt/ttr/TTREnvine"
+
+
+@patch("tooncher._cli.run")
+@patch("os.environ", {"TOONCHER_ENGINE_PATH": "/opt/ttr/TTREnvine"})
+def test_engine_path_arg_env(run_mock):
+    with patch("sys.argv", ["", "--engine-path", "/opt/ttr/TTREngine", "username"]):
+        tooncher._cli.main()
+    run_mock.assert_called_once()
+    args, kwargs = run_mock.call_args
+    assert not args
+    assert kwargs["engine_path"] == "/opt/ttr/TTREngine"
+
+
+@patch("tooncher.launch")
+@patch("os.environ", {})
+def test_engine_path_config(launch_mock, tmpdir):
+    config_file = tmpdir.join("config")
+    config_file.write(
+        yaml.safe_dump(
+            {
+                "engine_path": "/opt/conf/TTR",
+                "accounts": [{"username": "someone", "password": "secret"}],
+            }
+        )
+    )
+    with patch("sys.argv", ["", "--config", config_file.strpath, "someone"]):
+        tooncher._cli.main()
+    launch_mock.assert_called_once()
+    args, kwargs = launch_mock.call_args
+    assert not args
+    assert kwargs["engine_path"] == pathlib.Path("/opt/conf/TTR")
+
+
+@patch("tooncher.launch")
+@patch("os.environ", {"TOONCHER_ENGINE_PATH": "/opt/ttr/TTREnvine"})
+def test_engine_path_env_config(launch_mock, tmpdir):
+    config_file = tmpdir.join("config")
+    config_file.write(
+        yaml.safe_dump(
+            {
+                "engine_path": "/opt/conf/TTR",
+                "accounts": [{"username": "someone", "password": "secret"}],
+            }
+        )
+    )
+    with patch("sys.argv", ["", "--config", config_file.strpath, "someone"]):
+        tooncher._cli.main()
+    launch_mock.assert_called_once()
+    args, kwargs = launch_mock.call_args
+    assert not args
+    assert kwargs["engine_path"] == pathlib.Path("/opt/ttr/TTREnvine")
+
+
+@patch("tooncher.launch")
+def test_account(launch_mock, tmpdir):
+    config_file = tmpdir.join("config")
+    config_file.write(
+        yaml.safe_dump(
+            {
+                "engine_path": "/opt/conf/TTR",
+                "accounts": [
+                    {"username": "someone", "password": "secret"},
+                    {"username": "toon", "password": "town"},
+                ],
+            }
+        )
+    )
+    with patch("sys.argv", ["", "--config", config_file.strpath, "toon"]):
+        tooncher._cli.main()
+    launch_mock.assert_called_once_with(
+        engine_path=pathlib.Path("/opt/conf/TTR"),
+        username="toon",
+        password="town",
+        validate_ssl_certs=True,
+        cpu_limit_percent=None,
+    )
+
+
+def test_account_duplicate_username(tmpdir):
+    config_file = tmpdir.join("config")
+    config_file.write(
+        yaml.safe_dump(
+            {
+                "engine_path": "/opt/conf/TTR",
+                "accounts": [
+                    {"username": "someone", "password": "secret"},
+                    {"username": "toon", "password": "town"},
+                    {"username": "toon", "password": "town2"},
+                ],
+            }
+        )
+    )
+    with patch("sys.argv", ["", "--config", config_file.strpath, "toon"]):
+        with pytest.raises(ValueError, match=r"multiple .* username"):
+            tooncher._cli.main()
+
+
+def test_account_unknown_username(tmpdir):
+    config_file = tmpdir.join("config")
+    config_file.write(
+        yaml.safe_dump(
+            {
+                "engine_path": "/opt/conf/TTR",
+                "accounts": [
+                    {"username": "someone", "password": "secret"},
+                    {"username": "toon", "password": "town"},
+                ],
+            }
+        )
+    )
+    with patch("sys.argv", ["", "--config", config_file.strpath, "player"]):
+        with pytest.raises(ValueError, match=r"not found"):
+            tooncher._cli.main()

+ 90 - 129
tooncher/__init__.py

@@ -1,64 +1,53 @@
+import copy
 import datetime
 import json
 import os
+import pathlib
 import ssl
 import subprocess
 import sys
 import traceback
+import typing
 import urllib.parse
 import urllib.request
-import tooncher.controls
-
-"""
-official api documentation:
-https://github.com/ToontownRewritten/api-doc/blob/master/login.md
-https://github.com/ToontownRewritten/api-doc/blob/master/invasions.md
-"""
-
-INVASIONS_API_URL = "https://www.toontownrewritten.com/api/invasions?format=json"
-LOGIN_API_URL = "https://www.toontownrewritten.com/api/login?format=json"
-
-if sys.platform == "darwin":
-    TOONTOWN_LIBRARY_PATH = os.path.join(
-        os.path.expanduser("~"), "Library", "Application Support", "Toontown Rewritten",
-    )
-    TOONTOWN_ENGINE_DEFAULT_PATH = os.path.join(
-        TOONTOWN_LIBRARY_PATH, "Toontown Rewritten",
-    )
-else:
-    TOONTOWN_LIBRARY_PATH = None
-    TOONTOWN_ENGINE_DEFAULT_PATH = None
 
+import tooncher.controls
 
-def start_engine(engine_path, gameserver, playcookie, **kwargs):
-    env = {
-        "TTR_GAMESERVER": gameserver,
-        "TTR_PLAYCOOKIE": playcookie,
-    }
+# official api documentation:
+# https://github.com/ToontownRewritten/api-doc/blob/master/login.md
+# https://github.com/ToontownRewritten/api-doc/blob/master/invasions.md
+
+_LOGIN_API_URL = "https://www.toontownrewritten.com/api/login?format=json"
+
+
+def start_engine(
+    engine_path: pathlib.Path, gameserver: str, playcookie: str, **popen_kwargs
+) -> subprocess.Popen:
+    # without XAUTHORITY:
+    # > :display:x11display(error): Could not open display ":0.0".
+    # > :ToonBase: Default graphics pipe is glxGraphicsPipe (OpenGL).
+    # > :ToonBase(warning): Unable to open 'onscreen' window.
+    # > Traceback (most recent call last):
+    # >   File "<compiled '__voltorbmain__'>", line 0, in <module>
+    # >   [...]
+    # >   File "<compiled 'direct.vlt8f63e471.ShowBase'>", line 0, in vltf05fd21b
+    # > Exception: Could not open window.
+    # optirun sets plenty of env vars
+    env = copy.copy(os.environ)
+    env["TTR_GAMESERVER"] = gameserver
+    env["TTR_PLAYCOOKIE"] = playcookie
+    engine_path = engine_path.resolve()
     if sys.platform == "darwin":
-        env["DYLD_LIBRARY_PATH"] = os.path.join(
-            TOONTOWN_LIBRARY_PATH, "Libraries.bundle",
-        )
-        env["DYLD_FRAMEWORK_PATH"] = os.path.join(TOONTOWN_LIBRARY_PATH, "Frameworks",)
-    elif sys.platform == "linux" and "XAUTHORITY" in os.environ:
-        """
-        Fix for TTREngine reporting:
-        > :display:x11display(error): Could not open display ":0.0".
-        > :ToonBase: Default graphics pipe is glxGraphicsPipe (OpenGL).
-        > :ToonBase(warning): Unable to open 'onscreen' window.
-        > Traceback (most recent call last):
-        >   File "<compiled '__voltorbmain__'>", line 0, in <module>
-        >   [...]
-        >   File "<compiled 'direct.vlt8f63e471.ShowBase'>", line 0, in vltf05fd21b
-        > Exception: Could not open window.
-        """
-        env["XAUTHORITY"] = os.environ["XAUTHORITY"]
+        env["DYLD_LIBRARY_PATH"] = str(engine_path.parent.joinpath("Libraries.bundle"))
+        env["DYLD_FRAMEWORK_PATH"] = str(engine_path.parent.joinpath("Frameworks"))
     return subprocess.Popen(
-        args=[engine_path], cwd=os.path.dirname(engine_path), env=env, **kwargs,
+        args=[str(engine_path)], cwd=engine_path.parent, env=env, **popen_kwargs,
     )
 
 
-def api_request(url, params=None, validate_ssl_cert=True):
+def _api_request(
+    url: str, params: typing.Optional[dict] = None, validate_ssl_cert: bool = True
+):
     resp = urllib.request.urlopen(
         url=url,
         data=urllib.parse.urlencode(params).encode("ascii") if params else None,
@@ -67,18 +56,23 @@ def api_request(url, params=None, validate_ssl_cert=True):
     return json.loads(resp.read().decode("ascii"))
 
 
-class LoginSuccessful:
-    def __init__(self, playcookie, gameserver):
+class _LoginSuccessful:
+    def __init__(self, playcookie: str, gameserver: str):
         self.playcookie = playcookie
         self.gameserver = gameserver
 
 
-class LoginDelayed:
-    def __init__(self, queue_token):
+class _LoginDelayed:
+    def __init__(self, queue_token: str):
         self.queue_token = queue_token
 
 
-def login(username=None, password=None, queue_token=None, validate_ssl_cert=True):
+def _login(
+    username: typing.Optional[str] = None,
+    password: typing.Optional[str] = None,
+    queue_token: typing.Optional[str] = None,
+    validate_ssl_cert: bool = True,
+) -> typing.Union[_LoginSuccessful, _LoginDelayed]:
     if username is not None and queue_token is None:
         assert password is not None
         req_params = {
@@ -91,99 +85,66 @@ def login(username=None, password=None, queue_token=None, validate_ssl_cert=True
         }
     else:
         raise Exception("either specify username or queue token")
-    resp_data = api_request(
-        url=LOGIN_API_URL, params=req_params, validate_ssl_cert=validate_ssl_cert,
+    resp_data = _api_request(
+        url=_LOGIN_API_URL, params=req_params, validate_ssl_cert=validate_ssl_cert,
     )
     if resp_data["success"] == "true":
-        return LoginSuccessful(
+        return _LoginSuccessful(
             playcookie=resp_data["cookie"], gameserver=resp_data["gameserver"],
         )
-    elif resp_data["success"] == "delayed":
-        return LoginDelayed(queue_token=resp_data["queueToken"],)
-    else:
-        raise Exception(repr(resp_data))
+    if resp_data["success"] == "delayed":
+        return _LoginDelayed(queue_token=resp_data["queueToken"],)
+    raise Exception(repr(resp_data))
 
 
 def launch(
-    engine_path,
-    username,
-    password,
-    validate_ssl_certs=True,
-    cpu_limit_percent=None,
+    engine_path: pathlib.Path,
+    username: str,
+    password: str,
+    validate_ssl_certs: bool = True,
+    cpu_limit_percent: typing.Optional[int] = None,
     enable_extended_keyboard_controls=False,
     extended_keyboard_control_toggle_keysym_name=None,
     engine_window_name=None,
-):
+) -> None:
     if engine_window_name and not enable_extended_keyboard_controls:
         raise Exception("Enable Extended Controls to change engine's window name",)
-    result = login(
+    result = _login(
         username=username, password=password, validate_ssl_cert=validate_ssl_certs,
     )
-    if isinstance(result, LoginDelayed):
-        result = login(
+    if isinstance(result, _LoginDelayed):
+        result = _login(
             queue_token=result.queue_token, validate_ssl_cert=validate_ssl_certs,
         )
-    if isinstance(result, LoginSuccessful):
-        p = start_engine(
-            engine_path=engine_path,
-            gameserver=result.gameserver,
-            playcookie=result.playcookie,
+    if not isinstance(result, _LoginSuccessful):
+        raise Exception("unexpected response: {!r}".format(result))
+    process = start_engine(
+        engine_path=engine_path,
+        gameserver=result.gameserver,
+        playcookie=result.playcookie,
+    )
+    if cpu_limit_percent is not None:
+        subprocess.Popen(
+            args=[
+                "cpulimit",
+                "--pid",
+                str(process.pid),
+                "--limit",
+                str(cpu_limit_percent),
+                # '--verbose',
+            ]
         )
-        if cpu_limit_percent is not None:
-            subprocess.Popen(
-                args=[
-                    "cpulimit",
-                    "--pid",
-                    str(p.pid),
-                    "--limit",
-                    str(cpu_limit_percent),
-                    # '--verbose',
-                ]
-            )
-        if enable_extended_keyboard_controls:
-            try:
-                tooncher.controls.ExtendedControls(
-                    primary_engine_pid=p.pid,
-                    primary_engine_window_name=engine_window_name,
-                    toggle_keysym_name=extended_keyboard_control_toggle_keysym_name,
-                ).run()
-            except Exception as e:
-                if isinstance(e, KeyboardInterrupt):
-                    raise e
-                else:
-                    traceback.print_exc()
-        if p.poll() is None:
-            p.wait()
-    else:
-        raise Exception(repr(result))
-
-
-class InvasionProgress:
-    def __init__(self, district, date, cog_type, despawned_number, total_number):
-        self.district = district
-        self.date = date
-        self.cog_type = cog_type
-        self.despawned_number = despawned_number
-        self.total_number = total_number
-
-    @property
-    def remaining_number(self):
-        return self.total_number - self.despawned_number
-
-
-def request_active_invasions(validate_ssl_certs=True):
-    resp_data = api_request(INVASIONS_API_URL)
-    if resp_data["error"] is not None:
-        raise Exception(resp_data["error"])
-    else:
-        invs = {}
-        for district, inv_data in resp_data["invasions"].items():
-            despawned_number, total_number = inv_data["progress"].split("/")
-            invs[district] = InvasionProgress(
-                district=district,
-                date=datetime.datetime.utcfromtimestamp(inv_data["asOf"]),
-                cog_type=inv_data["type"],
-                despawned_number=int(despawned_number),
-                total_number=int(total_number),
-            )
-        return invs
+    if enable_extended_keyboard_controls:
+        try:
+            tooncher.controls.ExtendedControls(
+                primary_engine_pid=process.pid,
+                primary_engine_window_name=engine_window_name,
+                toggle_keysym_name=extended_keyboard_control_toggle_keysym_name,
+            ).run()
+        except Exception as e:
+            if isinstance(e, KeyboardInterrupt):
+                raise e
+            else:
+                traceback.print_exc()
+    if process.poll() is None:
+        process.wait()

+ 146 - 0
tooncher/_cli.py

@@ -0,0 +1,146 @@
+import argparse
+import os
+import pathlib
+import sys
+
+import yaml
+
+import tooncher
+import tooncher.controls
+
+if sys.platform == "darwin":
+    _TOONTOWN_ENGINE_DEFAULT_PATH = os.path.join(
+        os.path.expanduser("~"),
+        "Library",
+        "Application Support",
+        "Toontown Rewritten",
+        "Toontown Rewritten",
+    )
+else:
+    _TOONTOWN_ENGINE_DEFAULT_PATH = None
+
+
+def run(
+    username,
+    config_path,
+    engine_path=None,
+    validate_ssl_certs=True,
+    cpu_limit_percent=None,
+    enable_extended_keyboard_controls=False,
+    extended_keyboard_control_toggle_keysym_name=None,
+    engine_window_name=None,
+):
+    if os.path.exists(config_path):
+        with open(config_path) as config_file:
+            config = yaml.safe_load(config_file.read())
+    else:
+        config = {}
+    if engine_path is None:
+        if "engine_path" in config:
+            engine_path = config["engine_path"]
+        else:
+            engine_path = _TOONTOWN_ENGINE_DEFAULT_PATH
+    if engine_path is None:
+        raise ValueError(
+            "missing path to toontown engine\n"
+            + "pass --engine-path, set $TOONCHER_ENGINE_PATH, or add to config file"
+        )
+    accounts = [a for a in config.get("accounts", []) if a["username"] == username]
+    if not accounts:
+        raise ValueError("username {!r} was not found in config file".format(username))
+    if len(accounts) > 1:
+        raise ValueError(
+            "multiple entries for username {!r} in config file".format(username)
+        )
+    tooncher.launch(
+        engine_path=pathlib.Path(engine_path),
+        username=accounts[0]["username"],
+        password=accounts[0]["password"],
+        validate_ssl_certs=validate_ssl_certs,
+        cpu_limit_percent=cpu_limit_percent,
+        enable_extended_keyboard_controls=enable_extended_keyboard_controls,
+        extended_keyboard_control_toggle_keysym_name=extended_keyboard_control_toggle_keysym_name,
+        engine_window_name=engine_window_name,
+    )
+
+
+class _EnvDefaultArgparser(argparse.ArgumentParser):
+    def add_argument(self, *args, envvar=None, **kwargs):
+        if envvar:
+            envvar_value = os.environ.get(envvar, None)
+            if envvar_value:
+                kwargs["required"] = False
+                kwargs["default"] = envvar_value
+        super().add_argument(*args, **kwargs)
+
+
+def _init_argparser():
+    argparser = _EnvDefaultArgparser(description=None)
+    argparser.add_argument("username")
+    argparser.add_argument(
+        "--config",
+        "-c",
+        metavar="path",
+        dest="config_path",
+        help="path to config file (default: %(default)s)",
+        default=os.path.join(os.path.expanduser("~"), ".tooncher"),
+    )
+    argparser.add_argument(
+        "--engine-path",
+        "-e",
+        metavar="path",
+        dest="engine_path",
+        envvar="TOONCHER_ENGINE_PATH",
+        default=None,
+        help="path to toontown engine (overrides path in config file, "
+        + "may also be set via env var $TOONCHER_ENGINE_PATH)",
+    )
+    argparser.add_argument(
+        "--no-ssl-cert-validation",
+        "-k",
+        dest="validate_ssl_certs",
+        help="do not validate ssl certificates",
+        action="store_false",
+    )
+    argparser.add_argument(
+        "--cpu-limit",
+        dest="cpu_limit_percent",
+        type=int,
+        default=None,
+        help="maximally allowed cpu usage in percent"
+        + " (requires cpulimit command, default: %(default)s)",
+    )
+    argparser.add_argument(
+        "--extended-controls",
+        dest="enable_extended_keyboard_controls",
+        action="store_true",
+        help="enable extended keyboard controls"
+        + ", e.g. walk with WASD"
+        + " (requires xlib for python, default: %(default)s)",
+    )
+    argparser.add_argument(
+        "--extended-controls-toggle",
+        metavar="KEYSYM_NAME",
+        dest="extended_keyboard_control_toggle_keysym_name",
+        default=tooncher.controls.EXTENDED_CONTROLS_DEFAULT_TOGGLE_KEYSYM_NAME,
+        help="key to turn extended keyboard controls on / off."
+        + " any keysym name may be used"
+        + " (see XStringToKeysym & X11/keysymdef.h, "
+        + " default: %(default)s)",
+    )
+    argparser.add_argument(
+        "--change-window-name",
+        metavar="ENGINE_WINDOW_NAME",
+        dest="engine_window_name",
+        default=None,
+        help="Change window name of engine after launch."
+        + " This requires Extended Controls to be enabled."
+        + " (default: no change)",
+    )
+    return argparser
+
+
+def main() -> None:
+    argparser = _init_argparser()
+    args = argparser.parse_args()
+    run(**vars(args))