From 435b2a56e812f05503b489389e4a9080bf279ca0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 20 Feb 2019 21:39:57 -0500 Subject: [PATCH 01/17] Remove stream opening lock on `DataFeed` Fixes to `tractor` that resolve issues with async generators being non-task safe make the need for the mutex lock in `DataFeed.open_stream()` unnecessary. Also, don't bother pushing empty quotes from the publisher; avoids hitting the network when possible. --- piker/brokers/data.py | 118 +++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/piker/brokers/data.py b/piker/brokers/data.py index 340ef73a..115b9ff7 100644 --- a/piker/brokers/data.py +++ b/piker/brokers/data.py @@ -1,5 +1,5 @@ """ -Live data feed machinery +Real-time data feed machinery """ import time from functools import partial @@ -9,7 +9,11 @@ import socket import json from types import ModuleType import typing -from typing import Coroutine, Callable, Dict, List, Any, Tuple +from typing import ( + Coroutine, Callable, Dict, + List, Any, Tuple, AsyncGenerator, + Sequence, +) import contextlib from operator import itemgetter @@ -77,7 +81,7 @@ async def stream_quotes( get_topics: typing.Callable, get_quotes: Coroutine, feed: BrokerFeed, - rate: int = 5, # delay between quote requests + rate: int = 3, # delay between quote requests diff_cached: bool = True, # only deliver "new" quotes to the queue ) -> None: """Stream quotes for a sequence of tickers at the given ``rate`` @@ -135,11 +139,12 @@ async def stream_quotes( # quote). new_quotes.setdefault(quote['key'], []).append(quote) else: - log.info(f"Delivering quotes:\n{quotes}") + # log.debug(f"Delivering quotes:\n{quotes}") for quote in quotes: new_quotes.setdefault(quote['key'], []).append(quote) - yield new_quotes + if new_quotes: + yield new_quotes # latency monitoring req_time = round(postquote_start - prequote_start, 3) @@ -341,65 +346,70 @@ class DataFeed: self._quote_type = None self._symbols = None self.quote_gen = None - self._mutex = trio.StrictFIFOLock() self._symbol_data_cache: Dict[str, Any] = {} - async def open_stream(self, symbols, feed_type, rate=1, test=None): + async def open_stream( + self, + symbols: Sequence[str], + feed_type: str, + rate: int = 1, + diff_cached: bool = True, + test: bool = None, + ) -> (AsyncGenerator, dict): if feed_type not in self._allowed: raise ValueError(f"Only feed types {self._allowed} are supported") self._quote_type = feed_type + try: + if self.quote_gen is not None and symbols != self._symbols: + log.info( + f"Stopping existing subscription for {self._symbols}") + await self.quote_gen.aclose() + self._symbols = symbols - async with self._mutex: - try: - if self.quote_gen is not None and symbols != self._symbols: - log.info( - f"Stopping existing subscription for {self._symbols}") - await self.quote_gen.aclose() - self._symbols = symbols + if feed_type == 'stock' and not ( + all(symbol in self._symbol_data_cache + for symbol in symbols) + ): + # subscribe for tickers (this performs a possible filtering + # where invalid symbols are discarded) + sd = await self.portal.run( + "piker.brokers.data", 'symbol_data', + broker=self.brokermod.name, tickers=symbols) + self._symbol_data_cache.update(sd) - if feed_type == 'stock' and not ( - all(symbol in self._symbol_data_cache - for symbol in symbols) - ): - # subscribe for tickers (this performs a possible filtering - # where invalid symbols are discarded) - sd = await self.portal.run( - "piker.brokers.data", 'symbol_data', - broker=self.brokermod.name, tickers=symbols) - self._symbol_data_cache.update(sd) + if test: + # stream from a local test file + quote_gen = await self.portal.run( + "piker.brokers.data", 'stream_from_file', + filename=test + ) + else: + log.info(f"Starting new stream for {symbols}") + # start live streaming from broker daemon + quote_gen = await self.portal.run( + "piker.brokers.data", + 'start_quote_stream', + broker=self.brokermod.name, + symbols=symbols, + feed_type=feed_type, + diff_cached=diff_cached, + rate=rate, + ) - if test: - # stream from a local test file - quote_gen = await self.portal.run( - "piker.brokers.data", 'stream_from_file', - filename=test - ) - else: - log.info(f"Starting new stream for {symbols}") - # start live streaming from broker daemon - quote_gen = await self.portal.run( - "piker.brokers.data", - 'start_quote_stream', - broker=self.brokermod.name, - symbols=symbols, - feed_type=feed_type, - rate=rate, - ) + # get first quotes response + log.debug(f"Waiting on first quote for {symbols}...") + quotes = {} + quotes = await quote_gen.__anext__() - # get first quotes response - log.debug(f"Waiting on first quote for {symbols}...") - quotes = {} - quotes = await quote_gen.__anext__() - - self.quote_gen = quote_gen - self.first_quotes = quotes - return quote_gen, quotes - except Exception: - if self.quote_gen: - await self.quote_gen.aclose() - self.quote_gen = None - raise + self.quote_gen = quote_gen + self.first_quotes = quotes + return quote_gen, quotes + except Exception: + if self.quote_gen: + await self.quote_gen.aclose() + self.quote_gen = None + raise def format_quotes(self, quotes, symbol_data={}): self._symbol_data_cache.update(symbol_data) From 462c419970ac35cd2086d266e616fc44eab6a717 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 21 Feb 2019 17:24:50 -0500 Subject: [PATCH 02/17] Add basic practice account support --- piker/brokers/questrade.py | 73 ++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/piker/brokers/questrade.py b/piker/brokers/questrade.py index 12d87de8..643962c9 100644 --- a/piker/brokers/questrade.py +++ b/piker/brokers/questrade.py @@ -25,7 +25,8 @@ asks.init('trio') log = get_logger(__name__) -_refresh_token_ep = 'https://login.questrade.com/oauth2/' +_use_practice_account = False +_refresh_token_ep = 'https://{}login.questrade.com/oauth2/' _version = 'v1' # stock queries/sec @@ -102,7 +103,10 @@ class _API: resp = await self._sess.get(path=f'/{path}', params=params) return resproc(resp, log) - async def _new_auth_token(self, refresh_token: str) -> dict: + async def _new_auth_token( + self, + refresh_token: str, + ) -> dict: """Request a new api authorization ``refresh_token``. Gain api access using either a user provided or existing token. @@ -112,18 +116,42 @@ class _API: http://www.questrade.com/api/documentation/security """ resp = await self._sess.get( - _refresh_token_ep + 'token', + self.client._auth_ep + 'token', params={'grant_type': 'refresh_token', 'refresh_token': refresh_token} ) return resproc(resp, log) + async def _revoke_auth_token( + self, + practise: bool = False, + ) -> None: + """Revoke api access for the current token. + """ + token = self.access_data['refresh_token'] + log.debug(f"Revoking token {token}") + resp = await asks.post( + self.client._auth_ep + 'revoke', + headers={'token': token} + ) + return resp + + # accounts end points + async def accounts(self) -> dict: return await self._get('accounts') async def time(self) -> dict: return await self._get('time') + async def balances(self, id: str) -> dict: + return await self._get(f'accounts/{id}/balances') + + async def postions(self, id: str) -> dict: + return await self._get(f'accounts/{id}/positions') + + # market end points + async def markets(self) -> dict: return await self._get('markets') @@ -146,12 +174,6 @@ class _API: async def candles(self, id: str, start: str, end, interval) -> dict: return await self._get(f'markets/candles/{id}', params={}) - async def balances(self, id: str) -> dict: - return await self._get(f'accounts/{id}/balances') - - async def postions(self, id: str) -> dict: - return await self._get(f'accounts/{id}/positions') - async def option_contracts(self, symbol_id: str) -> dict: "Retrieve all option contract API ids with expiry -> strike prices." contracts = await self._get(f'symbols/{symbol_id}/options') @@ -189,10 +211,16 @@ class Client: Provides a high-level api which wraps the underlying endpoint calls. """ - def __init__(self, config: configparser.ConfigParser): + def __init__( + self, + config: configparser.ConfigParser, + ): self._sess = asks.Session() self.api = _API(self) self._conf = config + self._is_practise_account = _use_practice_account + self._auth_ep = _refresh_token_ep.format( + 'practice' if _use_practice_account else '') self.access_data = {} self._reload_config(config) self._symbol_cache: Dict[str, int] = {} @@ -209,17 +237,6 @@ class Client: self._conf = config or get_config(**kwargs) self.access_data = dict(self._conf['questrade']) - async def _revoke_auth_token(self) -> None: - """Revoke api access for the current token. - """ - token = self.access_data['refresh_token'] - log.debug(f"Revoking token {token}") - resp = await asks.post( - _refresh_token_ep + 'revoke', - headers={'token': token} - ) - return resp - def write_config(self): """Save access creds to config file. """ @@ -256,7 +273,7 @@ class Client: if not access_token or ( expires < time.time() ) or force_refresh: - log.info("REFRESHING TOKENS!") + log.info("Refreshing API tokens") log.debug( f"Refreshing access token {access_token} which expired" f" at {expires_stamp}") @@ -304,7 +321,7 @@ class Client: # write to config to disk self.write_config() else: - log.info( + log.debug( f"\nCurrent access token {access_token} expires at" f" {expires_stamp}\n") @@ -525,17 +542,21 @@ def get_config( @asynccontextmanager -async def get_client() -> Client: +async def get_client(**kwargs) -> Client: """Spawn a broker client for making requests to the API service. """ - conf = get_config() + conf = get_config(config_path=kwargs.get('config_path')) log.debug(f"Loaded config:\n{colorize_json(dict(conf['questrade']))}") - client = Client(conf) + client = Client(conf, **kwargs) await client.ensure_access() try: log.debug("Check time to ensure access token is valid") + # XXX: the `time()` end point requires acc_read Oauth access. + # In order to use a client you need at least one key with this + # access enabled in order to do symbol searches and id lookups. await client.api.time() except Exception: + raise # access token is likely no good log.warn(f"Access tokens {client.access_data} seem" f" expired, forcing refresh") From cbb973ae9ded7c01d1c378e529885c80286b53d2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 21 Feb 2019 23:07:44 -0500 Subject: [PATCH 03/17] Drop internal nursery from option chain --- piker/ui/option_chain.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/piker/ui/option_chain.py b/piker/ui/option_chain.py index da981f54..4267ac01 100644 --- a/piker/ui/option_chain.py +++ b/piker/ui/option_chain.py @@ -16,7 +16,6 @@ from kivy.core.window import Window from kivy.uix.label import Label from ..log import get_logger -from ..brokers.core import contracts from ..brokers.data import DataFeed from .pager import PagerView @@ -155,6 +154,8 @@ async def find_local_monitor(): if not portal: log.warn( "No monitor app could be found, no symbol link established..") + else: + log.info(f"Found {portal.channel.uid}") yield portal @@ -172,6 +173,7 @@ class OptionChain(object): ): self.symbol = None self.expiry = None + self.rate = rate self.widgets = widgets self.bidasks = bidasks self._strikes2rows = {} @@ -212,17 +214,14 @@ class OptionChain(object): """Open an internal update task scope required to allow for dynamic real-time operation. """ - self._parent_nursery = nursery - async with trio.open_nursery() as n: - self._nursery = n - # fill out and start updating strike table - n.start_soon( - partial(self._start_displaying, symbol, expiry=expiry) - ) - # listen for undlerlying symbol changes from a local monitor app - n.start_soon(self._rx_symbols) - yield self - n.cancel_scope.cancel() + n = self._nursery = nursery + # fill out and start updating strike table + n.start_soon( + partial(self._start_displaying, symbol, expiry=expiry) + ) + # listen for undlerlying symbol changes from a local monitor app + n.start_soon(self._rx_symbols) + yield self self._nursery = None # make sure we always tear down our existing data feed @@ -346,9 +345,6 @@ class OptionChain(object): self._update_cs.cancel() await trio.sleep(0) - if self._quote_gen: - await self._quote_gen.aclose() - # redraw any symbol specific UI components if self.symbol != symbol or expiry is None: # set window title @@ -359,7 +355,6 @@ class OptionChain(object): # retreive all contracts to populate expiry row all_contracts = await self.feed.call_client( 'get_all_contracts', symbols=[symbol]) - # all_contracts = await contracts(self.feed.brokermod, symbol) if not all_contracts: label = self.no_opts_label @@ -374,7 +369,7 @@ class OptionChain(object): # msgpack... The expiry index is 2, see the ``ContractsKey`` named # tuple in the questrade broker mod. It would normally look # something like: - # expiry = next(iter(all_contracts)).expiry if not expiry else expiry + # exp = next(iter(all_contracts)).expiry if not exp else exp ei = 2 # start streaming soonest contract by default if not provided expiry = next(iter(all_contracts))[ei] if not expiry else expiry @@ -401,6 +396,7 @@ class OptionChain(object): self._quote_gen, first_quotes = await self.feed.open_stream( [(symbol, expiry)], 'option', + rate=self.rate, ) log.debug(f"Got first_quotes for {symbol}:{expiry}") records, displayables = self.feed.format_quotes(first_quotes) @@ -443,7 +439,6 @@ async def new_chain_ui( portal: tractor._portal.Portal, symbol: str, brokermod: types.ModuleType, - nursery: trio._core._run.Nursery, rate: int = 1, ) -> None: """Create and return a new option chain UI. @@ -499,7 +494,6 @@ async def _async_main( portal, symbol, brokermod, - nursery, rate=rate, ) async with chain.open_rt_display(nursery, symbol): From 7ee731faac84537e14f92f803367f6f4d7e987fd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 21 Feb 2019 23:09:19 -0500 Subject: [PATCH 04/17] Use trio memory channels throughout UIs --- piker/ui/monitor.py | 11 ++++++----- piker/ui/tabular.py | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/piker/ui/monitor.py b/piker/ui/monitor.py index fafab986..237f6432 100644 --- a/piker/ui/monitor.py +++ b/piker/ui/monitor.py @@ -148,13 +148,14 @@ async def stream_symbol_selection(): """ widgets = tractor.current_actor().statespace['widgets'] table = widgets['table'] - q = trio.Queue(1) - table._click_queues.append(q) + send_chan, recv_chan = trio.open_memory_channel(0) + table._click_queues.append(send_chan) try: - async for symbol in q: - yield symbol + async with recv_chan: + async for symbol in recv_chan: + yield symbol finally: - table._click_queues.remove(q) + table._click_queues.remove(send_chan) async def _async_main( diff --git a/piker/ui/tabular.py b/piker/ui/tabular.py index 7a9aca2f..bb307a91 100644 --- a/piker/ui/tabular.py +++ b/piker/ui/tabular.py @@ -383,8 +383,8 @@ class Row(HoverBehavior, GridLayout): def on_press(self, value=None): log.info(f"Pressed row for {self._last_record['symbol']}") if self.table and not self.is_header: - for q in self.table._click_queues: - q.put_nowait(self._last_record['symbol']) + for sendchan in self.table._click_queues: + sendchan.send_nowait(self._last_record['symbol']) class TickerTable(GridLayout): @@ -399,7 +399,7 @@ class TickerTable(GridLayout): self._auto_sort = auto_sort self._symbols2index = {} self._sorted = [] - self._click_queues: List[trio.Queue] = [] + self._click_queues: List[trio.abc.SendChannel[str]] = [] def append_row(self, key, row): """Append a `Row` of `Cell` objects to this table. From 1a0427db08544e87f6f42614f1e78b3fc9b00f1f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 21 Feb 2019 23:10:24 -0500 Subject: [PATCH 05/17] Use `DataFeed` api in streaming tests --- tests/test_questrade.py | 98 ++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/tests/test_questrade.py b/tests/test_questrade.py index 1c081935..1cfb60dd 100644 --- a/tests/test_questrade.py +++ b/tests/test_questrade.py @@ -2,14 +2,16 @@ Questrade broker testing """ import time -import logging import trio from trio.testing import trio_test -import tractor -from tractor.testing import tractor_test from piker.brokers import questrade as qt import pytest +import tractor +from tractor.testing import tractor_test + +from piker.brokers import get_brokermod +from piker.brokers.data import DataFeed log = tractor.get_logger('tests') @@ -129,6 +131,7 @@ async def test_concurrent_tokens_refresh(us_symbols, loglevel): for tries in range(30): log.info(f"{tries}: GETTING QUOTES!") quotes = await quoter(us_symbols) + assert quotes await trio.sleep(0.1) async def intermittently_refresh_tokens(client): @@ -197,7 +200,10 @@ async def test_option_chain(tmx_symbols): quotes = await client.option_chains(contracts) # verify contents match what we expect for quote in quotes: - assert quote['underlying'] in tmx_symbols + underlying = quote['underlying'] + # XXX: sometimes it's '' for old expiries? + if underlying: + assert underlying in tmx_symbols for key in _ex_quotes['option']: quote.pop(key) assert not quote @@ -230,43 +236,34 @@ async def test_option_quote_latency(tmx_symbols): await trio.sleep(0.1) -async def stream_option_chain(portal, symbols): +async def stream_option_chain(feed, symbols): """Start up an option quote stream. ``symbols`` arg is ignored here. """ symbol = symbols[0] - async with qt.get_client() as client: - contracts = await client.get_all_contracts([symbol]) + contracts = await feed.call_client( + 'get_all_contracts', symbols=[symbol]) contractkey = next(iter(contracts)) subs_keys = list( - map(lambda item: (item.symbol, item.expiry), contracts)) + # map(lambda item: (item.symbol, item.expiry), contracts)) + map(lambda item: (item[0], item[2]), contracts)) sub = subs_keys[0] - agen = await portal.run( - 'piker.brokers.data', - 'start_quote_stream', - broker='questrade', - symbols=[sub], - feed_type='option', - rate=3, - diff_cached=False, - ) # latency arithmetic loops = 8 period = 1/3. # 3 rps - timeout = loops / period + timeout = float('inf') #loops / period try: - # wait on the data streamer to actually start - # delivering - await agen.__anext__() - # it'd sure be nice to have an asyncitertools here... with trio.fail_after(timeout): + stream, first_quotes = await feed.open_stream( + [sub], 'option', rate=4, diff_cached=False, + ) count = 0 - async for quotes in agen: + async for quotes in stream: # print(f'got quotes for {quotes.keys()}') # we should receive all calls and puts assert len(quotes) == len(contracts[contractkey]) * 2 @@ -282,21 +279,12 @@ async def stream_option_chain(portal, symbols): # switch the subscription and make sure # stream is still working sub = subs_keys[1] - await agen.aclose() - agen = await portal.run( - 'piker.brokers.data', - 'start_quote_stream', - broker='questrade', - symbols=[sub], - feed_type='option', - rate=4, - diff_cached=False, - ) - - await agen.__anext__() with trio.fail_after(timeout): + stream, first_quotes = await feed.open_stream( + [sub], 'option', rate=4, diff_cached=False, + ) count = 0 - async for quotes in agen: + async for quotes in stream: for symbol, quote in quotes.items(): assert quote['key'] == sub count += 1 @@ -304,29 +292,32 @@ async def stream_option_chain(portal, symbols): break finally: # unsub - await agen.aclose() + await stream.aclose() -async def stream_stocks(portal, symbols): +async def stream_stocks(feed, symbols): """Start up a stock quote stream. """ - agen = await portal.run( - 'piker.brokers.data', - 'start_quote_stream', - broker='questrade', - symbols=symbols, - diff_cached=False, - ) + stream, first_quotes = await feed.open_stream( + symbols, 'stock', rate=3, diff_cached=False) + # latency arithmetic + loops = 8 + period = 1/3. # 3 rps + timeout = loops / period + try: # it'd sure be nice to have an asyncitertools here... - async for quotes in agen: + count = 0 + async for quotes in stream: assert quotes for key in quotes: assert key in symbols - break + count += 1 + if count == loops: + break finally: # unsub - await agen.aclose() + await stream.aclose() @pytest.mark.parametrize( @@ -339,8 +330,10 @@ async def stream_stocks(portal, symbols): (stream_option_chain, stream_option_chain), ], ids=[ - 'stocks', 'options', - 'stocks_and_options', 'stocks_and_stocks', + 'stocks', + 'options', + 'stocks_and_options', + 'stocks_and_stocks', 'options_and_options', ], ) @@ -348,6 +341,7 @@ async def stream_stocks(portal, symbols): async def test_quote_streaming(tmx_symbols, loglevel, stream_what): """Set up option streaming using the broker daemon. """ + brokermod = get_brokermod('questrade') async with tractor.find_actor('brokerd') as portal: async with tractor.open_nursery() as nursery: # only one per host address, spawns an actor if None @@ -360,6 +354,8 @@ async def test_quote_streaming(tmx_symbols, loglevel, stream_what): 'piker.brokers.core' ], ) + feed = DataFeed(portal, brokermod) + if len(stream_what) > 1: # stream disparate symbol sets per task first, *tail = tmx_symbols @@ -369,7 +365,7 @@ async def test_quote_streaming(tmx_symbols, loglevel, stream_what): async with trio.open_nursery() as n: for syms, func in zip(symbols, stream_what): - n.start_soon(func, portal, syms) + n.start_soon(func, feed, syms) # stop all spawned subactors await nursery.cancel() From bc518b992d054a5caf9c3931fc863de1afa971e6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 23 Feb 2019 00:01:04 -0500 Subject: [PATCH 06/17] Deps bump --- Pipfile.lock | 370 ++++++++++++++++++++++++++------------------------- 1 file changed, 186 insertions(+), 184 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 72cecc49..0ee3bb37 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -16,9 +16,9 @@ "default": { "asks": { "hashes": [ - "sha256:1679e5bd1dfa6c5d2220bdf2b8921c9c0d063d08370a7c66b9e167113681406f" + "sha256:d67aecaa02d0c67fa761dfdb23854391e6996c6045fb9385b1f508b0956a190d" ], - "version": "==2.2.0" + "version": "==2.2.1" }, "async-generator": { "hashes": [ @@ -50,37 +50,37 @@ }, "cython": { "hashes": [ - "sha256:1327655db47beb665961d3dc0365e20c9e8e80c234513ab2c7d06ec0dd9d63eb", - "sha256:142400f13102403f43576bb92d808a668e29deda5625388cfa39fe0bcf37b3d1", - "sha256:1b4204715141281a631337378f0c15fe660b35e1b6888ca05f1f3f49df3b97d5", - "sha256:23aabaaf8887e6db99df2145de6742f8c92830134735778bf2ae26338f2b406f", - "sha256:2a724c6f21fdf4e3c1e8c5c862ff87f5420fdaecf53a5a0417915e483d90217f", - "sha256:2c9c8c1c6e8bd3587e5f5db6f865a42195ff2dedcaf5cdb63fdea10c98bd6246", - "sha256:3a1be38b774423605189d60652b3d8a324fc81d213f96569720c8093784245ab", - "sha256:46be5297a76513e4d5d6e746737d4866a762cfe457e57d7c54baa7ef8fea7e9a", - "sha256:48dc2ea4c4d3f34ddcad5bc71b1f1cf49830f868832d3e5df803c811e7395b6e", - "sha256:53f33e04d2ed078ac02841741bcd536b546e1f416608084468ab30a87638a466", - "sha256:57b10588618ca19a4cc870f381aa8805bc5fe0c62d19d7f940232ff8a373887c", - "sha256:6001038341b52301450bb9c62e5d5da825788944572679277e137ffb3596e718", - "sha256:70bef52e735607060f327d729be35c820d9018d260a875e4f98b20ba8c4fff96", - "sha256:7d0f76b251699be8f1f1064dcb12d4b3b2b676ce15ff30c104e0c2091a015142", - "sha256:9440b64c1569c26a184b7c778bb221cf9987c5c8486d32cda02302c66ea78980", - "sha256:956cc97eac6f9d3b16e3b2d2a94c5586af3403ba97945e9d88a4a0f029899646", - "sha256:ae430ad8cce937e07ea566d1d7899eef1fedc8ec512b4d5fa37ebf2c1f879936", - "sha256:bdb575149881978d62167dd8427402a5872a79bd83e9d51219680670e9f80b40", - "sha256:c0ffcddd3dbdf22aae3980931112cc8b2732315a6273988f3205cf5dacf36f45", - "sha256:c133e2efc57426974366ac74f2ef0f1171b860301ac27f72316deacff4ccdc17", - "sha256:c6e9521d0b77eb1da89e8264eb98c8f5cda7c49a49b8128acfd35f0ca50e56d0", - "sha256:c7cac0220ecb733024e8acfcfb6b593a007185690f2ea470d2392b72510b7187", - "sha256:d53483820ac28f2be2ff13eedd56c0f36a4c583727b551d3d468023556e2336a", - "sha256:d60210784186d61e0ec808d5dbee5d661c7457a57f93cb5fdc456394607ce98c", - "sha256:d687fb1cd9df28c1515666174c62e54bd894a6a6d0862f89705063cd47739f83", - "sha256:d926764d9c768a48b0a16a91696aaa25498057e060934f968fa4c5629b942d85", - "sha256:d94a2f4ad74732f58d1c771fc5d90a62c4fe4c98d0adfecbc76cd0d8d14bf044", - "sha256:def76a546eeec059666f5f4117dfdf9c78e50fa1f95bdd23b04618c7adf845cd" + "sha256:0154d3eead9432dfbef489fecf3a9d9202da0ab4966b796c319c4a3048ff2c03", + "sha256:0355e23994919a6abfce3b9493062f69317f2057560bde694493fa18306b7824", + "sha256:06c0c1332ce36bb6feb6c3590cd72c0b4fa59b34202b1975d484319595e2a548", + "sha256:0970fc905136b520a7595e1d43ff465a8ab24103ac54da801f9bb25be940bb5b", + "sha256:1242351548eeb2c99ca2958fa2eebae08fc361f30d56588ff4f28cdb63a440c1", + "sha256:17a573b551aa34878eba7e0b34a774b18e4b2b35943b2e7d2ae0a31ac5446e39", + "sha256:28025cd1d36df61257646d97325046ee894118e267a49d19fd321fbca413c3df", + "sha256:37d1d560d49985b87629785cf9971add6dd621fc9db1505a5811dcb0feb34a94", + "sha256:3f6ed611cf01e7bbd852bb4f77bea05f0fcca0556926aa0de21a20f719c4abc5", + "sha256:5254de3aecc883d89243f37da74ceff70d9bf459b94ad816f889c794a51a3e76", + "sha256:54484d6b3c102c1e52ebb5dbcee4b7b42efae96ac3d1a2e1d640acab8d7c6fbe", + "sha256:6760738fab5d44e3615fb4c3a12dd5b766850e79dc1bd2ecb4c1df361871f1c3", + "sha256:69c3cd2fe8c2db18a2042aaeb8b3bb0a9ea214c1612c8431fab0acb5ed434b07", + "sha256:73d3e28f9fb445bf67cc753c826e63ce9c3308d62d7e642dfb8cc3556f3ac685", + "sha256:74763f2ac133aabb1a8260ff00303571b91b7c866e0bbcd05159dc72a67f9911", + "sha256:764049a11173b2039674879b1be0d73e2288af4fc1ad8177aa99cdc0de335b31", + "sha256:9d5290d749099a8e446422adfb0aa2142c711284800fb1eb70f595101e32cbf1", + "sha256:a12a83d72aa1298236b63c1d5e95de6230c634cc9c3eb06be51c67f88ccedd92", + "sha256:aa83ce29f04c3d83d51863819281be8bf35d22ae1b8fba9a32cd8a84cd471998", + "sha256:ab3d291304c4e4160276533d3e4e36380ba18dacf2c8d6573980f2ef168f3afb", + "sha256:b1b4ffcb39e77e29862e23d3a25a7c307cac85c8d0654d51547059c053060fc5", + "sha256:c0ef97e126831ec8ae616c5a4d9b321b6e792cf48a1bf473fd6555226c91839f", + "sha256:c15c3fe45855d985922c0f74f8c282b126b3458d5662c4875ae0d088d12b7c3f", + "sha256:cdafe6f7f7dd32ce79b9d5dade7045de7c89d747bce4804f41be84027dc23312", + "sha256:d4d9a9531d3f5990f2f043288359c83527ab927ef4ad9c55a831166d68a53baa", + "sha256:f5722b4eb8052405c314dfae8a1a6e27ee493d051354c53f1ceb8f4e1fd3f075", + "sha256:f581171b9c3b4d5048ce64634b210bfccec06ef3a7422f1807a2a8de31a3c075", + "sha256:f8971da715deda1670e2383185c0c2a1ea819fb17221953f4d0c20d0f14ef24d" ], "index": "pypi", - "version": "==0.29.3" + "version": "==0.29.5" }, "e1839a8": { "editable": true, @@ -135,37 +135,37 @@ }, "multio": { "hashes": [ - "sha256:e8bce12aa8d2e076d96f4c4b6bfb70c01e0e0af9892f9ffc4ec868854e1b877e" + "sha256:fdcd9bd48d053da9f44b1ca56c2f7cf0902d4eb76b41cf16b684166d7180f79f" ], - "version": "==0.2.4" + "version": "==0.2.5" }, "numpy": { "hashes": [ - "sha256:00a458d6821b1e87be873f2126d5646b901047a7480e8ae9773ecf214f0e19f3", - "sha256:0470c5dc32212a08ebc2405f32e8ceb9a5b1c8ac61a2daf9835ec0856a220495", - "sha256:24a9c287a4a1c427c2d45bf7c4fc6180c52a08fa0990d4c94e4c86a9b1e23ba5", - "sha256:25600e8901012180a1b7cd1ac3e27e7793586ecd432383191929ac2edf37ff5d", - "sha256:2d279bd99329e72c30937bdef82b6dc7779c7607c5a379bab1bf76be1f4c1422", - "sha256:32af2bcf4bb7631dac19736a6e092ec9715e770dcaa1f85fcd99dec5040b2a4d", - "sha256:3e90a9fce378114b6c2fc01fff7423300515c7b54b7cc71b02a22bc0bd7dfdd8", - "sha256:5774d49516c37fd3fc1f232e033d2b152f3323ca4c7bfefd7277e4c67f3c08b4", - "sha256:64ff21aac30d40c20ba994c94a08d439b8ced3b9c704af897e9e4ba09d10e62c", - "sha256:803b2af862dcad6c11231ea3cd1015d1293efd6c87088be33d713a9b23e9e419", - "sha256:95c830b09626508f7808ce7f1344fb98068e63143e6050e5dc3063142fc60007", - "sha256:96e49a0c82b4e3130093002f625545104037c2d25866fa2e0c90d6e54f5a1fbc", - "sha256:a1dd8221f0e69038748f47b8bb3248d0b9ecdf13fe837440951c3d5ff72639bb", - "sha256:a80ecac5664f420556a725a5646f2d1c60a7c0489d68a38b5056393e949e27ac", - "sha256:b19a47ff1bd2fca0cacdfa830c967746764c32dca6a0c0328d9c893f4bfe2f6b", - "sha256:be43df2c563e264b38e3318574d80fc8f365df3fb745270934d2dbe54e006f41", - "sha256:c40cb17188f6ae3c5b6efc6f0fd43a7ddd219b7807fe179e71027849a9b91afc", - "sha256:c6251e0f0ecac53ba2b99d9f0cc16fa9021914a78869c38213c436ba343641f0", - "sha256:cb189bd98b2e7ac02df389b6212846ab20661f4bafe16b5a70a6f1728c1cc7cb", - "sha256:ef4ae41add536cb825d8aa029c15ef510aead06ea5b68daea64f0b9ecbff17db", - "sha256:f00a2c21f60284e024bba351875f3501c6d5817d64997a0afe4f4355161a8889", - "sha256:f1232f98a6bbd6d1678249f94028bccc541bbc306aa5c4e1471a881b0e5a3409", - "sha256:fea682f6ddc09517df0e6d5caad9613c6d91a42232aeb082df67e4d205de19cc" + "sha256:0cdbbaa30ae69281b18dd995d3079c4e552ad6d5426977f66b9a2a95f11f552a", + "sha256:2b0cca1049bd39d1879fa4d598624cafe82d35529c72de1b3d528d68031cdd95", + "sha256:31d3fe5b673e99d33d70cfee2ea8fe8dccd60f265c3ed990873a88647e3dd288", + "sha256:34dd4922aab246c39bf5df03ca653d6265e65971deca6784c956bf356bca6197", + "sha256:384e2dfa03da7c8d54f8f934f61b6a5e4e1ebb56a65b287567629d6c14578003", + "sha256:392e2ea22b41a22c0289a88053204b616181288162ba78e6823e1760309d5277", + "sha256:4341a39fc085f31a583be505eabf00e17c619b469fef78dc7e8241385bfddaa4", + "sha256:45080f065dcaa573ebecbfe13cdd86e8c0a68c4e999aa06bd365374ea7137706", + "sha256:485cb1eb4c9962f4cd042fed9424482ec1d83fee5dc2ef3f2552ac47852cb259", + "sha256:575cefd28d3e0da85b0864506ae26b06483ee4a906e308be5a7ad11083f9d757", + "sha256:62784b35df7de7ca4d0d81c5b6af5983f48c5cdef32fc3635b445674e56e3266", + "sha256:69c152f7c11bf3b4fc11bc4cc62eb0334371c0db6844ebace43b7c815b602805", + "sha256:6ccfdcefd287f252cf1ea7a3f1656070da330c4a5658e43ad223269165cdf977", + "sha256:7298fbd73c0b3eff1d53dc9b9bdb7add8797bb55eeee38c8ccd7906755ba28af", + "sha256:79463d918d1bf3aeb9186e3df17ddb0baca443f41371df422f99ee94f4f2bbfe", + "sha256:8bbee788d82c0ac656536de70e817af09b7694f5326b0ef08e5c1014fcb96bb3", + "sha256:a863957192855c4c57f60a75a1ac06ce5362ad18506d362dd807e194b4baf3ce", + "sha256:ae602ba425fb2b074e16d125cdce4f0194903da935b2e7fe284ebecca6d92e76", + "sha256:b13faa258b20fa66d29011f99fdf498641ca74a0a6d9266bc27d83c70fea4a6a", + "sha256:c2c39d69266621dd7464e2bb740d6eb5abc64ddc339cc97aa669f3bb4d75c103", + "sha256:e9c88f173d31909d881a60f08a8494e63f1aff2a4052476b24d4f50e82c47e24", + "sha256:f1a29267ac29fff0913de0f11f3a9edfcd3f39595f467026c29376fad243ebe3", + "sha256:f69dde0c5a137d887676a8129373e44366055cf19d1b434e853310c7a1e68f93" ], - "version": "==1.16.0" + "version": "==1.16.1" }, "outcome": { "hashes": [ @@ -176,35 +176,35 @@ }, "pandas": { "hashes": [ - "sha256:02d34a55e85819a7eab096f391f8dcc237876e8b3cdaf1fba964f5fb59af9acf", - "sha256:0dbcf78e68f619840184ce661c68c1760de403b0f69d81905d6b9a699d1861d6", - "sha256:174c3974da26fd778ac8537d74efb17d4cef59e6b3e81e3c59690f39a6f6b73d", - "sha256:3a8ab5c350131ba273d3f8eb430343304d6c2138a61d34e4a11ebd75f8bf3e7e", - "sha256:560074ce9ff95409b233c0a8d143a2546a2d71d636d583172252dc0021fdb11b", - "sha256:5bded8cb431705609dbd9048114f1d6d59bef2f1ca95a8c58bd649442c9dc16c", - "sha256:8a8748684787792f3a643a7e0530c3024301f3e5799a199a5c2c526c07f712ba", - "sha256:8c7e43c4b7920fc02ce7743b976aca15bd45293ed298d84793307bc9799df3f6", - "sha256:9bd9ef3e183b7b1ce90b7ab5e8672907cd73dc36f036fc6714f0e7a5f9852da0", - "sha256:d3f27e276c8557c15c19c5c9a414e77b893d39fce6e6e40e5c46fcf5eeffe028", - "sha256:d40b82a4aee4ca968348e41bf6588ed9cadd171c7da8b671ed31d3fd967de703", - "sha256:d8cf054a099ff694a0e75386471bdde098efe7c350548ec6b899f169bef1a859", - "sha256:dd9f4843aa59f09698679b64064f11f51d60e45358ab45299de4dcff90524be3", - "sha256:e6f9f5ad4e73f5eecaa66e9c9d30ff8661c400190a6079ee170e37a466457e31", - "sha256:e9989e17f203900b2c7add53fa17d6686e66282598359b43fb12260ae8bf7eba", - "sha256:eadc9d19b25420e1ae77f0a11b779d4e71f47c3aa1953c218e8fe812d1f5341e", - "sha256:ecb630a99b0ab6c178b5c2988ca8c5b98f6ec2fd9e172c2873a5df44b261310f", - "sha256:f8eb9308bd64abf71dda77b823913696cd85c4f36c026acee0a64d8834a09b43", - "sha256:fe71a037ce866d9fb717fd3a792d46c744433179bf3f25da48af8f46cee20c3e", - "sha256:ff0d83306bfda4639fac2a4f8df2c51eb2bbdda540a74490703e8a6b413a37eb" + "sha256:02c830f951f3dc8c3164e2639a8961881390f7492f71a7835c2330f54539ad57", + "sha256:179015834c72a577486337394493cc2969feee9a04a2ea09f50c724e4b52ab42", + "sha256:3894960d43c64cfea5142ac783b101362f5008ee92e962392156a3f8d1558995", + "sha256:435821cb2501eabbcee7e83614bd710940dc0cf28b5afbc4bdb816c31cec71af", + "sha256:8294dea9aa1811f93558702856e3b68dd1dfd7e9dbc8e0865918a07ee0f21c2c", + "sha256:844e745ab27a9a01c86925fe776f9d2e09575e65f0bf8eba5090edddd655dffc", + "sha256:a08d49f5fa2a2243262fe5581cb89f6c0c7cc525b8d6411719ab9400a9dc4a82", + "sha256:a435c251246075337eb9fdc4160fd15c8a87cc0679d8d61fb5255d8d5a12f044", + "sha256:a799f03c0ec6d8687f425d7d6c075e8055a9a808f1ba87604d91f20507631d8d", + "sha256:aea72ce5b3a016b578cc05c04a2f68d9cafacf5d784b6fe832e66381cb62c719", + "sha256:c145e94c6da2af7eaf1fd827293ac1090a61a9b80150bebe99f8966a02378db9", + "sha256:c8a7b470c88c779301b73b23cabdbbd94b83b93040b2ccffa409e06df23831c0", + "sha256:c9e31b36abbd7b94c547d9047f13e1546e3ba967044cf4f9718575fcb7b81bb6", + "sha256:d960b7a03c33c328c723cfc2f8902a6291645f4efa0a5c1d4c5fa008cdc1ea77", + "sha256:da21fae4c173781b012217c9444f13c67449957a4d45184a9718268732c09564", + "sha256:db26c0fea0bd7d33c356da98bafd2c0dfb8f338e45e2824ff8f4f3e61b5c5f25", + "sha256:dc296c3f16ec620cfb4daf0f672e3c90f3920ece8261b2760cd0ebd9cd4daa55", + "sha256:e8da67cb2e9333ec30d53cfb96e27a4865d1648688e5471699070d35d8ab38cf", + "sha256:fb4f047a63f91f22aade4438aaf790400b96644e802daab4293e9b799802f93f", + "sha256:fef9939176cba0c2526ebeefffb8b9807543dc0954877b7226f751ec1294a869" ], - "version": "==0.24.0" + "version": "==0.24.1" }, "pdbpp": { "hashes": [ - "sha256:535085916fcfb768690ba0aeab2967c2a2163a0a60e5b703776846873e171399" + "sha256:438bb2c885e40e9dcf649d9b598e4fe30fd1e3558c89a6ad3f447a9839a04e9f" ], "index": "pypi", - "version": "==0.9.3" + "version": "==0.9.6" }, "pygments": { "hashes": [ @@ -215,10 +215,10 @@ }, "python-dateutil": { "hashes": [ - "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", - "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" + "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", + "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" ], - "version": "==2.7.5" + "version": "==2.8.0" }, "pytz": { "hashes": [ @@ -250,14 +250,15 @@ }, "tractor": { "git": "git://github.com/tgoodlet/tractor.git", - "ref": "977eaedb0bd4b235a5ac07da318f4c1d3be3749a" + "ref": "a927966170ea092be003b72029ca5d432c5e6239" }, "trio": { "hashes": [ - "sha256:d323cc15f6406d15954af91e5e34af2001cc24163fdde29e3f88a227a1b53ab0" + "sha256:3796774aedbf5be581c68f98c79b565654876de6e9a01c6a95e3ec6cd4e4b4c3", + "sha256:b0c03d312c300a947e54e204be88255992434e824374b7d3cc886876dab9a542" ], "index": "pypi", - "version": "==0.10.0" + "version": "==0.11.0" }, "wmctrl": { "hashes": [ @@ -269,9 +270,9 @@ "develop": { "asks": { "hashes": [ - "sha256:1679e5bd1dfa6c5d2220bdf2b8921c9c0d063d08370a7c66b9e167113681406f" + "sha256:d67aecaa02d0c67fa761dfdb23854391e6996c6045fb9385b1f508b0956a190d" ], - "version": "==2.2.0" + "version": "==2.2.1" }, "async-generator": { "hashes": [ @@ -282,10 +283,10 @@ }, "atomicwrites": { "hashes": [ - "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", - "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" ], - "version": "==1.2.1" + "version": "==1.3.0" }, "attrs": { "hashes": [ @@ -310,37 +311,37 @@ }, "cython": { "hashes": [ - "sha256:1327655db47beb665961d3dc0365e20c9e8e80c234513ab2c7d06ec0dd9d63eb", - "sha256:142400f13102403f43576bb92d808a668e29deda5625388cfa39fe0bcf37b3d1", - "sha256:1b4204715141281a631337378f0c15fe660b35e1b6888ca05f1f3f49df3b97d5", - "sha256:23aabaaf8887e6db99df2145de6742f8c92830134735778bf2ae26338f2b406f", - "sha256:2a724c6f21fdf4e3c1e8c5c862ff87f5420fdaecf53a5a0417915e483d90217f", - "sha256:2c9c8c1c6e8bd3587e5f5db6f865a42195ff2dedcaf5cdb63fdea10c98bd6246", - "sha256:3a1be38b774423605189d60652b3d8a324fc81d213f96569720c8093784245ab", - "sha256:46be5297a76513e4d5d6e746737d4866a762cfe457e57d7c54baa7ef8fea7e9a", - "sha256:48dc2ea4c4d3f34ddcad5bc71b1f1cf49830f868832d3e5df803c811e7395b6e", - "sha256:53f33e04d2ed078ac02841741bcd536b546e1f416608084468ab30a87638a466", - "sha256:57b10588618ca19a4cc870f381aa8805bc5fe0c62d19d7f940232ff8a373887c", - "sha256:6001038341b52301450bb9c62e5d5da825788944572679277e137ffb3596e718", - "sha256:70bef52e735607060f327d729be35c820d9018d260a875e4f98b20ba8c4fff96", - "sha256:7d0f76b251699be8f1f1064dcb12d4b3b2b676ce15ff30c104e0c2091a015142", - "sha256:9440b64c1569c26a184b7c778bb221cf9987c5c8486d32cda02302c66ea78980", - "sha256:956cc97eac6f9d3b16e3b2d2a94c5586af3403ba97945e9d88a4a0f029899646", - "sha256:ae430ad8cce937e07ea566d1d7899eef1fedc8ec512b4d5fa37ebf2c1f879936", - "sha256:bdb575149881978d62167dd8427402a5872a79bd83e9d51219680670e9f80b40", - "sha256:c0ffcddd3dbdf22aae3980931112cc8b2732315a6273988f3205cf5dacf36f45", - "sha256:c133e2efc57426974366ac74f2ef0f1171b860301ac27f72316deacff4ccdc17", - "sha256:c6e9521d0b77eb1da89e8264eb98c8f5cda7c49a49b8128acfd35f0ca50e56d0", - "sha256:c7cac0220ecb733024e8acfcfb6b593a007185690f2ea470d2392b72510b7187", - "sha256:d53483820ac28f2be2ff13eedd56c0f36a4c583727b551d3d468023556e2336a", - "sha256:d60210784186d61e0ec808d5dbee5d661c7457a57f93cb5fdc456394607ce98c", - "sha256:d687fb1cd9df28c1515666174c62e54bd894a6a6d0862f89705063cd47739f83", - "sha256:d926764d9c768a48b0a16a91696aaa25498057e060934f968fa4c5629b942d85", - "sha256:d94a2f4ad74732f58d1c771fc5d90a62c4fe4c98d0adfecbc76cd0d8d14bf044", - "sha256:def76a546eeec059666f5f4117dfdf9c78e50fa1f95bdd23b04618c7adf845cd" + "sha256:0154d3eead9432dfbef489fecf3a9d9202da0ab4966b796c319c4a3048ff2c03", + "sha256:0355e23994919a6abfce3b9493062f69317f2057560bde694493fa18306b7824", + "sha256:06c0c1332ce36bb6feb6c3590cd72c0b4fa59b34202b1975d484319595e2a548", + "sha256:0970fc905136b520a7595e1d43ff465a8ab24103ac54da801f9bb25be940bb5b", + "sha256:1242351548eeb2c99ca2958fa2eebae08fc361f30d56588ff4f28cdb63a440c1", + "sha256:17a573b551aa34878eba7e0b34a774b18e4b2b35943b2e7d2ae0a31ac5446e39", + "sha256:28025cd1d36df61257646d97325046ee894118e267a49d19fd321fbca413c3df", + "sha256:37d1d560d49985b87629785cf9971add6dd621fc9db1505a5811dcb0feb34a94", + "sha256:3f6ed611cf01e7bbd852bb4f77bea05f0fcca0556926aa0de21a20f719c4abc5", + "sha256:5254de3aecc883d89243f37da74ceff70d9bf459b94ad816f889c794a51a3e76", + "sha256:54484d6b3c102c1e52ebb5dbcee4b7b42efae96ac3d1a2e1d640acab8d7c6fbe", + "sha256:6760738fab5d44e3615fb4c3a12dd5b766850e79dc1bd2ecb4c1df361871f1c3", + "sha256:69c3cd2fe8c2db18a2042aaeb8b3bb0a9ea214c1612c8431fab0acb5ed434b07", + "sha256:73d3e28f9fb445bf67cc753c826e63ce9c3308d62d7e642dfb8cc3556f3ac685", + "sha256:74763f2ac133aabb1a8260ff00303571b91b7c866e0bbcd05159dc72a67f9911", + "sha256:764049a11173b2039674879b1be0d73e2288af4fc1ad8177aa99cdc0de335b31", + "sha256:9d5290d749099a8e446422adfb0aa2142c711284800fb1eb70f595101e32cbf1", + "sha256:a12a83d72aa1298236b63c1d5e95de6230c634cc9c3eb06be51c67f88ccedd92", + "sha256:aa83ce29f04c3d83d51863819281be8bf35d22ae1b8fba9a32cd8a84cd471998", + "sha256:ab3d291304c4e4160276533d3e4e36380ba18dacf2c8d6573980f2ef168f3afb", + "sha256:b1b4ffcb39e77e29862e23d3a25a7c307cac85c8d0654d51547059c053060fc5", + "sha256:c0ef97e126831ec8ae616c5a4d9b321b6e792cf48a1bf473fd6555226c91839f", + "sha256:c15c3fe45855d985922c0f74f8c282b126b3458d5662c4875ae0d088d12b7c3f", + "sha256:cdafe6f7f7dd32ce79b9d5dade7045de7c89d747bce4804f41be84027dc23312", + "sha256:d4d9a9531d3f5990f2f043288359c83527ab927ef4ad9c55a831166d68a53baa", + "sha256:f5722b4eb8052405c314dfae8a1a6e27ee493d051354c53f1ceb8f4e1fd3f075", + "sha256:f581171b9c3b4d5048ce64634b210bfccec06ef3a7422f1807a2a8de31a3c075", + "sha256:f8971da715deda1670e2383185c0c2a1ea819fb17221953f4d0c20d0f14ef24d" ], "index": "pypi", - "version": "==0.29.3" + "version": "==0.29.5" }, "fancycompleter": { "hashes": [ @@ -364,11 +365,11 @@ }, "more-itertools": { "hashes": [ - "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", - "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", - "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9" + "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", + "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1" ], - "version": "==5.0.0" + "markers": "python_version > '2.7'", + "version": "==6.0.0" }, "msgpack": { "hashes": [ @@ -395,37 +396,37 @@ }, "multio": { "hashes": [ - "sha256:e8bce12aa8d2e076d96f4c4b6bfb70c01e0e0af9892f9ffc4ec868854e1b877e" + "sha256:fdcd9bd48d053da9f44b1ca56c2f7cf0902d4eb76b41cf16b684166d7180f79f" ], - "version": "==0.2.4" + "version": "==0.2.5" }, "numpy": { "hashes": [ - "sha256:00a458d6821b1e87be873f2126d5646b901047a7480e8ae9773ecf214f0e19f3", - "sha256:0470c5dc32212a08ebc2405f32e8ceb9a5b1c8ac61a2daf9835ec0856a220495", - "sha256:24a9c287a4a1c427c2d45bf7c4fc6180c52a08fa0990d4c94e4c86a9b1e23ba5", - "sha256:25600e8901012180a1b7cd1ac3e27e7793586ecd432383191929ac2edf37ff5d", - "sha256:2d279bd99329e72c30937bdef82b6dc7779c7607c5a379bab1bf76be1f4c1422", - "sha256:32af2bcf4bb7631dac19736a6e092ec9715e770dcaa1f85fcd99dec5040b2a4d", - "sha256:3e90a9fce378114b6c2fc01fff7423300515c7b54b7cc71b02a22bc0bd7dfdd8", - "sha256:5774d49516c37fd3fc1f232e033d2b152f3323ca4c7bfefd7277e4c67f3c08b4", - "sha256:64ff21aac30d40c20ba994c94a08d439b8ced3b9c704af897e9e4ba09d10e62c", - "sha256:803b2af862dcad6c11231ea3cd1015d1293efd6c87088be33d713a9b23e9e419", - "sha256:95c830b09626508f7808ce7f1344fb98068e63143e6050e5dc3063142fc60007", - "sha256:96e49a0c82b4e3130093002f625545104037c2d25866fa2e0c90d6e54f5a1fbc", - "sha256:a1dd8221f0e69038748f47b8bb3248d0b9ecdf13fe837440951c3d5ff72639bb", - "sha256:a80ecac5664f420556a725a5646f2d1c60a7c0489d68a38b5056393e949e27ac", - "sha256:b19a47ff1bd2fca0cacdfa830c967746764c32dca6a0c0328d9c893f4bfe2f6b", - "sha256:be43df2c563e264b38e3318574d80fc8f365df3fb745270934d2dbe54e006f41", - "sha256:c40cb17188f6ae3c5b6efc6f0fd43a7ddd219b7807fe179e71027849a9b91afc", - "sha256:c6251e0f0ecac53ba2b99d9f0cc16fa9021914a78869c38213c436ba343641f0", - "sha256:cb189bd98b2e7ac02df389b6212846ab20661f4bafe16b5a70a6f1728c1cc7cb", - "sha256:ef4ae41add536cb825d8aa029c15ef510aead06ea5b68daea64f0b9ecbff17db", - "sha256:f00a2c21f60284e024bba351875f3501c6d5817d64997a0afe4f4355161a8889", - "sha256:f1232f98a6bbd6d1678249f94028bccc541bbc306aa5c4e1471a881b0e5a3409", - "sha256:fea682f6ddc09517df0e6d5caad9613c6d91a42232aeb082df67e4d205de19cc" + "sha256:0cdbbaa30ae69281b18dd995d3079c4e552ad6d5426977f66b9a2a95f11f552a", + "sha256:2b0cca1049bd39d1879fa4d598624cafe82d35529c72de1b3d528d68031cdd95", + "sha256:31d3fe5b673e99d33d70cfee2ea8fe8dccd60f265c3ed990873a88647e3dd288", + "sha256:34dd4922aab246c39bf5df03ca653d6265e65971deca6784c956bf356bca6197", + "sha256:384e2dfa03da7c8d54f8f934f61b6a5e4e1ebb56a65b287567629d6c14578003", + "sha256:392e2ea22b41a22c0289a88053204b616181288162ba78e6823e1760309d5277", + "sha256:4341a39fc085f31a583be505eabf00e17c619b469fef78dc7e8241385bfddaa4", + "sha256:45080f065dcaa573ebecbfe13cdd86e8c0a68c4e999aa06bd365374ea7137706", + "sha256:485cb1eb4c9962f4cd042fed9424482ec1d83fee5dc2ef3f2552ac47852cb259", + "sha256:575cefd28d3e0da85b0864506ae26b06483ee4a906e308be5a7ad11083f9d757", + "sha256:62784b35df7de7ca4d0d81c5b6af5983f48c5cdef32fc3635b445674e56e3266", + "sha256:69c152f7c11bf3b4fc11bc4cc62eb0334371c0db6844ebace43b7c815b602805", + "sha256:6ccfdcefd287f252cf1ea7a3f1656070da330c4a5658e43ad223269165cdf977", + "sha256:7298fbd73c0b3eff1d53dc9b9bdb7add8797bb55eeee38c8ccd7906755ba28af", + "sha256:79463d918d1bf3aeb9186e3df17ddb0baca443f41371df422f99ee94f4f2bbfe", + "sha256:8bbee788d82c0ac656536de70e817af09b7694f5326b0ef08e5c1014fcb96bb3", + "sha256:a863957192855c4c57f60a75a1ac06ce5362ad18506d362dd807e194b4baf3ce", + "sha256:ae602ba425fb2b074e16d125cdce4f0194903da935b2e7fe284ebecca6d92e76", + "sha256:b13faa258b20fa66d29011f99fdf498641ca74a0a6d9266bc27d83c70fea4a6a", + "sha256:c2c39d69266621dd7464e2bb740d6eb5abc64ddc339cc97aa669f3bb4d75c103", + "sha256:e9c88f173d31909d881a60f08a8494e63f1aff2a4052476b24d4f50e82c47e24", + "sha256:f1a29267ac29fff0913de0f11f3a9edfcd3f39595f467026c29376fad243ebe3", + "sha256:f69dde0c5a137d887676a8129373e44366055cf19d1b434e853310c7a1e68f93" ], - "version": "==1.16.0" + "version": "==1.16.1" }, "outcome": { "hashes": [ @@ -436,35 +437,35 @@ }, "pandas": { "hashes": [ - "sha256:02d34a55e85819a7eab096f391f8dcc237876e8b3cdaf1fba964f5fb59af9acf", - "sha256:0dbcf78e68f619840184ce661c68c1760de403b0f69d81905d6b9a699d1861d6", - "sha256:174c3974da26fd778ac8537d74efb17d4cef59e6b3e81e3c59690f39a6f6b73d", - "sha256:3a8ab5c350131ba273d3f8eb430343304d6c2138a61d34e4a11ebd75f8bf3e7e", - "sha256:560074ce9ff95409b233c0a8d143a2546a2d71d636d583172252dc0021fdb11b", - "sha256:5bded8cb431705609dbd9048114f1d6d59bef2f1ca95a8c58bd649442c9dc16c", - "sha256:8a8748684787792f3a643a7e0530c3024301f3e5799a199a5c2c526c07f712ba", - "sha256:8c7e43c4b7920fc02ce7743b976aca15bd45293ed298d84793307bc9799df3f6", - "sha256:9bd9ef3e183b7b1ce90b7ab5e8672907cd73dc36f036fc6714f0e7a5f9852da0", - "sha256:d3f27e276c8557c15c19c5c9a414e77b893d39fce6e6e40e5c46fcf5eeffe028", - "sha256:d40b82a4aee4ca968348e41bf6588ed9cadd171c7da8b671ed31d3fd967de703", - "sha256:d8cf054a099ff694a0e75386471bdde098efe7c350548ec6b899f169bef1a859", - "sha256:dd9f4843aa59f09698679b64064f11f51d60e45358ab45299de4dcff90524be3", - "sha256:e6f9f5ad4e73f5eecaa66e9c9d30ff8661c400190a6079ee170e37a466457e31", - "sha256:e9989e17f203900b2c7add53fa17d6686e66282598359b43fb12260ae8bf7eba", - "sha256:eadc9d19b25420e1ae77f0a11b779d4e71f47c3aa1953c218e8fe812d1f5341e", - "sha256:ecb630a99b0ab6c178b5c2988ca8c5b98f6ec2fd9e172c2873a5df44b261310f", - "sha256:f8eb9308bd64abf71dda77b823913696cd85c4f36c026acee0a64d8834a09b43", - "sha256:fe71a037ce866d9fb717fd3a792d46c744433179bf3f25da48af8f46cee20c3e", - "sha256:ff0d83306bfda4639fac2a4f8df2c51eb2bbdda540a74490703e8a6b413a37eb" + "sha256:02c830f951f3dc8c3164e2639a8961881390f7492f71a7835c2330f54539ad57", + "sha256:179015834c72a577486337394493cc2969feee9a04a2ea09f50c724e4b52ab42", + "sha256:3894960d43c64cfea5142ac783b101362f5008ee92e962392156a3f8d1558995", + "sha256:435821cb2501eabbcee7e83614bd710940dc0cf28b5afbc4bdb816c31cec71af", + "sha256:8294dea9aa1811f93558702856e3b68dd1dfd7e9dbc8e0865918a07ee0f21c2c", + "sha256:844e745ab27a9a01c86925fe776f9d2e09575e65f0bf8eba5090edddd655dffc", + "sha256:a08d49f5fa2a2243262fe5581cb89f6c0c7cc525b8d6411719ab9400a9dc4a82", + "sha256:a435c251246075337eb9fdc4160fd15c8a87cc0679d8d61fb5255d8d5a12f044", + "sha256:a799f03c0ec6d8687f425d7d6c075e8055a9a808f1ba87604d91f20507631d8d", + "sha256:aea72ce5b3a016b578cc05c04a2f68d9cafacf5d784b6fe832e66381cb62c719", + "sha256:c145e94c6da2af7eaf1fd827293ac1090a61a9b80150bebe99f8966a02378db9", + "sha256:c8a7b470c88c779301b73b23cabdbbd94b83b93040b2ccffa409e06df23831c0", + "sha256:c9e31b36abbd7b94c547d9047f13e1546e3ba967044cf4f9718575fcb7b81bb6", + "sha256:d960b7a03c33c328c723cfc2f8902a6291645f4efa0a5c1d4c5fa008cdc1ea77", + "sha256:da21fae4c173781b012217c9444f13c67449957a4d45184a9718268732c09564", + "sha256:db26c0fea0bd7d33c356da98bafd2c0dfb8f338e45e2824ff8f4f3e61b5c5f25", + "sha256:dc296c3f16ec620cfb4daf0f672e3c90f3920ece8261b2760cd0ebd9cd4daa55", + "sha256:e8da67cb2e9333ec30d53cfb96e27a4865d1648688e5471699070d35d8ab38cf", + "sha256:fb4f047a63f91f22aade4438aaf790400b96644e802daab4293e9b799802f93f", + "sha256:fef9939176cba0c2526ebeefffb8b9807543dc0954877b7226f751ec1294a869" ], - "version": "==0.24.0" + "version": "==0.24.1" }, "pdbpp": { "hashes": [ - "sha256:535085916fcfb768690ba0aeab2967c2a2163a0a60e5b703776846873e171399" + "sha256:438bb2c885e40e9dcf649d9b598e4fe30fd1e3558c89a6ad3f447a9839a04e9f" ], "index": "pypi", - "version": "==0.9.3" + "version": "==0.9.6" }, "piker": { "editable": true, @@ -479,10 +480,10 @@ }, "py": { "hashes": [ - "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", - "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" ], - "version": "==1.7.0" + "version": "==1.8.0" }, "pygments": { "hashes": [ @@ -493,18 +494,18 @@ }, "pytest": { "hashes": [ - "sha256:41568ea7ecb4a68d7f63837cf65b92ce8d0105e43196ff2b26622995bb3dc4b2", - "sha256:c3c573a29d7c9547fb90217ece8a8843aa0c1328a797e200290dc3d0b4b823be" + "sha256:067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c", + "sha256:9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4" ], "index": "pypi", - "version": "==4.1.1" + "version": "==4.3.0" }, "python-dateutil": { "hashes": [ - "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", - "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" + "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", + "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" ], - "version": "==2.7.5" + "version": "==2.8.0" }, "pytz": { "hashes": [ @@ -536,10 +537,11 @@ }, "trio": { "hashes": [ - "sha256:d323cc15f6406d15954af91e5e34af2001cc24163fdde29e3f88a227a1b53ab0" + "sha256:3796774aedbf5be581c68f98c79b565654876de6e9a01c6a95e3ec6cd4e4b4c3", + "sha256:b0c03d312c300a947e54e204be88255992434e824374b7d3cc886876dab9a542" ], "index": "pypi", - "version": "==0.10.0" + "version": "==0.11.0" }, "wmctrl": { "hashes": [ From c1a398d8266d4cca856eaef2993ebea84f6aef54 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 23 Feb 2019 16:13:04 -0500 Subject: [PATCH 07/17] Use click context to factor cmd options --- piker/cli.py | 139 ++++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 68 deletions(-) diff --git a/piker/cli.py b/piker/cli.py index d980dea3..8c6be8de 100644 --- a/piker/cli.py +++ b/piker/cli.py @@ -11,21 +11,27 @@ import click import pandas as pd import trio import tractor -from async_generator import asynccontextmanager from . import watchlists as wl from .brokers import core, get_brokermod, data from .log import get_console_log, colorize_json, get_logger +from .brokers.core import maybe_spawn_brokerd_as_subactor, _data_mods log = get_logger('cli') -DEFAULT_BROKER = 'robinhood' +DEFAULT_BROKER = 'questrade' _config_dir = click.get_app_dir('piker') _watchlists_data_path = os.path.join(_config_dir, 'watchlists.json') -_data_mods = [ - 'piker.brokers.core', - 'piker.brokers.data', -] +_context_defaults = dict( + default_map={ + 'monitor': { + 'rate': 3, + }, + 'optschain': { + 'rate': 1, + }, + } +) @click.command() @@ -43,24 +49,33 @@ def pikerd(loglevel, host, tl): ) -@click.group() -def cli(): - pass - - -@cli.command() +@click.group(context_settings=_context_defaults) @click.option('--broker', '-b', default=DEFAULT_BROKER, help='Broker backend to use') @click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.pass_context +def cli(ctx, broker, loglevel): + # ensure that ctx.obj exists even though we aren't using it (yet) + ctx.ensure_object(dict) + ctx.obj.update({ + 'broker': broker, + 'brokermod': get_brokermod(broker), + 'loglevel': loglevel, + 'log': get_console_log(loglevel), + }) + + +@cli.command() @click.option('--keys', '-k', multiple=True, help='Return results only for these keys') @click.argument('meth', nargs=1) @click.argument('kwargs', nargs=-1) -def api(meth, kwargs, loglevel, broker, keys): +@click.pass_obj +def api(config, meth, kwargs, keys): """client for testing broker API methods with pretty printing of output. """ - get_console_log(loglevel) - brokermod = get_brokermod(broker) + # global opts + broker = config['broker'] _kwargs = {} for kwarg in kwargs: @@ -71,7 +86,7 @@ def api(meth, kwargs, loglevel, broker, keys): _kwargs[key] = value data = trio.run( - partial(core.api, brokermod, meth, **_kwargs) + partial(core.api, broker, meth, **_kwargs) ) if keys: @@ -89,18 +104,17 @@ def api(meth, kwargs, loglevel, broker, keys): @cli.command() -@click.option('--broker', '-b', default=DEFAULT_BROKER, - help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--df-output', '-df', flag_value=True, help='Output in `pandas.DataFrame` format') @click.argument('tickers', nargs=-1, required=True) -def quote(loglevel, broker, tickers, df_output): +@click.pass_obj +def quote(config, tickers, df_output): """Retreive symbol quotes on the console in either json or dataframe format. """ - brokermod = get_brokermod(broker) - get_console_log(loglevel) + # global opts + brokermod = config['brokermod'] + quotes = trio.run(partial(core.stocks_quote, brokermod, tickers)) if not quotes: log.error(f"No quotes could be found for {tickers}?") @@ -125,40 +139,22 @@ def quote(loglevel, broker, tickers, df_output): click.echo(colorize_json(quotes)) -@asynccontextmanager -async def maybe_spawn_brokerd_as_subactor(sleep=0.5, tries=10, loglevel=None): - """If no ``brokerd`` daemon-actor can be found spawn one in a - local subactor. - """ - async with tractor.open_nursery() as nursery: - async with tractor.find_actor('brokerd') as portal: - if not portal: - log.info( - "No broker daemon could be found, spawning brokerd..") - portal = await nursery.start_actor( - 'brokerd', - rpc_module_paths=_data_mods, - loglevel=loglevel, - ) - yield portal - - @cli.command() -@click.option('--broker', '-b', default=DEFAULT_BROKER, - help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--tl', is_flag=True, help='Enable tractor logging') @click.option('--rate', '-r', default=3, help='Quote rate limit') @click.option('--test', '-t', help='Test quote stream file') @click.option('--dhost', '-dh', default='127.0.0.1', help='Daemon host address to connect to') @click.argument('name', nargs=1, required=True) -def monitor(loglevel, broker, rate, name, dhost, test, tl): +@click.pass_obj +def monitor(config, rate, name, dhost, test, tl): """Spawn a real-time watchlist. """ - from .ui.monitor import _async_main - log = get_console_log(loglevel) # activate console logging - brokermod = get_brokermod(broker) + # global opts + brokermod = config['brokermod'] + loglevel = config['loglevel'] + log = config['log'] + watchlist_from_file = wl.ensure_watchlists(_watchlists_data_path) watchlists = wl.merge_watchlist(watchlist_from_file, wl._builtins) tickers = watchlists[name] @@ -166,6 +162,8 @@ def monitor(loglevel, broker, rate, name, dhost, test, tl): log.error(f"No symbols found for watchlist `{name}`?") return + from .ui.monitor import _async_main + async def main(tries): async with maybe_spawn_brokerd_as_subactor( tries=tries, loglevel=loglevel @@ -185,20 +183,21 @@ def monitor(loglevel, broker, rate, name, dhost, test, tl): @cli.command() -@click.option('--broker', '-b', default=DEFAULT_BROKER, - help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--rate', '-r', default=5, help='Logging level') @click.option('--filename', '-f', default='quotestream.jsonstream', help='Logging level') @click.option('--dhost', '-dh', default='127.0.0.1', help='Daemon host address to connect to') @click.argument('name', nargs=1, required=True) -def record(loglevel, broker, rate, name, dhost, filename): +@click.pass_obj +def record(config, rate, name, dhost, filename): """Record client side quotes to file """ - log = get_console_log(loglevel) # activate console logging - brokermod = get_brokermod(broker) + # global opts + brokermod = config['brokermod'] + loglevel = config['loglevel'] + log = config['log'] + watchlist_from_file = wl.ensure_watchlists(_watchlists_data_path) watchlists = wl.merge_watchlist(watchlist_from_file, wl._builtins) tickers = watchlists[name] @@ -222,15 +221,17 @@ def record(loglevel, broker, rate, name, dhost, filename): @cli.group() -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--config_dir', '-d', default=_watchlists_data_path, help='Path to piker configuration directory') @click.pass_context -def watchlists(ctx, loglevel, config_dir): +def watchlists(ctx, config_dir): """Watchlists commands and operations """ + loglevel = ctx.parent.params['loglevel'] get_console_log(loglevel) # activate console logging + wl.make_config_dir(_config_dir) + ctx.ensure_object(dict) ctx.obj = {'path': config_dir, 'watchlist': wl.ensure_watchlists(config_dir)} @@ -317,9 +318,12 @@ def dump(ctx, name): @click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--ids', flag_value=True, help='Include numeric ids in output') @click.argument('symbol', required=True) -def contracts(loglevel, broker, symbol, ids): +@click.pass_context +def contracts(ctx, loglevel, broker, symbol, ids): + brokermod = get_brokermod(broker) get_console_log(loglevel) + contracts = trio.run(partial(core.contracts, brokermod, symbol)) if not ids: # just print out expiry dates which can be used with @@ -333,19 +337,18 @@ def contracts(loglevel, broker, symbol, ids): @cli.command() -@click.option('--broker', '-b', default=DEFAULT_BROKER, - help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--df-output', '-df', flag_value=True, help='Output in `pandas.DataFrame` format') @click.option('--date', '-d', help='Contracts expiry date') @click.argument('symbol', required=True) -def optsquote(loglevel, broker, symbol, df_output, date): +@click.pass_obj +def optsquote(config, symbol, df_output, date): """Retreive symbol quotes on the console in either json or dataframe format. """ - brokermod = get_brokermod(broker) - get_console_log(loglevel) + # global opts + brokermod = config['brokermod'] + quotes = trio.run( partial( core.option_chain, brokermod, symbol, date @@ -366,20 +369,20 @@ def optsquote(loglevel, broker, symbol, df_output, date): @cli.command() -@click.option('--broker', '-b', default=DEFAULT_BROKER, - help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option('--tl', is_flag=True, help='Enable tractor logging') @click.option('--date', '-d', help='Contracts expiry date') @click.option('--test', '-t', help='Test quote stream file') @click.option('--rate', '-r', default=1, help='Logging level') @click.argument('symbol', required=True) -def optschain(loglevel, broker, symbol, date, tl, rate, test): +@click.pass_obj +def optschain(config, symbol, date, tl, rate, test): """Start the real-time option chain UI. """ + # global opts + loglevel = config['loglevel'] + brokermod = config['brokermod'] + from .ui.option_chain import _async_main - log = get_console_log(loglevel) # activate console logging - brokermod = get_brokermod(broker) async def main(tries): async with maybe_spawn_brokerd_as_subactor( From 295ccbbe64a34f99147b53c4ef4b59b4dd2f915d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 24 Feb 2019 10:55:52 -0500 Subject: [PATCH 08/17] Name the data what it is --- piker/brokers/_util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/piker/brokers/_util.py b/piker/brokers/_util.py index 8d7a3e7b..73fb3e9f 100644 --- a/piker/brokers/_util.py +++ b/piker/brokers/_util.py @@ -24,11 +24,11 @@ def resproc( if not resp.status_code == 200: raise BrokerError(resp.body) try: - data = resp.json() + json = resp.json() except json.decoder.JSONDecodeError: log.exception(f"Failed to process {resp}:\n{resp.text}") raise BrokerError(resp.text) else: - log.trace(f"Received json contents:\n{colorize_json(data)}") + log.trace(f"Received json contents:\n{colorize_json(json)}") - return data if return_json else resp + return json if return_json else resp From 130553b8df8971a965d173be84db09adc47f1a6b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 19:29:54 -0500 Subject: [PATCH 09/17] Accept a path arg to `write()` --- piker/brokers/config.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/piker/brokers/config.py b/piker/brokers/config.py index 22cbfe9c..f1c8a90d 100644 --- a/piker/brokers/config.py +++ b/piker/brokers/config.py @@ -1,7 +1,7 @@ """ Broker configuration mgmt. """ -from os import path, makedirs +import os import configparser import click from ..log import get_logger @@ -9,28 +9,46 @@ from ..log import get_logger log = get_logger('broker-config') _config_dir = click.get_app_dir('piker') -_broker_conf_path = path.join(_config_dir, 'brokers.ini') +_file_name = 'brokers.ini' -def load(path: str = None) -> (configparser.ConfigParser, str): +def _override_config_dir( + path: str +) -> None: + global _config_dir + _config_dir = path + + +def get_broker_conf_path(): + return os.path.join(_config_dir, _file_name) + + +def load( + path: str = None +) -> (configparser.ConfigParser, str): """Load broker config. """ - path = path or _broker_conf_path + path = path or get_broker_conf_path() config = configparser.ConfigParser() - read = config.read(path) + config.read(path) log.debug(f"Read config file {path}") return config, path -def write(config: configparser.ConfigParser) -> None: +def write( + config: configparser.ConfigParser, + path: str = None, +) -> None: """Write broker config to disk. Create a ``brokers.ini`` file if one does not exist. """ - if not path.isdir(_config_dir): + path = path or get_broker_conf_path() + dirname = os.path.dirname(path) + if not os.path.isdir(dirname): log.debug(f"Creating config dir {_config_dir}") - makedirs(_config_dir) + os.makedirs(dirname) - log.debug(f"Writing config file {_broker_conf_path}") - with open(_broker_conf_path, 'w') as cf: + log.debug(f"Writing config file {path}") + with open(path, 'w') as cf: return config.write(cf) From 77548d2ee6114846c0a1a577fce6b60e23eb10fb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 20:11:45 -0500 Subject: [PATCH 10/17] Add token-from-user toggles to token auth methods --- piker/brokers/__init__.py | 4 +++ piker/brokers/data.py | 8 +++--- piker/brokers/questrade.py | 57 +++++++++++++++++++++++--------------- piker/brokers/robinhood.py | 3 +- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/piker/brokers/__init__.py b/piker/brokers/__init__.py index 2e12b3ea..ae14799c 100644 --- a/piker/brokers/__init__.py +++ b/piker/brokers/__init__.py @@ -4,6 +4,10 @@ Broker clients, daemons and general back end machinery. from importlib import import_module from types import ModuleType +# TODO: move to urllib3/requests once supported +import asks +asks.init('trio') + __brokers__ = [ 'questrade', 'robinhood', diff --git a/piker/brokers/data.py b/piker/brokers/data.py index 115b9ff7..0d2a04b8 100644 --- a/piker/brokers/data.py +++ b/piker/brokers/data.py @@ -77,15 +77,15 @@ class BrokerFeed: @tractor.msg.pub(tasks=['stock', 'option']) -async def stream_quotes( +async def stream_requests( get_topics: typing.Callable, get_quotes: Coroutine, feed: BrokerFeed, rate: int = 3, # delay between quote requests diff_cached: bool = True, # only deliver "new" quotes to the queue ) -> None: - """Stream quotes for a sequence of tickers at the given ``rate`` - per second. + """Stream requests for quotes for a set of symbols at the given + ``rate`` (per second). A stock-broker client ``get_quotes()`` async context manager must be provided which returns an async quote retrieval function. @@ -307,7 +307,7 @@ async def start_quote_stream( # push initial smoke quote response for client initialization await ctx.send_yield(payload) - await stream_quotes( + await stream_requests( # pub required kwargs task_name=feed_type, diff --git a/piker/brokers/questrade.py b/piker/brokers/questrade.py index 643962c9..01643a57 100644 --- a/piker/brokers/questrade.py +++ b/piker/brokers/questrade.py @@ -12,6 +12,7 @@ from typing import List, Tuple, Dict, Any, Iterator, NamedTuple import trio from async_generator import asynccontextmanager import wrapt +import asks from ..calc import humanize, percent_change from . import config @@ -19,10 +20,6 @@ from ._util import resproc, BrokerError from ..log import get_logger, colorize_json from .._async_utils import async_lifo_cache -# TODO: move to urllib3/requests once supported -import asks -asks.init('trio') - log = get_logger(__name__) _use_practice_account = False @@ -218,11 +215,13 @@ class Client: self._sess = asks.Session() self.api = _API(self) self._conf = config - self._is_practise_account = _use_practice_account + self._is_practice = _use_practice_account or ( + config['questrade'].get('is_practice', False) + ) self._auth_ep = _refresh_token_ep.format( - 'practice' if _use_practice_account else '') + 'practice' if self._is_practice else '') self.access_data = {} - self._reload_config(config) + self._reload_config(config=config) self._symbol_cache: Dict[str, int] = {} self._optids2contractinfo = {} self._contract2ids = {} @@ -234,7 +233,10 @@ class Client: self._mutex = trio.StrictFIFOLock() def _reload_config(self, config=None, **kwargs): - self._conf = config or get_config(**kwargs) + if config: + self._conf = config + else: + self._conf, _ = get_config(**kwargs) self.access_data = dict(self._conf['questrade']) def write_config(self): @@ -243,7 +245,11 @@ class Client: self._conf['questrade'] = self.access_data config.write(self._conf) - async def ensure_access(self, force_refresh: bool = False) -> dict: + async def ensure_access( + self, + force_refresh: bool = False, + ask_user: bool = True, + ) -> dict: """Acquire a new token set (``access_token`` and ``refresh_token``). Checks if the locally cached (file system) ``access_token`` has expired @@ -295,14 +301,16 @@ class Client: elif msg == 'Bad Request': # likely config ``refresh_token`` is expired but - # may be updated in the config file via another - # piker process + # may be updated in the config file via + # another actor self._reload_config() try: data = await self.api._new_auth_token( self.access_data['refresh_token']) except BrokerError as qterr: - if get_err_msg(qterr) == 'Bad Request': + if get_err_msg(qterr) == 'Bad Request' and ( + ask_user + ): # actually expired; get new from user self._reload_config(force_from_user=True) data = await self.api._new_auth_token( @@ -518,8 +526,8 @@ def _token_from_user(conf: 'configparser.ConfigParser') -> None: def get_config( - force_from_user: bool = False, config_path: str = None, + force_from_user: bool = False, ) -> "configparser.ConfigParser": """Load the broker config from disk. @@ -531,24 +539,27 @@ def get_config( """ log.debug("Reloading access config data") conf, path = config.load(config_path) - if not conf.has_section('questrade'): - log.warn( - f"No valid refresh token could be found in {path}") - elif force_from_user: + if force_from_user: log.warn(f"Forcing manual token auth from user") _token_from_user(conf) - return conf + return conf, path @asynccontextmanager -async def get_client(**kwargs) -> Client: +async def get_client( + config_path: str = None, + ask_user: bool = True +) -> Client: """Spawn a broker client for making requests to the API service. """ - conf = get_config(config_path=kwargs.get('config_path')) + conf, path = get_config(config_path) + if not conf.has_section('questrade'): + raise ValueError( + f"No `questrade` section could be found in {path}") log.debug(f"Loaded config:\n{colorize_json(dict(conf['questrade']))}") - client = Client(conf, **kwargs) - await client.ensure_access() + client = Client(conf) + await client.ensure_access(ask_user=ask_user) try: log.debug("Check time to ensure access token is valid") # XXX: the `time()` end point requires acc_read Oauth access. @@ -560,7 +571,7 @@ async def get_client(**kwargs) -> Client: # access token is likely no good log.warn(f"Access tokens {client.access_data} seem" f" expired, forcing refresh") - await client.ensure_access(force_refresh=True) + await client.ensure_access(force_refresh=True, ask_user=ask_user) await client.api.time() try: yield client diff --git a/piker/brokers/robinhood.py b/piker/brokers/robinhood.py index 4c17fff1..34be0627 100644 --- a/piker/brokers/robinhood.py +++ b/piker/brokers/robinhood.py @@ -9,15 +9,14 @@ from functools import partial from typing import List from async_generator import asynccontextmanager -# TODO: move to urllib3/requests once supported import asks from ..log import get_logger from ._util import resproc, BrokerError from ..calc import percent_change -asks.init('trio') log = get_logger(__name__) + _service_ep = 'https://api.robinhood.com' From 747d703d9278625e3741c09e1a743de316b407df Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 20:14:36 -0500 Subject: [PATCH 11/17] Add hack-fix to avoid leaking cmdline flags to kivy --- piker/ui/__init__.py | 8 +++++++- piker/ui/monitor.py | 3 +-- piker/ui/option_chain.py | 3 +-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/piker/ui/__init__.py b/piker/ui/__init__.py index 941b7bb6..bc6e6ac9 100644 --- a/piker/ui/__init__.py +++ b/piker/ui/__init__.py @@ -1,7 +1,13 @@ """ -Stuff for you eyes. +Stuff for your eyes. """ import os +import sys + +# XXX clear all flags at import to avoid upsetting +# ol' kivy see: https://github.com/kivy/kivy/issues/4225 +# though this is likely a ``click`` problem +sys.argv[1:] = [] # use the trio async loop os.environ['KIVY_EVENTLOOP'] = 'trio' diff --git a/piker/ui/monitor.py b/piker/ui/monitor.py index 237f6432..7404a750 100644 --- a/piker/ui/monitor.py +++ b/piker/ui/monitor.py @@ -252,8 +252,7 @@ async def _async_main( quotes ) try: - # Trio-kivy entry point. - await async_runTouchApp(widgets['root']) # run kivy + await async_runTouchApp(widgets['root']) finally: # cancel remote data feed task await quote_gen.aclose() diff --git a/piker/ui/option_chain.py b/piker/ui/option_chain.py index 4267ac01..4ebe9dac 100644 --- a/piker/ui/option_chain.py +++ b/piker/ui/option_chain.py @@ -498,8 +498,7 @@ async def _async_main( ) async with chain.open_rt_display(nursery, symbol): try: - # trio-kivy entry point. - await async_runTouchApp(chain.widgets['root']) # run kivy + await async_runTouchApp(chain.widgets['root']) finally: if chain._quote_gen: await chain._quote_gen.aclose() From d3fae00e74d95b924dd7435d111ae61bb9694e5e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 20:22:29 -0500 Subject: [PATCH 12/17] Add a `configdir` cli option --- piker/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/piker/cli.py b/piker/cli.py index 8c6be8de..21b51611 100644 --- a/piker/cli.py +++ b/piker/cli.py @@ -13,8 +13,8 @@ import trio import tractor from . import watchlists as wl -from .brokers import core, get_brokermod, data from .log import get_console_log, colorize_json, get_logger +from .brokers import core, get_brokermod, data, config from .brokers.core import maybe_spawn_brokerd_as_subactor, _data_mods log = get_logger('cli') @@ -53,8 +53,13 @@ def pikerd(loglevel, host, tl): @click.option('--broker', '-b', default=DEFAULT_BROKER, help='Broker backend to use') @click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.option('--configdir', '-c', help='Configuration directory') @click.pass_context -def cli(ctx, broker, loglevel): +def cli(ctx, broker, loglevel, configdir): + if configdir is not None: + assert os.path.isdir(configdir), f"`{configdir}` is not a valid path" + config._override_config_dir(configdir) + # ensure that ctx.obj exists even though we aren't using it (yet) ctx.ensure_object(dict) ctx.obj.update({ From 414734f803d404f81fd57ea15abddbd3ac4e87e9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 20:23:20 -0500 Subject: [PATCH 13/17] Add travisCI specific test suite integration Questrade is the default broker backend (for now) so the CI can run using a practice account token handed down through an env variable. If we add a cached directory to the build then the token should remain persistent in the brokers config and will only need to be updated if something goes wrong. Also, add a `--confdir` flag for pytest much in the same way as for the `piker` cli. --- tests/conftest.py | 68 +++++++++++++++++++++++++++++++++++++++-- tests/test_questrade.py | 11 ++----- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 76a8ddee..fbb33eec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,17 @@ +import os + import pytest import tractor +import trio from piker import log +from piker.brokers import questrade, config def pytest_addoption(parser): parser.addoption("--ll", action="store", dest='loglevel', default=None, help="logging level to set when testing") + parser.addoption("--confdir", default=None, + help="Use a practice API account") @pytest.fixture(scope='session', autouse=True) @@ -17,9 +23,65 @@ def loglevel(request): tractor.log._default_loglevel = orig -@pytest.fixture -def brokerconf(): - from piker.brokers import config +@pytest.fixture(scope='session') +def test_config(): + dirname = os.path.dirname + dirpath = os.path.abspath( + os.path.join( + dirname(os.path.realpath(__file__)), + 'data' + ) + ) + return dirpath + + +@pytest.fixture(scope='session') +def travis(): + is_travis = os.environ.get('TRAVIS', False) + if is_travis: + # this directory is cached, see .travis.yaml + cache_dir = config.get_broker_conf_path() + refresh_token = os.environ['QT_REFRESH_TOKEN'] + + def write_with_token(token): + conf, path = config.load(cache_dir) + conf.setdefault('questrade', {}).update({'refresh_token': token}) + config.write(conf, path) + + # if not os.path.isdir(cache_dir): + # write_with_token(refresh_token) + + async def ensure_config(): + # try to refresh current token using cached brokers config + # if it fails fail try using the refresh token provided by the + # env var and if that fails stop the test run here. + try: + async with questrade.get_client(ask_user=False): + pass + + except (KeyError, ValueError, questrade.BrokerError): + # 3 cases: + # - config doesn't have a ``refresh_token`` k/v + # - cache dir does not exist yet + # - current token is expired; take it form env var + write_with_token(refresh_token) + + async with questrade.get_client(ask_user=False): + pass + + # XXX ``pytest_trio`` doesn't support scope or autouse + trio.run(ensure_config) + + +@pytest.fixture(scope='session', autouse=True) +def brokerconf(request, test_config, travis): + """If the `--confdir` flag is not passed use the + broker config file found in that dir. + """ + confdir = request.config.option.confdir + if confdir is not None: + config._override_config_dir(confdir) + return config.load()[0] diff --git a/tests/test_questrade.py b/tests/test_questrade.py index 1cfb60dd..7dea471c 100644 --- a/tests/test_questrade.py +++ b/tests/test_questrade.py @@ -17,14 +17,6 @@ from piker.brokers.data import DataFeed log = tractor.get_logger('tests') -@pytest.fixture(autouse=True) -def check_qt_conf_section(brokerconf): - """Skip this module's tests if we have not quetrade API creds. - """ - if not brokerconf.has_section('questrade'): - pytest.skip("No questrade API credentials available") - - # stock quote _ex_quotes = { 'stock': { @@ -137,7 +129,8 @@ async def test_concurrent_tokens_refresh(us_symbols, loglevel): async def intermittently_refresh_tokens(client): while True: try: - await client.ensure_access(force_refresh=True) + await client.ensure_access( + force_refresh=True, ask_user=False) log.info(f"last token data is {client.access_data}") await trio.sleep(1) except Exception: From e40e18dd5c990a41fa85dff6ae3edaf182119d59 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 22:07:13 -0500 Subject: [PATCH 14/17] Cache the piker config dir in CI --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 0f6967bd..37e0f180 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,5 +16,9 @@ install: - cd $TRAVIS_BUILD_DIR - pipenv install --dev -e . --deploy +cache: + directories: + - $HOME/.config/piker/ + script: - pipenv run pytest tests/ From 1fd033d3517d52a73e753d974c33c7e5045feb03 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 22:12:33 -0500 Subject: [PATCH 15/17] Token should always be from a practice account --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fbb33eec..956b55e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,12 +45,12 @@ def travis(): def write_with_token(token): conf, path = config.load(cache_dir) - conf.setdefault('questrade', {}).update({'refresh_token': token}) + conf.setdefault('questrade', {}).update( + {'refresh_token': token, + 'is_practice': 'True'} + ) config.write(conf, path) - # if not os.path.isdir(cache_dir): - # write_with_token(refresh_token) - async def ensure_config(): # try to refresh current token using cached brokers config # if it fails fail try using the refresh token provided by the From 5e9c38039c3ba7da086b3b3d4048458c1ef92d25 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 22:29:48 -0500 Subject: [PATCH 16/17] Move brokerd spawner to `brokers.core` --- piker/brokers/core.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/piker/brokers/core.py b/piker/brokers/core.py index b493a9e7..2fe6b736 100644 --- a/piker/brokers/core.py +++ b/piker/brokers/core.py @@ -5,16 +5,25 @@ import inspect from types import ModuleType from typing import List, Dict, Any, Optional +from async_generator import asynccontextmanager +import tractor + from ..log import get_logger from .data import DataFeed +from . import get_brokermod log = get_logger('broker.core') +_data_mods = [ + 'piker.brokers.core', + 'piker.brokers.data', +] -async def api(brokermod: ModuleType, methname: str, **kwargs) -> dict: +async def api(brokername: str, methname: str, **kwargs) -> dict: """Make (proxy through) a broker API call by name and return its result. """ + brokermod = get_brokermod(brokername) async with brokermod.get_client() as client: meth = getattr(client.api, methname, None) @@ -39,6 +48,24 @@ async def api(brokermod: ModuleType, methname: str, **kwargs) -> dict: return await meth(**kwargs) +@asynccontextmanager +async def maybe_spawn_brokerd_as_subactor(sleep=0.5, tries=10, loglevel=None): + """If no ``brokerd`` daemon-actor can be found spawn one in a + local subactor. + """ + async with tractor.open_nursery() as nursery: + async with tractor.find_actor('brokerd') as portal: + if not portal: + log.info( + "No broker daemon could be found, spawning brokerd..") + portal = await nursery.start_actor( + 'brokerd', + rpc_module_paths=_data_mods, + loglevel=loglevel, + ) + yield portal + + async def stocks_quote( brokermod: ModuleType, tickers: List[str] From 23067991109bc2b0fb85fcfeea31806c9d3910f4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Feb 2019 22:40:19 -0500 Subject: [PATCH 17/17] Speed bump quoter a slight bit --- tests/test_questrade.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_questrade.py b/tests/test_questrade.py index 7dea471c..67417f1d 100644 --- a/tests/test_questrade.py +++ b/tests/test_questrade.py @@ -120,11 +120,11 @@ async def test_concurrent_tokens_refresh(us_symbols, loglevel): quoter = await qt.stock_quoter(client, us_symbols) async def get_quotes(): - for tries in range(30): + for tries in range(15): log.info(f"{tries}: GETTING QUOTES!") quotes = await quoter(us_symbols) assert quotes - await trio.sleep(0.1) + await trio.sleep(0.2) async def intermittently_refresh_tokens(client): while True: