From 462b4d4e71da2697c7bcc69b2b8005eac0ac1700 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:15:49 +0000 Subject: [PATCH 1/3] preferences.conf is settings.conf, its template tidied (shorter lines, neater comments) An old preferences.conf in the data dir is renamed to settings.conf at start, so no setting is lost. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 4 +- README.md | 2 +- bot.py | 6 +-- compose.yaml | 2 +- defaults/finbot.env | 2 +- defaults/preferences.conf | 61 ----------------------- defaults/settings.conf | 63 ++++++++++++++++++++++++ doc/commands.md | 14 +++--- doc/configuration.md | 20 ++++---- doc/docker.md | 10 ++-- doc/install.md | 2 +- finbot.service | 4 +- lib/config.py | 52 +++++++++++++------- tests/test_config.py | 101 ++++++++++++++++++++++++-------------- tests/test_datadir.py | 6 +-- tests/test_edges.py | 4 +- util/check_config.py | 8 +-- util/split_env.py | 12 ++--- util/sync_defaults.py | 2 +- 20 files changed, 212 insertions(+), 165 deletions(-) delete mode 100644 defaults/preferences.conf create mode 100644 defaults/settings.conf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 313a45aa..b5b714f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: return 1 } wait_for 'finbot is running' - test -f "$data/preferences.conf" + test -f "$data/settings.conf" docker kill -s HUP finbot wait_for 'config: reloaded' docker kill -s USR1 finbot diff --git a/CLAUDE.md b/CLAUDE.md index bbe08ba3..212ae7fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - Production runs the Debian system packages; `requirements.txt` is for pip/uv users and CI. The libraries in `requirements-optional.txt` are each imported only inside the code of the mode that needs it, never at module load (a test starts finbot with them all blocked). - A new preference goes in three places: a `Prefs` field with its default (`lib/config.py`; its type says how it is - read), `defaults/preferences.conf` (commented out at its default, `#key=value`: the template sets nothing, and finbot + read), `defaults/settings.conf` (commented out at its default, `#key=value`: the template sets nothing, and finbot never reads it), and the preferences table of `doc/configuration.md`. The tests catch a key missing from the template or a default that differs, but not the docs (no test may read README.md or `doc/*.md`: CI skips the tests for a change to the docs alone). @@ -118,7 +118,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm `state.load_for(chat, name, default)` and `state.edit_for`. A chat's key is its network and id (`Transport.key`, `telegram:-1001234`), the same in both modes. `lib/watchlist.py`: each chat's watchlist (`tickers(chat)`), and `with_holdings(chat)`, the stocks a report in that chat covers; a report takes the chat from its destination. -- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on +- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (settings.conf, a new object on each reload). `lib/jobs.py`: the scheduler: each chat's jobs, the default ones and those it added (`JOBS_FILE`, by chat key: the defaults it switched off and those it removed, `'deleted'`, the jobs it added; a direct message's defaults start off, `'defaults': 'off'` with those switched on, and it is marked `'dm'`; `see` makes a chat known at diff --git a/README.md b/README.md index 93c7338c..8631e4cf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ jobs, which `/jobs` switches on and off, adds to and runs ([Scheduling](doc/sche ## Documentation * [Commands and reports](doc/commands.md), with screenshots * [Scheduling](doc/scheduling.md): each chat's jobs, and the reports they can run -* [Configuration](doc/configuration.md): `finbot.env` and `preferences.conf`, the data directory, Sharesight +* [Configuration](doc/configuration.md): `finbot.env` and `settings.conf`, the data directory, Sharesight * [Caching](doc/caching.md) * Installing: [Docker](doc/docker.md) · [Python, or a Debian host with systemd](doc/install.md) * Networks: [Telegram](doc/telegram.md) · [Slack](doc/slack.md) · [Discord](doc/discord.md) diff --git a/bot.py b/bot.py index 90fecdd9..155f4831 100755 --- a/bot.py +++ b/bot.py @@ -260,8 +260,8 @@ def run(): return lambda *args: threading.Thread(target=run, daemon=True).start() def reload_all(*args): - """SIGHUP: re-read preferences.conf; a bad one is reported and leaves the running config untouched. A key set in - defaults/preferences.conf, which is never read, is pointed at the file to set it in. (Each chat's jobs need no + """SIGHUP: re-read settings.conf; a bad one is reported and leaves the running config untouched. A key set in + defaults/settings.conf, which is never read, is pointed at the file to set it in. (Each chat's jobs need no reload: a change made with .jobs counts at once.)""" try: changes = config.reload() @@ -339,7 +339,7 @@ def http_server(): threading.Thread(target=scheduler.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs for listener in listeners(): # the networks without a webhook: finbot fetches their updates threading.Thread(target=listener.run, args=(take,), daemon=True).start() - # Startup is done. systemctl reload finbot: pick up an edited preferences.conf + # Startup is done. systemctl reload finbot: pick up an edited settings.conf signal.signal(signal.SIGHUP, in_thread(reload_all)) signal.signal(signal.SIGUSR1, in_thread(flush_cache)) # systemctl kill -s USR1 finbot: delete the cache log.info('finbot is running: SIGHUP reloads, SIGUSR1 flushes the cache') diff --git a/compose.yaml b/compose.yaml index 42a61935..b1e79c87 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,5 @@ # finbot with Docker Compose (doc/docker.md). The configuration lives beside the checkout, outside the image and the -# container: ../finbot.env, the startup file, and ../finbot-data, DATA_DIR (preferences.conf, state and cache), both +# container: ../finbot.env, the startup file, and ../finbot-data, DATA_DIR (settings.conf, state and cache), both # made by util/docker_setup.sh. Build and start: docker compose up -d --build services: finbot: diff --git a/defaults/finbot.env b/defaults/finbot.env index 9f4010c7..47e10e11 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -6,7 +6,7 @@ # Keep it private: chmod 600, owned by root. It holds the credentials. # Flat KEY=value lines: no spaces around "=", no quotes (docker keeps quotes literally), no trailing comments; comments # on their own line. -# Everything else (thresholds, windows, display, caching, ...) is in preferences.conf, which the bot reloads without a +# Everything else (thresholds, windows, display, caching, ...) is in settings.conf, which the bot reloads without a # restart. # A preference key in this file is an error: finbot refuses to start. Lint both files with: # util/check_config.py /opt/finbot.env diff --git a/defaults/preferences.conf b/defaults/preferences.conf deleted file mode 100644 index 5dd2540c..00000000 --- a/defaults/preferences.conf +++ /dev/null @@ -1,61 +0,0 @@ -# Preferences for finbot: how the bot behaves. This file is only the template, which finbot never reads: the bot -# reads /preferences.conf, at start and on every reload, and without one runs on its built-in defaults. -# util/sync_defaults.py --apply copies this file there. -# Each key is commented out at its built-in default: to change one, remove its # and edit the value. -# Apply an edit without a restart: sudo systemctl reload finbot (docker: docker compose kill -s HUP finbot) -# Check an edit first: util/check_config.py /path/to/finbot.env -# Flat KEY=value lines, one per line. This is NOT an INI file: no [sections]. No spaces around "=", no quotes, no -# trailing comments; put comments on their own line. A key that is not listed here is an error and finbot refuses to -# start. -# The startup keys (credentials, ip, port, telegramChatID, ...) are NOT set here: they live in finbot.env, the startup -# file, which the launcher loads and the bot never reads. Changing them needs a restart. - -# Reports: how far back and forward to look, and when to alert -#past_days=28 -#future_days=30 -#price_percent=5 -#shorts_percent=15 -# .buy, .sell and .forward pe list only stocks that at least this many analysts cover: with fewer, a consensus says -# little -#min_analysts=7 -# price alerts: volatile and leveraged stocks need a bigger move; volatility_multiplier is how much bigger for -# volatile ones -#demote_volatile=True -#demote_leveraged=True -#volatility_multiplier=2 - -# Which portfolios to report on, space separated Sharesight portfolio IDs (empty: all that are not excluded) -#exclude_portfolios= -#include_portfolios= - -# Data sources: set trades_use_yahoo to False if Yahoo breaks -#trades_use_yahoo=True - -# Display -#graph=True -#hyperlink=True -#hyperlinkFooter=True -#hyperlinkProvider=google - -# Regional: where you are. -# exchange_suffix: a ticker Yahoo does not know is tried again on your exchange, by Yahoo's suffix for it (AX: BHP is -# tried as BHP.AX; NZ, L, TO and so on for others). Empty: a ticker is taken as written. -# country_code: your country, for the default jobs' messages that are for one (AU: the tax year's reminders). -# timezone: the clock of the times .jobs takes and shows, and a job's zone where it names none. -#exchange_suffix=AX -#country_code=AU -#timezone=Australia/Melbourne - -# Caching and network, in seconds -#cache=True -#cache_seconds=21600 -#performance_cache_seconds=2400 -#http_timeout=18 - -# Diagnostics. log_level: what the log shows, that level and those above it: ERROR, WARN, INFO (what finbot does, -# and each request it sends) or DEBUG (each cache miss, hit and write too). log_width: the most characters of a log -# line that can run long (each request's method and URL, each chat message), cut there, ending in an ellipsis (…); -# 0 cuts none. A chat message of several lines is logged on one line either way. -#log_level=INFO -#log_width=71 -#print_headers=False diff --git a/defaults/settings.conf b/defaults/settings.conf new file mode 100644 index 00000000..a3dac915 --- /dev/null +++ b/defaults/settings.conf @@ -0,0 +1,63 @@ +# finbot settings: how the bot behaves. +# +# This is only the template; finbot never reads it. The bot reads /settings.conf at +# start and on every reload, and runs on its built-in defaults without one. +# util/sync_defaults.py --apply copies this file there. +# +# Every key below is commented out at its default: remove the # to change one. +# Apply an edit: sudo systemctl reload finbot (docker: docker compose kill -s HUP finbot) +# Check it first: util/check_config.py /path/to/finbot.env +# +# Format: one KEY=value per line. Not an INI file: no [sections], no spaces around "=", +# no quotes, no trailing comments (comments go on their own line). An unknown key stops +# the start. +# Credentials, ip and port are not set here but in finbot.env, which needs a restart. + +# Reports: how far to look back and forward, and when to alert +#past_days=28 +#future_days=30 +#price_percent=5 +#shorts_percent=15 +# .buy, .sell and .forward pe list only stocks covered by at least this many analysts +#min_analysts=7 +# Price alerts: volatile and leveraged stocks need a bigger move, by volatility_multiplier +#demote_volatile=True +#demote_leveraged=True +#volatility_multiplier=2 + +# Portfolios to report on: Sharesight portfolio IDs, space separated (empty: all not excluded) +#exclude_portfolios= +#include_portfolios= + +# Data sources: set trades_use_yahoo to False if Yahoo breaks +#trades_use_yahoo=True + +# Display +#graph=True +#hyperlink=True +#hyperlinkFooter=True +#hyperlinkProvider=google + +# Regional +# exchange_suffix: a ticker Yahoo does not know is retried with your exchange's suffix +# (AX: BHP is tried as BHP.AX; NZ, L, TO and so on for others). Empty: taken as written. +# country_code: for the default jobs' messages meant for one country (AU: tax year reminders) +# timezone: the clock of the times .jobs takes and shows, and of a job that names no zone +#exchange_suffix=AX +#country_code=AU +#timezone=Australia/Melbourne + +# Caching and network, in seconds +#cache=True +#cache_seconds=21600 +#performance_cache_seconds=2400 +#http_timeout=18 + +# Diagnostics +# log_level: ERROR, WARN, INFO (what finbot does, and each request it sends) or DEBUG +# (each cache miss, hit and write too); the log shows that level and those above it +# log_width: the most characters of a long log line (a request, a chat message), cut with +# an ellipsis (…); 0 cuts none +#log_level=INFO +#log_width=71 +#print_headers=False diff --git a/doc/commands.md b/doc/commands.md index 7a3707f2..ae53fc22 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -120,7 +120,7 @@ As a message: `.AAPL`, or `@botname AAPL`. ![trade update in Slack](../img/trades.png?raw=true "Trade update in Slack") The `trades` report sends recent Sharesight trades to each chat that schedules it. -By default, this report searches Sharesight for trades from the past 28 days (`past_days`). You can override this in `preferences.conf`, or by providing a number as argument when triggered through the chat bot. +By default, this report searches Sharesight for trades from the past 28 days (`past_days`). You can override this in `settings.conf`, or by providing a number as argument when triggered through the chat bot. Without persistent storage, set `past_days=0` to avoid duplicate trade notifications. In this case, the schedule must run the report exactly once per day, after market close. @@ -149,7 +149,7 @@ You can also specify a portfolio name to get trades for just that portfolio: ![Price chart triggered from Slack](../img/finbot_price.png?raw=true "Price chart triggered from Slack") ![Price alert triggered by Cron](../img/price.png?raw=true "Price alert triggered by Cron") -The `price` report sends premarket, midsession, and interday price movements if any holdings moved over a percentage threshold. This data is sourced from Yahoo! Finance and Sharesight. The default threshold is 5% but you can change it by setting `price_percent` in the `preferences.conf`, or by providing a number as argument when triggered through the chat bot. Decimal fractions are accepted. +The `price` report sends premarket, midsession, and interday price movements if any holdings moved over a percentage threshold. This data is sourced from Yahoo! Finance and Sharesight. The default threshold is 5% but you can change it by setting `price_percent` in the `settings.conf`, or by providing a number as argument when triggered through the chat bot. Decimal fractions are accepted. If `demote_volatile` is `true`, stocks with a [market cap](https://www.investopedia.com/terms/m/marketcapitalization.asp) under US$150M, or a [beta](https://www.investopedia.com/terms/b/beta.asp) of > 1.5 and a market cap of under US$1B will have their alert threshold (`price_percent`) doubled. A market cap in another currency is converted at Yahoo's exchange rate. @@ -198,7 +198,7 @@ Or a specific stock. In this case, the output includes a graph. ### Portfolio performance ![Screenshot of Portfolio performance notification on Slack](../img/performance.png?raw=true "Screenshot performance notification on Slack") -The `performance` report covers Sharesight portfolio performance over a given period. The default is set by `past_days` in the `preferences.conf`, which can be overridden at runtime by specifying an integer as the argument. The portfolios are listed by name, then the S&P 500 and NASDAQ 100 benchmarks, in the list and the chart alike. +The `performance` report covers Sharesight portfolio performance over a given period. The default is set by `past_days` in the `settings.conf`, which can be overridden at runtime by specifying an integer as the argument. The portfolios are listed by name, then the S&P 500 and NASDAQ 100 benchmarks, in the list and the chart alike. Config example: ``` @@ -223,7 +223,7 @@ As a command, it also takes a portfolio: ### Events calendar ![earnings message in Slack](../img/earnings.png?raw=true "Earnings message in Slack") -The `cal` report sends upcoming earnings and ex-dividend date alerts. The data is sourced from Yahoo! Finance. It reports on events up to `future_days` into the future. This is set in the `preferences.conf` for a scheduled run, or can be specified as an argument when triggered through the chat bot. +The `cal` report sends upcoming earnings and ex-dividend date alerts. The data is sourced from Yahoo! Finance. It reports on events up to `future_days` into the future. This is set in the `settings.conf` for a scheduled run, or can be specified as an argument when triggered through the chat bot. **Finance Explainer:** When a company releases its quarterly earnings report, the stock price may undergo a signficant positive or negative movement, depending on whether the company beat or missed market expectations. You may wish to hold off buying more of this stock until after its earnings report, unless you think the stock will beat market expectations. @@ -250,7 +250,7 @@ Command (ex-dividend): ### Highly shorted stock warnings ![short warnings in Slack](../img/shorts.png?raw=true "Short warnings in Slack") -The `shorts` report sends short interest warnings. The data is sourced from Yahoo Finance and Shortman (AU). `shorts_percent` defines the alert threshold for the percentage of a stock's float shorted. This can be specified in the `preferences.conf` for a scheduled run, or as an argument when triggered through the chat bot. +The `shorts` report sends short interest warnings. The data is sourced from Yahoo Finance and Shortman (AU). `shorts_percent` defines the alert threshold for the percentage of a stock's float shorted. This can be specified in the `settings.conf` for a scheduled run, or as an argument when triggered through the chat bot. **Finance Explainer:** A high short ratio indicates a stock is exposed to high risks, such as potential banktrupcy. It may also incentivise negative news articles which harm the stock price. If the market is wrong, however, risk tolerant investors may receive windfall gains. This report is intended to alert you to an above-average risk, and prompt you to investigate this stock more closely. ``` @@ -265,7 +265,7 @@ Command: ### pe / forward pe / peg ![Forward PE report in Slack](../img/finbot_fpe.png?raw=true "Forward PE report in Slack") -List securities by P/E ratio, forward P/E ratio, or PEG ratio. A forward P/E is an analysts' estimate, so its lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `preferences.conf`). +List securities by P/E ratio, forward P/E ratio, or PEG ratio. A forward P/E is an analysts' estimate, so its lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `settings.conf`). Command: ``` @@ -288,7 +288,7 @@ Command: ### Buy ![Buy ratings in Slack](../img/finbot_buy.png?raw=true "Buy ratings in Slack") -Show top recommended buys by Yahoo Finance analysts rating, among stocks rated by at least `min_analysts` analysts (7 by default; see `preferences.conf`) +Show top recommended buys by Yahoo Finance analysts rating, among stocks rated by at least `min_analysts` analysts (7 by default; see `settings.conf`) Command: ``` diff --git a/doc/configuration.md b/doc/configuration.md index 74c82095..b3afb61a 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -1,6 +1,6 @@ # Configuration -Configuration is split into two files -- finbot.env for startup environment variables, and preferences.conf for values that can be changed at runtime. +Configuration is split into two files -- finbot.env for startup environment variables, and settings.conf for values that can be changed at runtime. Copy the shipped template: ``` @@ -40,13 +40,15 @@ of the chat network keys it needs. Retired keys do nothing, and only get a note at start (and from check_config) saying to delete the line: `telegramChatID`, `slackChannelID` and `discordChannelID` (there are no home chats: each chat has its own jobs, and an error that belongs to no chat goes to the log; an upgrade from before jobs per chat reads them once, as [Scheduling](scheduling.md) says), and `slack_webhook` and `discord_webhook`. -## `preferences.conf` items +## `settings.conf` items -The preferences, read by the bot itself from `/preferences.conf`; without that file, finbot runs on the built-in defaults below. `defaults/preferences.conf` is only its template: every key in it is commented out at its default, and finbot never reads it. To change a preference, copy the template into the data directory (this never overwrites a file there): +This file was called `preferences.conf` before: on an upgrade the bot renames an old `/preferences.conf` to `settings.conf` at start (it says so in the log), unless a `settings.conf` is there already. + +The preferences, read by the bot itself from `/settings.conf`; without that file, finbot runs on the built-in defaults below. `defaults/settings.conf` is only its template: every key in it is commented out at its default, and finbot never reads it. To change a preference, copy the template into the data directory (this never overwrites a file there): ``` util/sync_defaults.py [--dest DIR] --apply ``` -then remove the `#` before the key and edit its value. A key set in `defaults/preferences.conf` itself does nothing: the log at start (and check_config) says so, and where to set it instead. +then remove the `#` before the key and edit its value. A key set in `defaults/settings.conf` itself does nothing: the log at start (and check_config) says so, and where to set it instead. A change takes effect on a reload. A true/false preference takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. @@ -80,16 +82,16 @@ A change takes effect on a reload. A true/false preference takes `true`/`false`, A key in the wrong file, or one that is not listed, stops finbot starting; on a reload it is reported and nothing is applied. A key finbot no longer reads (`alliterate`, `chunk_maxlines`) is ignored, and the log at start says to delete it. -edit `preferences.conf`, then reload if running. The service log shows what changed (`config: future_days 30 -> 45`). If the file has a mistake, the reload reports it (`ERROR config: ...`), applies nothing and leaves finbot running on its previous settings. Check an edit before reloading with `util/check_config.py /opt/finbot.env`. Edit `finbot.env`, then restart: a running bot cannot see it change. +edit `settings.conf`, then reload if running. The service log shows what changed (`config: future_days 30 -> 45`). If the file has a mistake, the reload reports it (`ERROR config: ...`), applies nothing and leaves finbot running on its previous settings. Check an edit before reloading with `util/check_config.py /opt/finbot.env`. Edit `finbot.env`, then restart: a running bot cannot see it change. -**Upgrading from one `.env`?** finbot no longer reads it, and refuses to start while a `.env` is in the checkout, so its settings cannot be silently ignored. Run `util/split_env.py` (or `util/split_env.py /path/to/old.env` for one elsewhere). It reads either format, the old dotenv one (`key = 'value' # comment`) or plain `KEY=value`, writes `finbot.env.new` and `preferences.conf.new` next to the old file, never overwriting, and lists any key it dropped or did not recognise. Put `finbot.env` where your launcher loads it and `preferences.conf` in the data directory, then delete the old file. +**Upgrading from one `.env`?** finbot no longer reads it, and refuses to start while a `.env` is in the checkout, so its settings cannot be silently ignored. Run `util/split_env.py` (or `util/split_env.py /path/to/old.env` for one elsewhere). It reads either format, the old dotenv one (`key = 'value' # comment`) or plain `KEY=value`, writes `finbot.env.new` and `settings.conf.new` next to the old file, never overwriting, and lists any key it dropped or did not recognise. Put `finbot.env` where your launcher loads it and `settings.conf` in the data directory, then delete the old file. ## Data directory All state and cache lives under `var/` in the checkout by default. Set `DATA_DIR` to relocate it (cache moves to `$DATA_DIR/cache`); in Docker it is `/data`, the host's `/opt/finbot-data` (see [docker.md](docker.md)). Reference data (`finbot_adr.json`, `finbot_sws_*.json`) ships in `defaults/`. A file of the same name in the data directory takes precedence. Defaults are never copied or overwritten automatically; to see or pull in updates run: ``` util/sync_defaults.py # dry run -util/sync_defaults.py --apply # copy missing files (including a starter preferences.conf) +util/sync_defaults.py --apply # copy missing files (including a starter settings.conf) util/sync_defaults.py --force # also replace changed files, keeping .bak copies util/sync_defaults.py --dest /srv/finbot --apply # target another directory instead of the data directory util/sync_defaults.py --dest /srv/finbot --create --apply # and make it (mode 750) if it does not exist yet @@ -110,7 +112,7 @@ sharesight_client_secret=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e9981 * Sharesight is optional. Without its keys, a chat's reports cover its watchlist alone, so add your holdings to it (`/watchlist add SYMBOL`); `/holdings`, `/trades`, `/performance`, `/who`, `/pie` and `/treemap` say Sharesight is not set up, and the trades and performance jobs are not among the default jobs. ## Portfolios -Portfolios are auto-discovered, including other people's portfolios which are shared to you. To exclude specific portfolios, add their IDs to `exclude_portfolios` in the `preferences.conf`: +Portfolios are auto-discovered, including other people's portfolios which are shared to you. To exclude specific portfolios, add their IDs to `exclude_portfolios` in the `settings.conf`: ``` exclude_portfolios=100003 100004 ``` @@ -121,7 +123,7 @@ include_portfolios=100001 100002 ``` ## Hyperlinks -These `preferences.conf` options relate to hyperlinks: +These `settings.conf` options relate to hyperlinks: * `hyperlink`, if set to `False`, disables all hyperlinks. Note: it can not disable syntax highlighting and false positives by chat clients that try to detect domains. Defaults to `True`. ``` diff --git a/doc/docker.md b/doc/docker.md index c092d5b4..6e6a05c8 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -19,7 +19,7 @@ The configuration lives on the host, beside the checkout, so a rebuild or an upd |---|---|---| | `/opt/finbot` | the checkout, with `Dockerfile` and `compose.yaml` | the image's `/opt/finbot`, copied at build | | `/opt/finbot.env` | the startup file: the tokens, and the mode of each network | the environment, and `/run/finbot.env`, read-only, for the check | -| `/opt/finbot-data` | `DATA_DIR`: `preferences.conf`, each chat's jobs and watchlist, the records, the cache | `/data` | +| `/opt/finbot-data` | `DATA_DIR`: `settings.conf`, each chat's jobs and watchlist, the records, the cache | `/data` | `compose.yaml` finds the other two beside the checkout (`../finbot.env`, `../finbot-data`): clone it elsewhere and they move with it. The startup file stays out of the image because it holds the secrets, and the data dir because it @@ -29,7 +29,7 @@ must outlast every container. Run it once, as root; it is safe to run again. It: 1. runs `util/sync_defaults.py --create --apply --dest /opt/finbot-data`, which makes the data dir (mode 750) and copies - the shipped defaults into it: `preferences.conf` and the data files. It never replaces a file you + the shipped defaults into it: `settings.conf` and the data files. It never replaces a file you have changed. The script needs only the standard library, so any `python3` on the host runs it. 2. gives the data dir to the container's user: `chown -R 10001:10001 /opt/finbot-data`. 3. copies `defaults/finbot.env` to `/opt/finbot.env` if there is none, owned by `root:10001`, mode 640: root and the @@ -55,7 +55,7 @@ cd /opt/finbot && sudo GIT_COMMIT=$(git rev-parse HEAD) docker compose up -d --b Each start of the container, in order: 1. `util/sync_defaults.py --apply` copies into `/data` any default it lacks, such as a data file added by an update; -2. `util/check_config.py /run/finbot.env` checks the startup file and `preferences.conf`. A mistake stops the start +2. `util/check_config.py /run/finbot.env` checks the startup file and `settings.conf`. A mistake stops the start here, and the log says which key and why; 3. the bot starts. @@ -71,7 +71,7 @@ file beside `compose.yaml`), and read it with `sudo docker compose logs -f`. ## Reload, flush, update ``` -sudo docker compose kill -s HUP finbot # re-read preferences.conf +sudo docker compose kill -s HUP finbot # re-read settings.conf sudo docker compose kill -s USR1 finbot # flush the cache, and fill it again sudo docker compose exec finbot util/flush.py --after 24h # flush the files of the last day (see caching.md) cd /opt/finbot && sudo git pull && sudo GIT_COMMIT=$(git rev-parse HEAD) docker compose up -d --build @@ -118,7 +118,7 @@ then `util/docker_setup.sh` (it keeps your files) and start as above. The startu ## Troubleshooting * **The container stops at once and keeps restarting:** the log says why. `ERROR config: ... unknown key ...` is a - mistake in `/opt/finbot.env` or `preferences.conf`; fix it and `sudo docker compose up -d`. + mistake in `/opt/finbot.env` or `settings.conf`; fix it and `sudo docker compose up -d`. * **`PermissionError` on `/data`:** the data dir is not the container user's: `sudo chown -R 10001:10001 /opt/finbot-data`. * **`/data does not exist: --create makes it`:** the data dir is not mounted: `compose.yaml` mounts diff --git a/doc/install.md b/doc/install.md index 20ca35ec..2bed4d0c 100644 --- a/doc/install.md +++ b/doc/install.md @@ -71,7 +71,7 @@ Under systemd (below), `ExecStart=` runs the venv's Python: `/home//finbot/ `finbot.service` can take care of keeping `bot.py` running in the background and starting it on boot. Copy `finbot.service` to `/etc/systemd/system/`, edit it to set the `User`, `ExecStart` and the path of [`finbot.env`](configuration.md#finbotenv) (`EnvironmentFile=`, and the `ExecStartPre=` check), then enable and start -it. `systemctl reload finbot` re-reads `preferences.conf` without a restart; a change to `finbot.env` needs +it. `systemctl reload finbot` re-reads `settings.conf` without a restart; a change to `finbot.env` needs `systemctl restart finbot`: ``` sudo cp -v ~/finbot/finbot.service /etc/systemd/system/ diff --git a/finbot.service b/finbot.service index c3d84e22..73a1cd08 100644 --- a/finbot.service +++ b/finbot.service @@ -6,9 +6,9 @@ Type=simple User=CHANGEME # Or instead of User=: DynamicUser=yes and StateDirectory=finbot (state in /var/lib/finbot) ExecStart=/home/CHANGEME/finbot/bot.py -# systemctl reload finbot re-reads preferences.conf without a restart +# systemctl reload finbot re-reads settings.conf without a restart ExecReload=/bin/kill -HUP $MAINPID -# Refuse to start on a typo in finbot.env or preferences.conf ("+" runs the check as root) +# Refuse to start on a typo in finbot.env or settings.conf ("+" runs the check as root) ExecStartPre=+/home/CHANGEME/finbot/util/check_config.py /opt/finbot.env Restart=on-failure RestartSec=30s diff --git a/lib/config.py b/lib/config.py index 1b76ad7a..08f0bd5d 100644 --- a/lib/config.py +++ b/lib/config.py @@ -10,8 +10,8 @@ # finbot.env the startup file: DATA_DIR, ip, port, credentials and the mode of each network. Never read by the # bot: the launcher loads it into the environment (systemd EnvironmentFile=, docker --env-file, # init.d `set -a; . finbot.env`). A change needs a restart. -# preferences.conf how the bot behaves: /preferences.conf, read by the bot itself at start and on every -# reload (SIGHUP). +# settings.conf how the bot behaves: /settings.conf, read by the bot itself at start and on every +# reload (SIGHUP). An older preferences.conf there is renamed to it at start. REPO_DIR = Path(__file__).resolve().parent.parent @@ -22,7 +22,8 @@ SECRET_KEYS = ('sharesight_code', 'sharesight_client_id', 'sharesight_client_secret', 'slackBotToken', 'slackAppToken', 'slackOutgoingToken', 'slackOutgoingWebhook', 'slackSigningSecret', 'telegramBotToken', 'telegramOutgoingToken', 'telegramOutgoingWebhook', 'discordBotToken', 'discordOutgoingWebhook') # never printed -PREFERENCES_FILE = 'preferences.conf' +PREFERENCES_FILE = 'settings.conf' +OLD_PREFERENCES_FILE = 'preferences.conf' # what settings.conf was called: an upgrade renames it (adopt_old_file) TEMPLATE = REPO_DIR / 'defaults' / PREFERENCES_FILE # every key commented out at its default; finbot never reads it HOME_CHAT = ("each chat has its own jobs (/jobs), which post there as the bot, and an error that belongs to no chat" ' goes to the log; an upgrade from before jobs per chat reads it once more, to give that chat the old schedule,' @@ -106,7 +107,7 @@ def read_text(path): def read_preferences(path): - """{key: value} of a preferences.conf, None if there is none. Raises ConfigError for anything wrong with it.""" + """{key: value} of a settings.conf, None if there is none. Raises ConfigError for anything wrong with it.""" text = read_text(path) if text is None: return None @@ -117,8 +118,22 @@ def read_preferences(path): return values +def adopt_old_file(data_dir): + """An upgrade from when settings.conf was called preferences.conf: the old file in `data_dir` becomes + settings.conf, unless there is one already, so that no setting is lost in silence. Says so on stderr (the log is not + up yet); where the rename fails, the old file is left and a note says to rename it.""" + old, new = Path(data_dir, OLD_PREFERENCES_FILE), Path(data_dir, PREFERENCES_FILE) + if not old.is_file() or new.exists(): + return + try: + old.rename(new) + print(f'config: renamed {old} to {new}', file=sys.stderr) + except OSError as e: + print(f'WARN config: rename {old} to {new}: finbot no longer reads the old name ({e.strerror})', + file=sys.stderr) + def template_keys(): - """The keys that defaults/preferences.conf sets: its lines that are not commented out, none as shipped. finbot never + """The keys that defaults/settings.conf sets: its lines that are not commented out, none as shipped. finbot never reads the template, so a key set there does nothing; an unreadable template sets nothing either.""" try: text = read_text(TEMPLATE) or '' @@ -129,7 +144,7 @@ def template_keys(): def template_note(preferences_path): - """What to tell whoever set keys in defaults/preferences.conf, which finbot never reads: to set them in + """What to tell whoever set keys in defaults/settings.conf, which finbot never reads: to set them in preferences_path instead, made first with util/sync_defaults.py when there is none. None when the template sets nothing, as shipped.""" keys = template_keys() @@ -137,7 +152,7 @@ def template_note(preferences_path): return None fix = (f'edit {preferences_path} instead' if os.path.isfile(preferences_path) else f'run util/sync_defaults.py --apply, then edit {preferences_path}') - return f"defaults/preferences.conf sets {', '.join(keys)}, but finbot never reads it: {fix}" + return f"defaults/settings.conf sets {', '.join(keys)}, but finbot never reads it: {fix}" def read_startup_file(path): @@ -226,7 +241,7 @@ def check(self): @dataclasses.dataclass(frozen=True) class Env: """The startup settings: finbot.env, which the launcher loads into the environment; fixed for the process.""" - data_dir: str # state: each chat's jobs, the watchlist, seen trades, ratings, preferences.conf + data_dir: str # state: each chat's jobs, the watchlist, seen trades, ratings, settings.conf cache_dir: str # data_dir/cache ip: str port: int @@ -247,7 +262,7 @@ class Env: @property def preferences_path(self): - """Where preferences.conf is read from: the data dir.""" + """Where settings.conf is read from: the data dir.""" return self.data_dir + '/' + PREFERENCES_FILE @@ -288,7 +303,7 @@ def startup_values(getenv): @dataclasses.dataclass(frozen=True) class Prefs: - """The preferences: preferences.conf, which the bot reads at start and on every reload, each with its default here + """The preferences: settings.conf, which the bot reads at start and on every reload, each with its default here and read as its type says (preference_values). Read them as config.prefs. when they are used, never into a default or a module constant, so that a reload is seen.""" cache: bool = True @@ -321,7 +336,7 @@ class Prefs: def preference_values(getenv, source=None): - """The preferences (preferences.conf), computed from getenv(name, default): each read as its Prefs field's type + """The preferences (settings.conf), computed from getenv(name, default): each read as its Prefs field's type says (a tuple is a list of words), its default the field's. `source` is the file's own {key: value}. Raises ConfigError listing every bad value.""" r = _Reader(getenv) @@ -335,14 +350,14 @@ def preference_values(getenv, source=None): return Prefs(**values, source=dict(source or {})) -# preferences.conf's keys: what a reload can change +# settings.conf's keys: what a reload can change PREFERENCE_KEYS = tuple(field.name for field in dataclasses.fields(Prefs) if field.compare) def _getenv_for(preferences): - """getenv(name, default): preferences.conf first (preference keys only), then the process environment.""" + """getenv(name, default): settings.conf first (preference keys only), then the process environment.""" def getenv(name, default=None): - """A preference from preferences.conf, else the process environment's value, else default.""" + """A preference from settings.conf, else the process environment's value, else default.""" if name in preferences: return preferences[name] return os.environ.get(name, default) @@ -362,6 +377,7 @@ def _fail(e): try: env = startup_values(os.environ.get) + adopt_old_file(env.data_dir) preferences_path = env.preferences_path _preferences = read_preferences(preferences_path) or {} prefs = preference_values(_getenv_for(_preferences), _preferences) @@ -404,7 +420,7 @@ def data_json(name, default=None): def reload(): - """Re-read preferences.conf and apply it: config.prefs is replaced in one assignment, so a request never sees half + """Re-read settings.conf and apply it: config.prefs is replaced in one assignment, so a request never sees half of a reload. All or nothing. Returns one line per change; raises ConfigError (nothing applied) if the file is missing, unreadable or invalid.""" global prefs @@ -419,7 +435,7 @@ def reload(): def check_startup_environment(): - """For bot.py at start. Preferences must come from preferences.conf, not from the environment that finbot.env fills, + """For bot.py at start. Preferences must come from settings.conf, not from the environment that finbot.env fills, and a leftover .env from before the split stops the start: its settings would otherwise be silently ignored.""" problems = [f'{key} is a preference; move it from finbot.env to {PREFERENCES_FILE}' for key in PREFERENCE_KEYS if key in os.environ] @@ -430,7 +446,7 @@ def check_startup_environment(): problems.append(f'{OLD_ENV} is the old single config file, which finbot no longer reads; it will not start' ' while the file is there') problems.append(f'split it with {REPO_DIR}/util/split_env.py, which writes finbot.env.new and' - f' preferences.conf.new beside it; put finbot.env where your launcher loads it and {PREFERENCES_FILE} in' + f' settings.conf.new beside it; put finbot.env where your launcher loads it and {PREFERENCES_FILE} in' f' {env.data_dir}, then delete {OLD_ENV}') if problems: raise ConfigError(problems) @@ -456,7 +472,7 @@ def retired_notes(values, file=PREFERENCES_FILE): def startup_warnings(settings=None): """Settings in finbot.env (an Env; the running one by default), and keys set in the template - defaults/preferences.conf, that do not stop the start but keep part of the bot from working, or do nothing, a line + defaults/settings.conf, that do not stop the start but keep part of the bot from working, or do nothing, a line each.""" s = settings or env warnings = [] diff --git a/tests/test_config.py b/tests/test_config.py index 1ceaa27c..066dd1e3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,4 @@ -"""The two config files: finbot.env (the startup file, which the launcher loads) and preferences.conf (reloadable).""" +"""The two config files: finbot.env (the startup file, which the launcher loads) and settings.conf (reloadable).""" import ast, contextlib, dataclasses, io, os, re, shutil, subprocess, sys, types, unittest from pathlib import Path from unittest import mock @@ -32,7 +32,7 @@ def test_the_two_key_sets_are_disjoint_and_cover_everything_config_reads(self): self.assertEqual(read, set(config.STARTUP_KEYS)) def test_secrets_are_startup_keys(self): - """Every secret is a startup key: preferences.conf holds none.""" + """Every secret is a startup key: settings.conf holds none.""" self.assertTrue(set(config.SECRET_KEYS) <= set(config.STARTUP_KEYS)) self.assertEqual(len(config.SECRET_KEYS), 13) @@ -42,9 +42,9 @@ def test_unset_preferences_take_their_defaults(self): self.assertEqual(fields(values), fields(config.Prefs())) def test_templates_have_every_key_and_the_real_defaults(self): - """defaults/preferences.conf lists every key, commented out at its built-in default, so that it sets none; + """defaults/settings.conf lists every key, commented out at its built-in default, so that it sets none; finbot.env lists every key; both are valid as shipped.""" - template = REPO / 'defaults' / 'preferences.conf' + template = REPO / 'defaults' / 'settings.conf' self.assertEqual(sorted(keys_in(template)), sorted(config.PREFERENCE_KEYS)) self.assertEqual(sorted(keys_in(REPO / 'defaults' / 'finbot.env')), sorted(config.STARTUP_KEYS)) self.assertEqual((config.read_preferences(template), config.template_keys()), ({}, [])) # it sets nothing @@ -58,7 +58,7 @@ def test_templates_have_every_key_and_the_real_defaults(self): class Parsing(unittest.TestCase): def parse(self, text): """(what read_preferences makes of text, the file it was written to).""" - path = Path(common.temp_dir(self), 'preferences.conf') + path = Path(common.temp_dir(self), 'settings.conf') path.write_text(text) return config.read_preferences(path), path @@ -76,7 +76,7 @@ def test_comments_blanks_quotes_and_equals_in_values(self): {'future_days': '30', 'hyperlinkProvider': 'google', 'exclude_portfolios': '1 2', 'country_code': ''}) def test_missing_file_is_none(self): - """No preferences.conf is not an error: None.""" + """No settings.conf is not an error: None.""" self.assertIsNone(config.read_preferences(Path(common.temp_dir(self), 'nope'))) def test_unknown_key_gets_a_hint_and_a_line_number(self): @@ -86,7 +86,7 @@ def test_unknown_key_gets_a_hint_and_a_line_number(self): self.assertIn('line 2: unknown key futur_days (did you mean future_days?)', errors[0]) def test_a_startup_key_in_preferences_says_where_it_belongs(self): - """A startup key in preferences.conf is sent to finbot.env, without printing its value.""" + """A startup key in settings.conf is sent to finbot.env, without printing its value.""" errors = self.errors('slackOutgoingWebhook=https://x\nip=0.0.0.0\nport=5\nscheduler_catchup=3\n') self.assertEqual(len(errors), 4) for line in errors: @@ -146,14 +146,14 @@ def test_log_level_is_one_of_syslogs_levels_in_any_case(self): self.assertEqual(caught.exception.errors, ["log_level='loud' is not one of ERROR, WARN, INFO, DEBUG"]) def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): - """A key finbot no longer reads (alliterate in preferences.conf, slack_webhook in finbot.env, say) does not stop + """A key finbot no longer reads (alliterate in settings.conf, slack_webhook in finbot.env, say) does not stop a start, as an unknown key would: it is ignored, and the log at start (and check_config) says what to do instead.""" - path = Path(common.temp_dir(self), 'preferences.conf') + path = Path(common.temp_dir(self), 'settings.conf') path.write_text(''.join(f'{key}=True\n' for key in config.RETIRED) + 'future_days=45\n') values = config.read_preferences(path) self.assertEqual(config.preference_values(config._getenv_for(values)).future_days, 45) - notes = [f'{key} in preferences.conf does nothing any more: {instead}' + notes = [f'{key} in settings.conf does nothing any more: {instead}' for key, instead in config.RETIRED.items()] self.assertEqual(config.retired_notes(values), notes) self.assertEqual(check_config.notes(path), config.retired_notes(values)) @@ -172,7 +172,7 @@ def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): def test_unreadable_file_is_an_error_naming_it(self): """A preferences file that cannot be read is an error that names it.""" - path = Path(common.temp_dir(self), 'preferences.conf') + path = Path(common.temp_dir(self), 'settings.conf') path.mkdir() # reading a directory fails like a permission error would with self.assertRaises(config.ConfigError) as caught: config.read_preferences(path) @@ -201,7 +201,7 @@ def test_startup_file_linting(self): path.write_text('ip=1.2.3.4\nfuture_days=3\nslackBotTokn=x\nport=http\n') problems = check_config.check(path, Path(common.temp_dir(self), 'none')) self.assertEqual(len(problems), 2) # the two bad lines; the bad value is only found once the lines are valid - self.assertIn('future_days belongs in preferences.conf (it is reloadable)', problems[0]) + self.assertIn('future_days belongs in settings.conf (it is reloadable)', problems[0]) self.assertIn('unknown key slackBotTokn (did you mean slackBotToken?)', problems[1]) path.write_text('port=http\n') nowhere = Path(common.temp_dir(self), 'none') @@ -223,8 +223,8 @@ def test_check_config_gives_the_startup_warnings_of_a_valid_file(self): class Reload(unittest.TestCase): def setUp(self): - """An empty preferences.conf of this test's own, put back to the defaults afterwards.""" - self.file = Path(common.temp_dir(self), 'preferences.conf') + """An empty settings.conf of this test's own, put back to the defaults afterwards.""" + self.file = Path(common.temp_dir(self), 'settings.conf') self.file.write_text('') patch = mock.patch.object(config, 'preferences_path', str(self.file)) patch.start() @@ -342,25 +342,25 @@ def test_a_scheduled_job_runs_on_the_preferences_in_force_not_the_edited_file(se class Startup(unittest.TestCase): def run_config(self, code='from lib import config as c; print(c.prefs.future_days)', prefs=None, **env): - """Run code in a new interpreter: own data dir, prefs as preferences.conf, no settings in the environment.""" + """Run code in a new interpreter: own data dir, prefs as settings.conf, no settings in the environment.""" data = common.temp_dir(self) if prefs is not None: - Path(data, 'preferences.conf').write_text(prefs) + Path(data, 'settings.conf').write_text(prefs) env = {**common.bare_environment(), 'DATA_DIR': data, **env} return subprocess.run([sys.executable, '-c', code], cwd=REPO, env=env, capture_output=True, text=True) def test_no_preferences_file_is_fine(self): - """finbot starts without a preferences.conf, on the defaults.""" + """finbot starts without a settings.conf, on the defaults.""" out = self.run_config() self.assertEqual((out.returncode, out.stdout.split()[-1]), (0, '30')) def test_a_preferences_file_is_read_at_start(self): - """preferences.conf is read at start.""" + """settings.conf is read at start.""" self.assertEqual(self.run_config(prefs='future_days=45\n').stdout.split()[-1], '45') def test_a_broken_preferences_file_refuses_to_start(self): - """A bad value, unknown key, startup key or malformed line in preferences.conf stops the start, saying why.""" + """A bad value, unknown key, startup key or malformed line in settings.conf stops the start, saying why.""" for prefs, expect in (('future_days=abc\n', "future_days='abc' is not a whole number"), ('futur_days=1\n', 'unknown key futur_days'), ('slackBotToken=x\n', 'belongs in finbot.env'), ('what\n', 'expected KEY=value')): @@ -370,30 +370,30 @@ def test_a_broken_preferences_file_refuses_to_start(self): self.assertIn(expect, out.stderr) def test_the_template_is_never_read_and_a_key_set_there_is_pointed_elsewhere(self): - """defaults/preferences.conf is only a template: a key set there changes nothing, and the log at start says to - set it in the data dir's preferences.conf, or to make that file first with util/sync_defaults.py.""" + """defaults/settings.conf is only a template: a key set there changes nothing, and the log at start says to + set it in the data dir's settings.conf, or to make that file first with util/sync_defaults.py.""" tree = Path(common.temp_dir(self)) # config.py and a template of its own, which sets a key (tree / 'lib').mkdir() shutil.copy(REPO / 'lib' / 'config.py', tree / 'lib') (tree / 'defaults').mkdir() - (tree / 'defaults' / 'preferences.conf').write_text('# a comment\n#past_days=28\nfuture_days=45\n') + (tree / 'defaults' / 'settings.conf').write_text('# a comment\n#past_days=28\nfuture_days=45\n') code = 'from lib import config as c; print(c.prefs.future_days); print(*c.startup_warnings(), sep="\\n")' for made in (False, True): data = common.temp_dir(self) if made: - Path(data, 'preferences.conf').write_text('#future_days=30\n') + Path(data, 'settings.conf').write_text('#future_days=30\n') with self.subTest(preferences_conf=made): out = subprocess.run([sys.executable, '-c', code], cwd=tree, env={**common.bare_environment(), 'DATA_DIR': data}, capture_output=True, text=True) - conf = f'{data}/preferences.conf' + conf = f'{data}/settings.conf' fix = f'edit {conf} instead' if made else f'run util/sync_defaults.py --apply, then edit {conf}' self.assertEqual(out.stdout.split('\n')[:2], ['30', - f'defaults/preferences.conf sets future_days, but finbot never reads it: {fix}'], out.stderr) + f'defaults/settings.conf sets future_days, but finbot never reads it: {fix}'], out.stderr) def test_an_unreadable_preferences_file_refuses_to_start(self): - """A preferences.conf that cannot be read stops the start.""" + """A settings.conf that cannot be read stops the start.""" data = common.temp_dir(self) - Path(data, 'preferences.conf').mkdir() + Path(data, 'settings.conf').mkdir() out = subprocess.run([sys.executable, '-c', 'from lib import config'], cwd=REPO, env={**os.environ, 'DATA_DIR': data}, capture_output=True, text=True) self.assertEqual(out.returncode, 1) @@ -412,7 +412,7 @@ def test_a_preference_in_the_environment_is_refused_by_the_bot(self): with self.assertRaises(config.ConfigError) as caught: config.check_startup_environment() self.assertEqual(len(caught.exception.errors), 2) - self.assertIn('future_days is a preference; move it from finbot.env to preferences.conf', + self.assertIn('future_days is a preference; move it from finbot.env to settings.conf', '\n'.join(caught.exception.errors)) with mock.patch.dict(os.environ, {'ip': '1.2.3.4', 'port': '80'}): config.check_startup_environment() # startup keys are what the environment is for @@ -434,7 +434,7 @@ def test_a_leftover_env_in_the_checkout_refuses_to_start(self): config.check_startup_environment() self.assertEqual(len(caught.exception.errors), 2) self.assertIn(f'{old} is the old single config file, which finbot no longer reads', caught.exception.errors[0]) - self.assertIn('/util/split_env.py, which writes finbot.env.new and preferences.conf.new', + self.assertIn('/util/split_env.py, which writes finbot.env.new and settings.conf.new', caught.exception.errors[1]) def test_the_telegram_api_is_the_bot_tokens(self): @@ -511,14 +511,14 @@ def test_startup_report_is_a_short_count_never_names_or_values(self): self.assertNotIn('slackOutgoingWebhook', ''.join(lines)) with mock.patch.dict(os.environ, {'port': '5000'}, clear=True): self.assertEqual(config.startup_report()[0], 'startup environment (1 key set)') - conf = Path(common.temp_dir(self), 'preferences.conf') + conf = Path(common.temp_dir(self), 'settings.conf') conf.write_text('graph = false\n') for source, count in (({'graph': 'false'}, '1 key'), ({'log_level': 'DEBUG', 'graph': 'false'}, '2 keys')): with self.subTest(source=source), mock.patch.object(config, 'preferences_path', str(conf)), \ mock.patch.object(config, 'prefs', dataclasses.replace(config.prefs, source=source)): self.assertEqual(config.startup_report()[1], f'{conf} ({count} set; SIGHUP to reload)') with mock.patch.object(config, 'preferences_path', str(conf.with_name('missing.conf'))): - self.assertEqual(config.startup_report()[1], f'no preferences.conf in {config.env.data_dir}, reload' + self.assertEqual(config.startup_report()[1], f'no settings.conf in {config.env.data_dir}, reload' ' unavailable') @@ -532,7 +532,7 @@ def restore(self, file): def test_reload_all_reloads_preferences_and_keeps_them_on_an_error(self): """SIGHUP reloads preferences; on an error (logged) the running ones stay, and the bot survives it. The jobs need no reload: a change made with .jobs counts at once.""" - file = Path(common.temp_dir(self), 'preferences.conf') + file = Path(common.temp_dir(self), 'settings.conf') file.write_text('future_days=45\n') self.addCleanup(self.restore, file) with mock.patch.object(config, 'preferences_path', str(file)): @@ -548,9 +548,9 @@ def test_reload_all_reloads_preferences_and_keeps_them_on_an_error(self): self.assertEqual(config.prefs.future_days, 45) def test_a_reload_points_a_key_set_in_the_template_at_the_file_to_set_it_in(self): - """A key set in defaults/preferences.conf, which finbot never reads, is reported on a reload too, with the file + """A key set in defaults/settings.conf, which finbot never reads, is reported on a reload too, with the file to set it in.""" - file = Path(common.temp_dir(self), 'preferences.conf') + file = Path(common.temp_dir(self), 'settings.conf') file.write_text('') self.addCleanup(self.restore, file) template = Path(common.temp_dir(self), 'template.conf') @@ -558,7 +558,7 @@ def test_a_reload_points_a_key_set_in_the_template_at_the_file_to_set_it_in(self with mock.patch.object(config, 'preferences_path', str(file)), mock.patch.object(config, 'TEMPLATE', template): with contextlib.redirect_stderr(io.StringIO()) as err: bot.reload_all() - self.assertIn(f'WARN config: defaults/preferences.conf sets log_level, but finbot never reads it: edit {file}' + self.assertIn(f'WARN config: defaults/settings.conf sets log_level, but finbot never reads it: edit {file}' ' instead', err.getvalue()) self.assertEqual(config.prefs.log_level, 'INFO') @@ -585,7 +585,7 @@ def test_split_env_reads_the_old_dotenv_format_too(self): self.assertEqual(notes, ['.env line 5: not KEY=value, left out']) def test_split_env_writes_next_to_the_file_and_never_overwrites(self): - """split_env writes finbot.env.new (mode 0600) and preferences.conf.new beside the file, refuses to overwrite + """split_env writes finbot.env.new (mode 0600) and settings.conf.new beside the file, refuses to overwrite them, and says so for a missing file.""" d = Path(common.temp_dir(self)) (d / 'old.env').write_text('port=80\nfuture_days=3\n') @@ -593,7 +593,7 @@ def test_split_env_writes_next_to_the_file_and_never_overwrites(self): capture_output=True, text=True) self.assertEqual(run().returncode, 0) self.assertIn('port=80', (d / 'finbot.env.new').read_text()) - self.assertIn('future_days=3', (d / 'preferences.conf.new').read_text()) + self.assertIn('future_days=3', (d / 'settings.conf.new').read_text()) self.assertEqual(oct((d / 'finbot.env.new').stat().st_mode & 0o777), '0o600') out = run() self.assertNotEqual(out.returncode, 0) @@ -620,3 +620,30 @@ def test_check_config_script_exit_status_and_it_does_not_create_directories(self if __name__ == '__main__': unittest.main() + + +class OldFileName(unittest.TestCase): + """An upgrade from when settings.conf was called preferences.conf.""" + + def test_the_old_file_becomes_settings_conf(self): + """preferences.conf in the data dir is renamed to settings.conf, its settings kept and the rename said on + stderr; a settings.conf already there is left alone, and the old file with it.""" + data = Path(common.temp_dir(self)) + (data / 'preferences.conf').write_text('future_days=45\n') + with contextlib.redirect_stderr(io.StringIO()) as err: + config.adopt_old_file(str(data)) + self.assertEqual(((data / 'settings.conf').read_text(), (data / 'preferences.conf').exists()), + ('future_days=45\n', False)) + self.assertIn(f'config: renamed {data}/preferences.conf to {data}/settings.conf', err.getvalue()) + (data / 'preferences.conf').write_text('future_days=99\n') + config.adopt_old_file(str(data)) + self.assertEqual(((data / 'settings.conf').read_text(), (data / 'preferences.conf').exists()), + ('future_days=45\n', True)) + + def test_nothing_to_rename(self): + """No old file, or no data dir, is no error and says nothing.""" + data = Path(common.temp_dir(self)) + with contextlib.redirect_stderr(io.StringIO()) as err: + config.adopt_old_file(str(data)) + config.adopt_old_file(str(data / 'missing')) + self.assertEqual((err.getvalue(), list(data.iterdir())), ('', [])) diff --git a/tests/test_datadir.py b/tests/test_datadir.py index 95701d8c..f471f5f6 100644 --- a/tests/test_datadir.py +++ b/tests/test_datadir.py @@ -92,8 +92,8 @@ def test_sync_defaults(self): self.assertFalse(Path(config.env.data_dir, 'finbot_adr.json').exists()) # dry run sync_defaults.sync(apply=True) self.assertTrue(Path(config.env.data_dir, 'finbot_adr.json').exists()) - copied = Path(config.env.data_dir, 'preferences.conf') # the template as it is: every key commented out - self.assertEqual(copied.read_bytes(), (config.REPO_DIR / 'defaults' / 'preferences.conf').read_bytes()) + copied = Path(config.env.data_dir, 'settings.conf') # the template as it is: every key commented out + self.assertEqual(copied.read_bytes(), (config.REPO_DIR / 'defaults' / 'settings.conf').read_bytes()) self.assertEqual(config.read_preferences(copied), {}) # the startup file is never copied into the data dir self.assertFalse(Path(config.env.data_dir, 'finbot.env').exists()) @@ -135,7 +135,7 @@ def test_sync_defaults_runs_with_any_python_and_creates_nothing_unasked(self): self.assertIn('does not exist: --create makes it', refused.stderr) made = run('--create', '--apply', '--dest', str(dest)) self.assertEqual(made.returncode, 0, made.stderr) - self.assertTrue((dest / 'preferences.conf').exists()) + self.assertTrue((dest / 'settings.conf').exists()) self.assertFalse(Path(tmp, 'never').exists()) diff --git a/tests/test_edges.py b/tests/test_edges.py index cad3f4d8..c64b9685 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -582,7 +582,7 @@ def test_sighup_during_startup_is_ignored_and_works_afterwards(self): break self.assertIsNone(proc.poll(), ''.join(lines)) proc.send_signal(signal.SIGHUP) - for line in proc.stderr: # a reload now runs (there is no preferences.conf here, so it says so) + for line in proc.stderr: # a reload now runs (there is no settings.conf here, so it says so) lines.append(line) if 'reload requested' in line: break @@ -599,7 +599,7 @@ def test_usr1_flushes_the_cache_and_keeps_the_state(self): for name in cached: # the last: a write in flight (cache / name).write_text('{}') Path(data, 'finbot_watchlist.json').write_text('["T1"]') # state, not cache - Path(data, 'preferences.conf').write_text('cache=False\n') # the warm jobs a flush starts fetch nothing + Path(data, 'settings.conf').write_text('cache=False\n') # the warm jobs a flush starts fetch nothing no_jobs(data) env = bare_environment() env.update(DATA_DIR=data, port='0') diff --git a/util/check_config.py b/util/check_config.py index 667ffcd7..fa2fc7b3 100755 --- a/util/check_config.py +++ b/util/check_config.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Lint finbot's two config files with the rules the bot applies, without starting it. - util/check_config.py /opt/finbot.env [/var/lib/finbot/preferences.conf] + util/check_config.py /opt/finbot.env [/var/lib/finbot/settings.conf] finbot.env (the startup file) is never read by the bot, so this is the only place its typos are caught: an unknown key, -a preference key in it, a malformed line or a bad value. preferences.conf defaults to /preferences.conf and is +a preference key in it, a malformed line or a bad value. settings.conf defaults to /settings.conf and is checked the way the bot checks it at start and on reload. Exits 1 and prints every problem if either file is wrong, so a launcher can refuse to start (see doc/install.md and doc/docker.md).""" @@ -67,14 +67,14 @@ def notes(preferences_path, startup_path=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('startup', help='the finbot.env to check (the startup file)') ap.add_argument('preferences', nargs='?', default=config.preferences_path, - help='default: /preferences.conf') + help='default: /settings.conf') args = ap.parse_args() problems = check(args.startup, args.preferences) for line in problems: print('ERROR config:', line, file=sys.stderr) if not problems: print('config: ok', args.startup, - args.preferences if Path(args.preferences).is_file() else '(no preferences.conf, defaults)') + args.preferences if Path(args.preferences).is_file() else '(no settings.conf, defaults)') for line in warnings(args.startup): print('config: warning:', line) for line in notes(args.preferences, args.startup): diff --git a/util/split_env.py b/util/split_env.py index 253119e0..7a63f983 100755 --- a/util/split_env.py +++ b/util/split_env.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""Split an old single .env into the two config files: finbot.env (the startup file) and preferences.conf. +"""Split an old single .env into the two config files: finbot.env (the startup file) and settings.conf. util/split_env.py [old file] (default: the .env in the checkout, which finbot will not start with) Reads the old file in either format: dotenv (key = 'value' # comment, export key=value) or plain KEY=value. Writes -finbot.env.new and preferences.conf.new next to it; it never overwrites anything. Review them, then move them into -place: finbot.env wherever your launcher loads it from, preferences.conf into DATA_DIR. Then delete the old file. Keys +finbot.env.new and settings.conf.new next to it; it never overwrites anything. Review them, then move them into +place: finbot.env wherever your launcher loads it from, settings.conf into DATA_DIR. Then delete the old file. Keys it cannot place (removed or unknown) are listed and left out.""" import argparse @@ -81,8 +81,8 @@ def split(text, path='.env'): outputs = { src.with_name('finbot.env.new'): ('# finbot.env: the startup file, loaded by your launcher; a change needs a restart.', startup, 0o600), - src.with_name('preferences.conf.new'): - ('# preferences.conf: read by the bot; reload with systemctl reload finbot.', preferences, 0o644)} + src.with_name('settings.conf.new'): + ('# settings.conf: read by the bot; reload with systemctl reload finbot.', preferences, 0o644)} if any(path.exists() for path in outputs): sys.exit('refusing to overwrite: ' + ', '.join(str(p) for p in outputs if p.exists())) for path, (header, lines, mode) in outputs.items(): @@ -91,5 +91,5 @@ def split(text, path='.env'): print(f'wrote {path} ({len(lines)} keys)', file=sys.stderr) for note in notes: print(note, file=sys.stderr) - print('next: put finbot.env where your launcher loads it and preferences.conf in the data directory (DATA_DIR),' + print('next: put finbot.env where your launcher loads it and settings.conf in the data directory (DATA_DIR),' f' then delete {src}', file=sys.stderr) diff --git a/util/sync_defaults.py b/util/sync_defaults.py index 40084547..dde0b6e6 100755 --- a/util/sync_defaults.py +++ b/util/sync_defaults.py @@ -43,7 +43,7 @@ def sync(apply=False, force=False, dest=None, create=False): results.append((name, 'copied' if apply else 'would copy')) elif filecmp.cmp(src, target, shallow=False): results.append((name, 'skipped - destination identical')) - elif name == 'preferences.conf': + elif name == 'settings.conf': results.append((name, 'skipped - destination differs')) # configured preferences are never replaced elif force: shutil.copy2(target, str(target) + '.bak') From bc7caff778860d02afecd78d683409676237cae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:29:58 +0000 Subject: [PATCH 2/3] Settings are called settings (not preferences) in the code, messages and docs; check_config warns when finbot.env is not 600 root:root; check_config tidied config.prefs is config.settings, Prefs is Settings, read_preferences is read_settings and so on. The legacy file name preferences.conf stays only where an upgrade renames it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 20 +-- bot.py | 6 +- defaults/finbot.env | 4 +- doc/configuration.md | 6 +- doc/install.md | 6 +- doc/scheduling.md | 6 +- finbot.service | 2 +- lib/cache.py | 8 +- lib/catalog.py | 2 +- lib/config.py | 106 ++++++++-------- lib/delivery.py | 4 +- lib/dialects.py | 4 +- lib/formats.py | 4 +- lib/jobs.py | 4 +- lib/links.py | 4 +- lib/log.py | 8 +- lib/net.py | 4 +- lib/render.py | 4 +- lib/reports/about.py | 6 +- lib/reports/cal.py | 2 +- lib/reports/message.py | 2 +- lib/reports/performance.py | 4 +- lib/reports/pick.py | 4 +- lib/reports/price.py | 20 +-- lib/reports/profile.py | 2 +- lib/reports/rankings.py | 12 +- lib/reports/sharesight_warm.py | 4 +- lib/reports/shorts.py | 4 +- lib/reports/trades.py | 6 +- lib/reports/yahoo_warm.py | 4 +- lib/sharesight.py | 22 ++-- lib/watchlist.py | 6 +- lib/when.py | 2 +- lib/worker.py | 4 +- lib/yahoo.py | 6 +- tests/common.py | 10 +- tests/test_charts.py | 2 +- tests/test_config.py | 217 ++++++++++++++++++--------------- tests/test_datadir.py | 4 +- tests/test_edges.py | 16 +-- tests/test_finbot.py | 2 +- tests/test_inbound.py | 2 +- tests/test_reports.py | 22 ++-- tests/test_schedule.py | 4 +- tests/test_scheduler.py | 18 +-- tests/test_sending.py | 4 +- tests/test_sharesight.py | 4 +- tests/test_util.py | 10 +- tests/test_yahoo.py | 12 +- util/check_config.py | 85 +++++++++---- util/split_env.py | 14 +-- util/sync_defaults.py | 2 +- 52 files changed, 396 insertions(+), 344 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 212ae7fc..85aefc55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,9 +25,9 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm path a test reads). This cloud environment cannot reach `deb.debian.org`, so only CI builds the image. - Production runs the Debian system packages; `requirements.txt` is for pip/uv users and CI. The libraries in `requirements-optional.txt` are each imported only inside the code of the mode that needs it, never at module load (a test starts finbot with them all blocked). -- A new preference goes in three places: a `Prefs` field with its default (`lib/config.py`; its type says how it is +- A new setting goes in three places: a `Settings` field with its default (`lib/config.py`; its type says how it is read), `defaults/settings.conf` (commented out at its default, `#key=value`: the template sets nothing, and finbot - never reads it), and the preferences table of `doc/configuration.md`. The tests catch a key + never reads it), and the settings table of `doc/configuration.md`. The tests catch a key missing from the template or a default that differs, but not the docs (no test may read README.md or `doc/*.md`: CI skips the tests for a change to the docs alone). - If Yahoo and Sharesight are unreachable (the cloud environment's network policy can block them), check behaviour @@ -111,14 +111,14 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm `Portfolio` objects: `holdings()`, `tickers()`, `gain(days)` (the portfolio's), `trades(days)`. Sharesight is optional (`sharesight.configured()`): without its keys `tickers()` is empty, so reports cover the watchlist alone, and `portfolios()` raises `NotSetUp`, an answer. -- `lib/log.py`: the log, by level (`error warn info debug`), as the `log_level` preference lets in, and `cut()`, which - keeps a line that can run long (a request's, a chat message's) on one line, within the `log_width` preference; +- `lib/log.py`: the log, by level (`error warn info debug`), as the `log_level` setting lets in, and `cut()`, which + keeps a line that can run long (a request's, a chat message's) on one line, within the `log_width` setting; bot.py's `WholeLines` writes each line whole, whichever threads log at once. `lib/cache.py`: cache files (any may vanish at any time). `lib/state.py`: state files in `DATA_DIR`, which must last; change one with `with state.edit(name, default) as data:`, never load-then-save; a file of one record per chat with `state.load_for(chat, name, default)` and `state.edit_for`. A chat's key is its network and id (`Transport.key`, `telegram:-1001234`), the same in both modes. `lib/watchlist.py`: each chat's watchlist (`tickers(chat)`), and `with_holdings(chat)`, the stocks a report in that chat covers; a report takes the chat from its destination. -- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (settings.conf, a new object on +- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.settings` (settings.conf, a new object on each reload). `lib/jobs.py`: the scheduler: each chat's jobs, the default ones and those it added (`JOBS_FILE`, by chat key: the defaults it switched off and those it removed, `'deleted'`, the jobs it added; a direct message's defaults start off, `'defaults': 'off'` with those switched on, and it is marked `'dm'`; `see` makes a chat known at @@ -153,8 +153,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm checks `test_finbot`. ## Conventions -- Read settings as `config.env.` / `config.prefs.` where they are used, never into a default or module - constant (a reload must be seen); in tests, `self.env(...)` / `self.prefs(...)` change them. +- Read settings as `config.env.` / `config.settings.` where they are used, never into a default or module + constant (a reload must be seen); in tests, `self.env(...)` / `self.settings(...)` change them. - A report is a list of lines. `service` is a dialect (`lib/dialects.py`), which equals the network's name: markup with `service.bold(text)`, `.italic`, `.strike`, `.link(url, text)`, user-written text through `service.escape(text)`, stock links through `links.finance_link(symbol, exchange, service)`. Tests pass `TELEGRAM`, @@ -169,8 +169,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm sent, a job run; `net` logs each request it sends), `log.debug` (cache misses, hits and writes; the routine of a press: the press, its acknowledgement and the edit in place, sent with `logged='debug'`, and a success answered to a network). The - `log_level` preference sets what reaches the log; `lib/config.py` prints its own errors (it loads before - preferences exist). Everything goes to stderr: systemd discards stdout (`StandardOutput=null`), so the bot and its reports + `log_level` setting sets what reaches the log; `lib/config.py` prints its own errors (it loads before + settings exist). Everything goes to stderr: systemd discards stdout (`StandardOutput=null`), so the bot and its reports never print there (a test checks); only the util/ tools, run by hand, do. - A report gives its chart as a `render.Chart` spec, not a drawing: `deliver()` draws it only for a chat that takes charts, through `render.draw()`, which caches the image by the spec's hash (and the drawing code's). @@ -185,6 +185,6 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm green). One module: `.venv/bin/python -m unittest tests.test_reports -b`. - README.md is short: what finbot is, a quick start, and links. The rest is in `doc/`, a file per topic: `commands.md` (the commands and reports, 16 KB: grep for the section instead of reading it whole), `configuration.md` (the keys - and preferences), `scheduling.md`, `caching.md`, `install.md` (a Debian host), `docker.md`, and a guide per network, + and settings), `scheduling.md`, `caching.md`, `install.md` (a Debian host), `docker.md`, and a guide per network, `telegram.md`, `slack.md` and `discord.md`: a change to a network's setup or keys goes in its guide. `doc/yahoo/` and `doc/sharesight/` hold large API samples that the tests replay: inspect them with python, don't read them. diff --git a/bot.py b/bot.py index 155f4831..ff486c41 100755 --- a/bot.py +++ b/bot.py @@ -113,7 +113,7 @@ def print_headers(say=log.info): """Log the request headers (at INFO, or as say() logs), Telegram's secret header redacted.""" for name, value in sorted(environ.items()): say((name, '' if name == 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' else value)) - if config.prefs.print_headers: + if config.settings.print_headers: print_headers() path = environ.get('PATH_INFO', '') source = route(path) @@ -267,10 +267,10 @@ def reload_all(*args): changes = config.reload() for change in changes: log.info('config:', change) - log.info(f"config: reloaded {config.preferences_path}, {len(changes)} change{'' if len(changes) == 1 else 's'}") + log.info(f"config: reloaded {config.settings_path}, {len(changes)} change{'' if len(changes) == 1 else 's'}") except config.ConfigError as e: config.print_errors(e) - if note := config.template_note(config.preferences_path): + if note := config.template_note(config.settings_path): log.warn('WARN config:', note) def flush_cache(*args): diff --git a/defaults/finbot.env b/defaults/finbot.env index 47e10e11..79e02631 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -3,12 +3,12 @@ # systemd: EnvironmentFile=/opt/finbot.env (in finbot.service) # docker: --env-file /opt/finbot.env # init.d: set -a; . /opt/finbot.env; set +a; ./bot.py -# Keep it private: chmod 600, owned by root. It holds the credentials. +# Keep it private: chmod 600, owned by root:root. It holds the credentials (check_config.py warns if not). # Flat KEY=value lines: no spaces around "=", no quotes (docker keeps quotes literally), no trailing comments; comments # on their own line. # Everything else (thresholds, windows, display, caching, ...) is in settings.conf, which the bot reloads without a # restart. -# A preference key in this file is an error: finbot refuses to start. Lint both files with: +# A setting key in this file is an error: finbot refuses to start. Lint both files with: # util/check_config.py /opt/finbot.env # Where state and cache live. Leave unset under systemd with StateDirectory=finbot (it uses $STATE_DIRECTORY, diff --git a/doc/configuration.md b/doc/configuration.md index b3afb61a..7a7bcd8b 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -4,7 +4,7 @@ Configuration is split into two files -- finbot.env for startup environment vari Copy the shipped template: ``` -sudo cp ~/finbot/defaults/finbot.env /opt/finbot.env && sudo chmod 600 /opt/finbot.env +sudo cp ~/finbot/defaults/finbot.env /opt/finbot.env && sudo chmod 600 /opt/finbot.env && sudo chown root:root /opt/finbot.env ``` ## `finbot.env` @@ -44,13 +44,13 @@ Retired keys do nothing, and only get a note at start (and from check_config) sa This file was called `preferences.conf` before: on an upgrade the bot renames an old `/preferences.conf` to `settings.conf` at start (it says so in the log), unless a `settings.conf` is there already. -The preferences, read by the bot itself from `/settings.conf`; without that file, finbot runs on the built-in defaults below. `defaults/settings.conf` is only its template: every key in it is commented out at its default, and finbot never reads it. To change a preference, copy the template into the data directory (this never overwrites a file there): +The settings, read by the bot itself from `/settings.conf`; without that file, finbot runs on the built-in defaults below. `defaults/settings.conf` is only its template: every key in it is commented out at its default, and finbot never reads it. To change a setting, copy the template into the data directory (this never overwrites a file there): ``` util/sync_defaults.py [--dest DIR] --apply ``` then remove the `#` before the key and edit its value. A key set in `defaults/settings.conf` itself does nothing: the log at start (and check_config) says so, and where to set it instead. -A change takes effect on a reload. A true/false preference takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. +A change takes effect on a reload. A true/false setting takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. | Key | Default | Description | |---|---|---| diff --git a/doc/install.md b/doc/install.md index 2bed4d0c..15721bc4 100644 --- a/doc/install.md +++ b/doc/install.md @@ -35,15 +35,15 @@ systemd, as below. To run it in a container instead, see [docker.md](docker.md). ``` 3. The startup file, from its template; then set each network's keys from its guide, and Sharesight's: ``` - sudo cp ~/finbot/defaults/finbot.env /opt/finbot.env && sudo chmod 600 /opt/finbot.env + sudo cp ~/finbot/defaults/finbot.env /opt/finbot.env && sudo chmod 600 /opt/finbot.env && sudo chown root:root /opt/finbot.env sudoedit /opt/finbot.env ``` -4. Check it: `~/finbot/util/check_config.py /opt/finbot.env` +4. Check it: `~/finbot/util/check_config.py /opt/finbot.env` (it also warns if the file is not `600 root:root`) 5. Run it under systemd, below. The first start makes the data directory (`var/` in the checkout, or `/var/lib/finbot` with `DynamicUser=`); each chat then has the default scheduled jobs, which `/jobs` changes ([scheduling.md](scheduling.md)). -[configuration.md](configuration.md) has every key and preference. +[configuration.md](configuration.md) has every key and setting. ## Anywhere else: a Python venv On Linux, macOS or WSL, with git and Python 3.12 or later (on Debian and Ubuntu, also `python3-venv`): diff --git a/doc/scheduling.md b/doc/scheduling.md index b7093b31..c0ae0032 100644 --- a/doc/scheduling.md +++ b/doc/scheduling.md @@ -33,7 +33,7 @@ A button drawn for a list that has since changed acts on nothing: the screen is ### Add, step by step Each screen marks its default with `†` (`Sat†`, `09†`) and says _† indicates default_; after the first, **Back** takes a -step back. Times are by the `timezone` preference's clock. +step back. Times are by the `timezone` setting's clock. First what the job runs: a report, or a command of `/help` ([below](#a-job-in-english)). A message's text, and a command's stock or portfolio, cannot come from a button, so their screen says how to type them. Then how often: @@ -79,7 +79,7 @@ A job is a report and what it takes, then when it runs: midnight. It can take days, and a window of hours (`from 10am to 9pm`, `between 9am and 5pm`) or a span across days (`from mon 9am to sat 3pm`). * **Zone, last:** - * Times are by the `timezone` preference's clock. A job keeps that zone, so it follows its daylight saving: 8am stays + * Times are by the `timezone` setting's clock. A job keeps that zone, so it follows its daylight saving: 8am stays 8am. * Name `UTC` for a time that never moves. * Name another zone for its clock: an IANA name (`America/New_York`), a place (`New York`, `Hong Kong`), or a @@ -156,7 +156,7 @@ move as either side's daylight saving changes. * pre-market US at 7:15am New York; * mid-session about halfway through: 1:00pm Sydney for the ASX, 12:45pm New York for the NYSE; * the close 30 minutes after it: 4:30pm Sydney for the ASX, 4:30pm Hong Kong, 5:00pm London and 4:30pm New York. -* **The messages marked (AU)** are default jobs only where the `country_code` preference is `AU`. +* **The messages marked (AU)** are default jobs only where the `country_code` setting is `AU`. * **trades and performance** are default jobs only where Sharesight is set up (see [Configuration](configuration.md#sharesight)). * **A one-off leaves the list** once its date has passed. diff --git a/finbot.service b/finbot.service index 73a1cd08..3652d1e3 100644 --- a/finbot.service +++ b/finbot.service @@ -16,7 +16,7 @@ StandardOutput=null StandardError=journal SyslogIdentifier=finbot Environment=PYTHONUNBUFFERED=1 -# finbot.env: loaded by systemd as root (chmod 600); a change needs a restart +# finbot.env: loaded by systemd as root (600 root:root); a change needs a restart EnvironmentFile=/opt/finbot.env [Install] diff --git a/lib/cache.py b/lib/cache.py index 280ad7f9..9cd002ca 100644 --- a/lib/cache.py +++ b/lib/cache.py @@ -27,7 +27,7 @@ def age(name): def _fresh(name, seconds): """The cache file's path if written less than `seconds` ago (cache_seconds when None), else None.""" if seconds is None: - seconds = config.prefs.cache_seconds + seconds = config.settings.cache_seconds where, written = path(name), age(name) if written is None or written >= seconds: log.debug("cache miss:", where) @@ -68,12 +68,12 @@ def load(name): def cached(name, seconds, fetch): """fetch()'s answer (JSON), as sent: from the cache file `name` while it is under `seconds` old (cache_seconds when - None) and the cache preference is on, else fetched and written there unless empty. Answers are cached as sent, not + None) and the cache setting is on, else fetched and written there unless empty. Answers are cached as sent, not parsed, so that a parser fix needs no flush.""" - if config.prefs.cache and (data := read(name, seconds)): + if config.settings.cache and (data := read(name, seconds)): return data data = fetch() - if data and config.prefs.cache: + if data and config.settings.cache: write(name, data) return data diff --git a/lib/catalog.py b/lib/catalog.py index 4fa5ade1..c88c90cd 100644 --- a/lib/catalog.py +++ b/lib/catalog.py @@ -133,7 +133,7 @@ def message_name(lines, country=None, size=56): def items(country, sharesight=True): - """The default jobs where the country_code preference is country: every item but those for another country, and + """The default jobs where the country_code setting is country: every item but those for another country, and where Sharesight is not set up (sharesight false), those that report on it.""" return [item for item in ITEMS if item.country in (None, country) and (sharesight or item.action not in SHARESIGHT)] diff --git a/lib/config.py b/lib/config.py index 08f0bd5d..6d20cd10 100644 --- a/lib/config.py +++ b/lib/config.py @@ -22,9 +22,9 @@ SECRET_KEYS = ('sharesight_code', 'sharesight_client_id', 'sharesight_client_secret', 'slackBotToken', 'slackAppToken', 'slackOutgoingToken', 'slackOutgoingWebhook', 'slackSigningSecret', 'telegramBotToken', 'telegramOutgoingToken', 'telegramOutgoingWebhook', 'discordBotToken', 'discordOutgoingWebhook') # never printed -PREFERENCES_FILE = 'settings.conf' -OLD_PREFERENCES_FILE = 'preferences.conf' # what settings.conf was called: an upgrade renames it (adopt_old_file) -TEMPLATE = REPO_DIR / 'defaults' / PREFERENCES_FILE # every key commented out at its default; finbot never reads it +SETTINGS_FILE = 'settings.conf' +OLD_SETTINGS_FILE = 'preferences.conf' # what settings.conf was called: an upgrade renames it (adopt_old_file) +TEMPLATE = REPO_DIR / 'defaults' / SETTINGS_FILE # every key commented out at its default; finbot never reads it HOME_CHAT = ("each chat has its own jobs (/jobs), which post there as the bot, and an error that belongs to no chat" ' goes to the log; an upgrade from before jobs per chat reads it once more, to give that chat the old schedule,' ' watchlist and plans (lib/migrate.py); delete the line after that start') @@ -106,13 +106,13 @@ def read_text(path): raise ConfigError(f'cannot read {path}: {getattr(e, "strerror", None) or e}') -def read_preferences(path): +def read_settings(path): """{key: value} of a settings.conf, None if there is none. Raises ConfigError for anything wrong with it.""" text = read_text(path) if text is None: return None values, errors = parse_assignments(text, path, lambda key: None if key in RETIRED else - key_problem(key, PREFERENCE_KEYS, STARTUP_KEYS, 'finbot.env')) + key_problem(key, SETTING_KEYS, STARTUP_KEYS, 'finbot.env')) if errors: raise ConfigError(errors) return values @@ -122,7 +122,7 @@ def adopt_old_file(data_dir): """An upgrade from when settings.conf was called preferences.conf: the old file in `data_dir` becomes settings.conf, unless there is one already, so that no setting is lost in silence. Says so on stderr (the log is not up yet); where the rename fails, the old file is left and a note says to rename it.""" - old, new = Path(data_dir, OLD_PREFERENCES_FILE), Path(data_dir, PREFERENCES_FILE) + old, new = Path(data_dir, OLD_SETTINGS_FILE), Path(data_dir, SETTINGS_FILE) if not old.is_file() or new.exists(): return try: @@ -143,15 +143,15 @@ def template_keys(): if line.strip() and not line.lstrip().startswith('#')] -def template_note(preferences_path): +def template_note(settings_path): """What to tell whoever set keys in defaults/settings.conf, which finbot never reads: to set them in - preferences_path instead, made first with util/sync_defaults.py when there is none. None when the template sets + settings_path instead, made first with util/sync_defaults.py when there is none. None when the template sets nothing, as shipped.""" keys = template_keys() if not keys: return None - fix = (f'edit {preferences_path} instead' if os.path.isfile(preferences_path) else - f'run util/sync_defaults.py --apply, then edit {preferences_path}') + fix = (f'edit {settings_path} instead' if os.path.isfile(settings_path) else + f'run util/sync_defaults.py --apply, then edit {settings_path}') return f"defaults/settings.conf sets {', '.join(keys)}, but finbot never reads it: {fix}" @@ -161,7 +161,7 @@ def read_startup_file(path): if text is None: raise ConfigError(f'{path} does not exist') values, errors = parse_assignments(text, path, lambda key: None if key in RETIRED else - key_problem(key, STARTUP_KEYS, PREFERENCE_KEYS, PREFERENCES_FILE)) + key_problem(key, STARTUP_KEYS, SETTING_KEYS, SETTINGS_FILE)) if errors: raise ConfigError(errors) return values @@ -261,13 +261,13 @@ class Env: telegram_api: str # the Bot API's base URL for telegramBotToken ('' without one): a method's name follows it @property - def preferences_path(self): + def settings_path(self): """Where settings.conf is read from: the data dir.""" - return self.data_dir + '/' + PREFERENCES_FILE + return self.data_dir + '/' + SETTINGS_FILE def startup_values(getenv): - """The settings fixed when the bot starts (finbot.env), computed from getenv(name, default). Raises ConfigError.""" + """The startup settings (finbot.env), computed from getenv(name, default). Raises ConfigError.""" r = _Reader(getenv) data_dir = getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or 'var' data_dir = str(REPO_DIR / data_dir) # a relative one is in the checkout, wherever finbot starts @@ -302,9 +302,9 @@ def startup_values(getenv): @dataclasses.dataclass(frozen=True) -class Prefs: - """The preferences: settings.conf, which the bot reads at start and on every reload, each with its default here - and read as its type says (preference_values). Read them as config.prefs. when they are used, never into a +class Settings: + """The settings: settings.conf, which the bot reads at start and on every reload, each with its default here + and read as its type says (setting_values). Read them as config.settings. when they are used, never into a default or a module constant, so that a reload is seen.""" cache: bool = True cache_seconds: int = 21600 @@ -335,8 +335,8 @@ class Prefs: source: dict = dataclasses.field(default_factory=dict, compare=False, repr=False) -def preference_values(getenv, source=None): - """The preferences (settings.conf), computed from getenv(name, default): each read as its Prefs field's type +def setting_values(getenv, source=None): + """The settings (settings.conf), computed from getenv(name, default): each read as its Settings field's type says (a tuple is a list of words), its default the field's. `source` is the file's own {key: value}. Raises ConfigError listing every bad value.""" r = _Reader(getenv) @@ -345,21 +345,21 @@ def preference_values(getenv, source=None): 'hyperlinkProvider': lambda name, default: r.str(name, default).lower(), 'log_level': lambda name, default: r.choice(name, default, LOG_LEVELS)} values = {field.name: readers.get(field.name, readers[field.type])(field.name, field.default) - for field in dataclasses.fields(Prefs) if field.compare} + for field in dataclasses.fields(Settings) if field.compare} r.check() - return Prefs(**values, source=dict(source or {})) + return Settings(**values, source=dict(source or {})) # settings.conf's keys: what a reload can change -PREFERENCE_KEYS = tuple(field.name for field in dataclasses.fields(Prefs) if field.compare) +SETTING_KEYS = tuple(field.name for field in dataclasses.fields(Settings) if field.compare) -def _getenv_for(preferences): - """getenv(name, default): settings.conf first (preference keys only), then the process environment.""" +def _getenv_for(settings): + """getenv(name, default): settings.conf first (setting keys only), then the process environment.""" def getenv(name, default=None): - """A preference from settings.conf, else the process environment's value, else default.""" - if name in preferences: - return preferences[name] + """A setting from settings.conf, else the process environment's value, else default.""" + if name in settings: + return settings[name] return os.environ.get(name, default) return getenv @@ -378,9 +378,9 @@ def _fail(e): try: env = startup_values(os.environ.get) adopt_old_file(env.data_dir) - preferences_path = env.preferences_path - _preferences = read_preferences(preferences_path) or {} - prefs = preference_values(_getenv_for(_preferences), _preferences) + settings_path = env.settings_path + _settings = read_settings(settings_path) or {} + settings = setting_values(_getenv_for(_settings), _settings) except ConfigError as e: _fail(e) @@ -420,33 +420,33 @@ def data_json(name, default=None): def reload(): - """Re-read settings.conf and apply it: config.prefs is replaced in one assignment, so a request never sees half + """Re-read settings.conf and apply it: config.settings is replaced in one assignment, so a request never sees half of a reload. All or nothing. Returns one line per change; raises ConfigError (nothing applied) if the file is missing, unreadable or invalid.""" - global prefs - new = read_preferences(preferences_path) + global settings + new = read_settings(settings_path) if new is None: - raise ConfigError(f'reload requested but {preferences_path} does not exist; nothing reloaded') - values = preference_values(_getenv_for(new), new) - changes = [f"{name} {getattr(prefs, name)!r} -> {getattr(values, name)!r}" for name in PREFERENCE_KEYS - if getattr(prefs, name) != getattr(values, name)] - prefs = values + raise ConfigError(f'reload requested but {settings_path} does not exist; nothing reloaded') + values = setting_values(_getenv_for(new), new) + changes = [f"{name} {getattr(settings, name)!r} -> {getattr(values, name)!r}" for name in SETTING_KEYS + if getattr(settings, name) != getattr(values, name)] + settings = values return changes def check_startup_environment(): - """For bot.py at start. Preferences must come from settings.conf, not from the environment that finbot.env fills, + """For bot.py at start. Settings must come from settings.conf, not from the environment that finbot.env fills, and a leftover .env from before the split stops the start: its settings would otherwise be silently ignored.""" - problems = [f'{key} is a preference; move it from finbot.env to {PREFERENCES_FILE}' - for key in PREFERENCE_KEYS if key in os.environ] - if 'volatile_multiplier' in os.environ: # the old name, from before the split: it never worked as a preference - problems.append('volatile_multiplier was renamed to volatility_multiplier, a preference; move it from' - f' finbot.env to {PREFERENCES_FILE}') + problems = [f'{key} is a setting; move it from finbot.env to {SETTINGS_FILE}' + for key in SETTING_KEYS if key in os.environ] + if 'volatile_multiplier' in os.environ: # the old name, from before the split: it never worked as a setting + problems.append('volatile_multiplier was renamed to volatility_multiplier, a setting; move it from' + f' finbot.env to {SETTINGS_FILE}') if OLD_ENV.exists(): problems.append(f'{OLD_ENV} is the old single config file, which finbot no longer reads; it will not start' ' while the file is there') problems.append(f'split it with {REPO_DIR}/util/split_env.py, which writes finbot.env.new and' - f' settings.conf.new beside it; put finbot.env where your launcher loads it and {PREFERENCES_FILE} in' + f' settings.conf.new beside it; put finbot.env where your launcher loads it and {SETTINGS_FILE} in' f' {env.data_dir}, then delete {OLD_ENV}') if problems: raise ConfigError(problems) @@ -458,23 +458,23 @@ def keys(count): """'1 key', '2 keys'.""" return f"{count} key{'' if count == 1 else 's'}" lines = [f'startup environment ({keys(sum(key in os.environ for key in STARTUP_KEYS))} set)'] - if os.path.isfile(preferences_path): - lines.append(f'{preferences_path} ({keys(len(prefs.source))} set; SIGHUP to reload)') + if os.path.isfile(settings_path): + lines.append(f'{settings_path} ({keys(len(settings.source))} set; SIGHUP to reload)') else: - lines.append(f'no {PREFERENCES_FILE} in {env.data_dir}, reload unavailable') - return lines + retired_notes(os.environ, 'finbot.env') + retired_notes(prefs.source) + lines.append(f'no {SETTINGS_FILE} in {env.data_dir}, reload unavailable') + return lines + retired_notes(os.environ, 'finbot.env') + retired_notes(settings.source) -def retired_notes(values, file=PREFERENCES_FILE): +def retired_notes(values, file=SETTINGS_FILE): """A line for each key of a file's {key: value} that does nothing any more, saying what to do instead.""" return [f'{key} in {file} does nothing any more: {instead}' for key, instead in RETIRED.items() if key in values] -def startup_warnings(settings=None): +def startup_warnings(startup=None): """Settings in finbot.env (an Env; the running one by default), and keys set in the template defaults/settings.conf, that do not stop the start but keep part of the bot from working, or do nothing, a line each.""" - s = settings or env + s = startup or env warnings = [] if s.slackBotToken and not (s.slackAppToken or s.slackOutgoingWebhook): warnings.append('slackBotToken is set but neither slackAppToken nor slackOutgoingWebhook is: finbot hears' @@ -508,6 +508,6 @@ def startup_warnings(settings=None): if s.discordPublicKey and not s.discordOutgoingWebhook: warnings.append('discordPublicKey is set but discordOutgoingWebhook is not: finbot keeps to the Discord gateway' ' (pull mode), and the key is not used') - if note := template_note(s.preferences_path): + if note := template_note(s.settings_path): warnings.append(note) return warnings diff --git a/lib/delivery.py b/lib/delivery.py index 09448960..e8f30eb8 100644 --- a/lib/delivery.py +++ b/lib/delivery.py @@ -61,12 +61,12 @@ def targets(dest): def deliver(dest, report): """Post a report: its chart, drawn here only where it can go (a destination that takes charts, with the graph - preference on), with the lines as its caption; else the lines, and the buttons under the last message unless they + setting on), with the lines as its caption; else the lines, and the buttons under the last message unless they were the chart's. A chart the network refuses goes again with its heading (the first line) alone as its caption, the lines following as text; refused again, the lines go as text alone, without its buttons. A chart that may have arrived after all (a timeout) is not sent again. Returns the network's answers, one per message.""" chart = report.chart - if chart is None or not (dest.takes_charts and config.prefs.graph and chart.rows): + if chart is None or not (dest.takes_charts and config.settings.graph and chart.rows): return dest.text(report.lines, report.buttons if chart is None else None) caption = '\n'.join(map(str, report.lines)) or chart.title image = render.draw(chart) diff --git a/lib/dialects.py b/lib/dialects.py index 94766612..8f4bcb98 100644 --- a/lib/dialects.py +++ b/lib/dialects.py @@ -32,8 +32,8 @@ def _styled(self, style, text): return before + text + after def link(self, url, text): - """text linking to url; just the text with the hyperlink preference off.""" - return self._link(url, text) if config.prefs.hyperlink else text + """text linking to url; just the text with the hyperlink setting off.""" + return self._link(url, text) if config.settings.hyperlink else text def escape(self, text): """Text a user wrote (a .plan entry, a name) made safe to show: Telegram's HTML and Slack's markup both take diff --git a/lib/formats.py b/lib/formats.py index 09e3f304..2142c54e 100644 --- a/lib/formats.py +++ b/lib/formats.py @@ -218,8 +218,8 @@ def plain(line): def zone(): - """The timezone preference as a zone: UTC where it names none.""" + """The timezone setting as a zone: UTC where it names none.""" try: - return zoneinfo.ZoneInfo(config.prefs.timezone) + return zoneinfo.ZoneInfo(config.settings.timezone) except (ValueError, zoneinfo.ZoneInfoNotFoundError): return zoneinfo.ZoneInfo('UTC') diff --git a/lib/jobs.py b/lib/jobs.py index 93a68f4d..db12a3b4 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -336,7 +336,7 @@ def load(self): counts from now, as one does that was off.""" jobs = system_jobs() chats = self.stored_chats() - items = catalog.items(config.prefs.country_code, sharesight.configured()) + items = catalog.items(config.settings.country_code, sharesight.configured()) for chat, record in chats.items(): gone = set(removed(record)) jobs += [make_job(item.action, item.args, item.schedule, chat, item_on(record, item.key), item.key, @@ -554,7 +554,7 @@ def add(self, chat, action, args, schedule): raise ScheduleError(f'{job.action} reports on Sharesight, which is not set up') if schedule.frequency == 'once' and schedule.next_after(self.clock()) is None: raise ScheduleError(f"{when.days_words(schedule.days)} has gone by: a one-off is for a time ahead") - if item := catalog.matching(job.action, job.args, schedule, config.prefs.country_code): + if item := catalog.matching(job.action, job.args, schedule, config.settings.country_code): def restore(record): """Bring the default job back where the chat removed it, and switch it on.""" keep(record, 'deleted', [key for key in removed(record) if key != item.key]) diff --git a/lib/links.py b/lib/links.py index a8d88e09..519ff8dc 100644 --- a/lib/links.py +++ b/lib/links.py @@ -21,13 +21,13 @@ def yahoo_link(ticker, service, brief=True, text=None): def finance_link(symbol, exchange, service, days=1, brief=True, text=None): """A link to the stock's page on the site hyperlinkProvider names, Google Finance (google) or Yahoo, and on Yahoo for what Google has no page for (NO_GOOGLE_PAGE, indices).""" - if config.prefs.hyperlinkProvider == 'google' and exchange not in NO_GOOGLE_PAGE and not symbol.startswith('^'): + if config.settings.hyperlinkProvider == 'google' and exchange not in NO_GOOGLE_PAGE and not symbol.startswith('^'): return gfinance_link(symbol, exchange, service, days, brief, text) return yahoo_link(symbol, service, brief, text) def gfinance_link(symbol, exchange, service, days=1, brief=True, text=None): """A link to the stock's Google Finance page, its chart window the one nearest to `days`, reading `text`, else the - symbol (brief) or SYMBOL:EXCHANGE (a US listing: the symbol). Just that text with the hyperlink preference off.""" + symbol (brief) or SYMBOL:EXCHANGE (a US listing: the symbol). Just that text with the hyperlink setting off.""" window = next((window for limit, window in WINDOWS if (days or 1) > limit), '1D') if '.' in exchange: exchange = exchange.split('.')[1] diff --git a/lib/log.py b/lib/log.py index fb07f256..0c9d7026 100644 --- a/lib/log.py +++ b/lib/log.py @@ -1,7 +1,7 @@ """The log, syslog style: each line has a level, ERROR, WARN, INFO or DEBUG, and goes to stderr (which systemd and -Docker take to the journal) when the log_level preference lets it in: that level and those above it, so ERROR always +Docker take to the journal) when the log_level setting lets it in: that level and those above it, so ERROR always does. Each takes print()'s arguments. A line that can run long (a request's URL, a chat message) goes through cut(), -which puts it on one line within the log_width preference.""" +which puts it on one line within the log_width setting.""" import sys from lib import config @@ -11,7 +11,7 @@ def enabled(level): """Whether log_level lets lines of this level into the log.""" - return LEVELS.index(level) <= LEVELS.index(config.prefs.log_level) + return LEVELS.index(level) <= LEVELS.index(config.settings.log_level) def write(level, parts, options): @@ -23,7 +23,7 @@ def write(level, parts, options): def cut(text): """A log line that can run long (text) on one line, each run of line breaks and spaces one space, and cut to log_width characters, its last an ellipsis (…); whole where log_width is 0.""" - text, width = ' '.join(str(text).split()), config.prefs.log_width + text, width = ' '.join(str(text).split()), config.settings.log_width return text if width <= 0 or len(text) <= width else text[:width - 1] + '…' diff --git a/lib/net.py b/lib/net.py index 2a610bb0..56339631 100644 --- a/lib/net.py +++ b/lib/net.py @@ -31,7 +31,7 @@ def request(method, url, **kwargs): def logged(method, url, kwargs): """The request's arguments without `logged`; the request noted first, its method and URL without secrets or the - scheme, cut to the log_width preference (log.cut): at INFO; at DEBUG with logged='debug' (the routine of a press: + scheme, cut to the log_width setting (log.cut): at INFO; at DEBUG with logged='debug' (the routine of a press: its acknowledgement, the message edited in place); not at all with logged=False (Telegram's long polling, asked again and again).""" shown = kwargs.pop('logged', True) @@ -42,7 +42,7 @@ def logged(method, url, kwargs): def timeouts(kwargs): """The request's arguments with a timeout, unless it has its own: CONNECT_TIMEOUT to connect, then http_timeout for the answer.""" - return {'timeout': (CONNECT_TIMEOUT, config.prefs.http_timeout), **kwargs} + return {'timeout': (CONNECT_TIMEOUT, config.settings.http_timeout), **kwargs} def redact(text): """text (or an exception) without the secrets that request URLs, headers and error messages carry: Telegram and diff --git a/lib/render.py b/lib/render.py index ba17dec0..3f23b651 100644 --- a/lib/render.py +++ b/lib/render.py @@ -633,10 +633,10 @@ def draw(chart): raise ValueError(f"unknown chart kind {chart.kind!r}") key = json.dumps([CODE_VERSION, dataclasses.asdict(chart)], sort_keys=True, default=str) name = f"finbot_chart_{hashlib.sha1(key.encode()).hexdigest()[:20]}.png" - if config.prefs.cache and (image := cache.read_bytes(name)): + if config.settings.cache and (image := cache.read_bytes(name)): return image image = RENDERERS[chart.kind](trimmed(chart)) - if config.prefs.cache: + if config.settings.cache: cache.write_bytes(name, image) image.seek(0) return image diff --git a/lib/reports/about.py b/lib/reports/about.py index cf9f0772..c5d265ba 100644 --- a/lib/reports/about.py +++ b/lib/reports/about.py @@ -55,7 +55,7 @@ def jobs_screen(service, chat, screen='top', page=0, note=None, tappable=True): """A screen of the .jobs menu that lists a chat's jobs (by its key), numbered, each ✅ (on) or ❌ (off), * where it - has never run, its name and its schedule in words, by the timezone preference's clock (lib/when.py), then the key + has never run, its name and its schedule in words, by the timezone setting's clock (lib/when.py), then the key to the marks shown. top has what can be done (Add, and where there is a job to act on, Delete, Enable, Disable, Run now, Upcoming...) and how Add is typed, in italics; on, off, del and run a button for each job the screen acts on (acts), which switches it on or off, deletes it or runs it now, and Enable all, Disable all or Delete all. A page at @@ -154,7 +154,7 @@ def job_line(number, job, outcome): return f"{number}. {job.name}: {outcome}" def next_screen(service, chat, count=20): - """`.jobs upcoming`: a chat's next runs, in time order, at most count, each by the timezone preference's clock + """`.jobs upcoming`: a chat's next runs, in time order, at most count, each by the timezone setting's clock (and its own zone's beside it, where that differs), with its number and name in .jobs; a repeat's runs on a day are one entry, from its first to its last. Returns (lines, buttons): Cancel, back to the list.""" scheduler = jobs.current @@ -190,7 +190,7 @@ def moment_words(moment, local, zone=None): def add_screen(service, chat, steps): """A step of Add, from the choices made so far (steps, from the button pressed: the report's number, how often, then the LEVELS of that frequency in turn): (lines, buttons) of the next choice, its default marked †, or (None, - (action, arguments, schedule)) once every choice is made, by the timezone preference's clock. Cancel goes back to + (action, arguments, schedule)) once every choice is made, by the timezone setting's clock. Cancel goes back to the list, and Back, after the report, a step back. Raises ValueError for choices that are none (forged data, or an older finbot's).""" scheduler = jobs.current diff --git a/lib/reports/cal.py b/lib/reports/cal.py index 4d38c6d7..b8aad7bf 100644 --- a/lib/reports/cal.py +++ b/lib/reports/cal.py @@ -22,7 +22,7 @@ def run(dest=None, *, days=None, specific_stock=None, event='earnings'): the network can; a reply is not, or the chat would collect pinned copies.""" interactive = delivery.interactive(dest) if days is None: - days = config.prefs.future_days + days = config.settings.future_days for chat in delivery.targets(dest): events = gather(watchlist.covered(chat.key, specific_stock), days, specific_stock, event) payload = report(chat.dialect, events, days, specific_stock, interactive, event) diff --git a/lib/reports/message.py b/lib/reports/message.py index a949c798..2aae7bd7 100644 --- a/lib/reports/message.py +++ b/lib/reports/message.py @@ -22,7 +22,7 @@ def run(dest=None, *, lines): def today(): - """Today's date, by the timezone preference's clock.""" + """Today's date, by the timezone setting's clock.""" return datetime.datetime.now(formats.zone()).date() diff --git a/lib/reports/performance.py b/lib/reports/performance.py index c09df2d9..721ebc37 100644 --- a/lib/reports/performance.py +++ b/lib/reports/performance.py @@ -47,11 +47,11 @@ def report(service, past_days, portfolio_select=None, performance=None): return payload, render.Chart('bars', "Performance", span, tuple(chart_rows), value_format='percent2') def run(dest=None, *, past_days=None, user='', portfolio_select=None): - """Post each portfolio's performance over past_days (the past_days preference when None), or the one named, beside + """Post each portfolio's performance over past_days (the past_days setting when None), or the one named, beside the benchmarks: to the chat that asked or whose job it is (dest), with its chart and period buttons where they can go.""" if past_days is None: - past_days = config.prefs.past_days + past_days = config.settings.past_days performance = gather(past_days, portfolio_select) for chat in delivery.targets(dest): payload, graph = report(chat.dialect, past_days, portfolio_select, performance) diff --git a/lib/reports/pick.py b/lib/reports/pick.py index c13b9a54..4b4cdb15 100644 --- a/lib/reports/pick.py +++ b/lib/reports/pick.py @@ -17,8 +17,8 @@ def as_photo(chat, cmd): """Whether a picker's message is a picture (a 1x1 image whose caption is the picker): when its answer is a chart, - and this chat takes charts (the graph preference on); else it is text.""" - return cmd in CHARTS and chat.takes_charts and config.prefs.graph + and this chat takes charts (the graph setting on); else it is text.""" + return cmd in CHARTS and chat.takes_charts and config.settings.graph def noun(cmd): """What a picker picks: 'portfolio' or 'ticker'.""" diff --git a/lib/reports/price.py b/lib/reports/price.py index d2282b14..1449e601 100644 --- a/lib/reports/price.py +++ b/lib/reports/price.py @@ -20,7 +20,7 @@ def gather(tickers, specific_stock=None, days=None): symbol): Yahoo's quotes, and over `days` each one's change as percent_change_period, from its Yahoo history (the change its chart shows), the histories fetched together up front.""" market_data = yahoo.fetch(tickers) - if config.prefs.demote_volatile: # trigger_bar compares market caps, in US dollars + if config.settings.demote_volatile: # trigger_bar compares market caps, in US dollars yahoo.add_usd_market_caps(market_data) if not days: return market_data @@ -40,24 +40,24 @@ def gather(tickers, specific_stock=None, days=None): def needs_beta(quote): """Whether trigger_bar must know this ticker's beta: a small cap between US$150M and US$1B, which counts as volatile when its beta is over 1.5. The beta is only in Yahoo's details.""" - return config.prefs.demote_volatile and 1.5e8 <= quote.get('market_cap_usd', float('inf')) < 1e9 + return config.settings.demote_volatile and 1.5e8 <= quote.get('market_cap_usd', float('inf')) < 1e9 def trigger_bar(ticker, quote, threshold, details): """The move this ticker needs before it alone sends a scheduled report: more for leveraged ETFs (demote_leveraged) and volatile small caps (demote_volatile; their market caps in US dollars), else the threshold itself. details: Yahoo's details of the tickers that need_beta, fetched together.""" title = quote['profile_title'].lower() - if config.prefs.demote_leveraged and ('2x' in title or '3x' in title): + if config.settings.demote_leveraged and ('2x' in title or '3x' in title): return threshold * 1.3 cap = quote.get('market_cap_usd') - if config.prefs.demote_volatile and cap is not None and cap < 1e9: + if config.settings.demote_volatile and cap is not None and cap < 1e9: if cap < 1e7: - return threshold * config.prefs.volatility_multiplier * 1.3 + return threshold * config.settings.volatility_multiplier * 1.3 if cap < 1.5e8: - return threshold * config.prefs.volatility_multiplier + return threshold * config.settings.volatility_multiplier beta = details.get(ticker, {}).get('beta') if beta is None or beta > 1.5: - return threshold * config.prefs.volatility_multiplier + return threshold * config.settings.volatility_multiplier return threshold # today's moves, in session, out of it (pre- or post-market), at the close, over `days` @@ -102,7 +102,7 @@ def moves(tickers, mode='interday', threshold=None, days=None, specific_stock=No a mode of MODES ('period': over `days`): each whose move is `threshold` percent or more, or the one stock asked about, whatever its move. A scheduled report keeps them only when one passes its trigger_bar.""" if threshold is None: - threshold = config.prefs.price_percent + threshold = config.settings.price_percent market_data = gather(tickers, specific_stock, days) now = datetime.datetime.now() rows, exchanges = [], set() @@ -126,7 +126,7 @@ def report(service, found, mode='interday', threshold=None, days=None, top=None, """(payload, chart) of a price report in one network's markup, from moves()' Moves: the moves of `threshold` percent or more in a mode of MODES ('period': over `days`), or the top and bottom `top`.""" if threshold is None: - threshold = config.prefs.price_percent + threshold = config.settings.price_percent lines = [f"{formats.get_emoji(percent)} {formats.short_name(quote['profile_title'])} " f"({links.finance_link(ticker, quote['profile_exchange'], service, days=days)}) " f"{formats.signed_percent(round(percent))}" for ticker, percent, quote in found.rows] @@ -175,7 +175,7 @@ def run(dest=None, *, mode='interday', threshold=None, days=None, top=None, user job it is (dest), with its chart and period buttons where they can go.""" interactive = delivery.interactive(dest) if threshold is None: - threshold = config.prefs.price_percent + threshold = config.settings.price_percent def markup(chat): """A chart's period buttons, where the chat takes buttons: the daily and N-day lists, but not the intraday ones.""" diff --git a/lib/reports/profile.py b/lib/reports/profile.py index d321b836..1926e927 100644 --- a/lib/reports/profile.py +++ b/lib/reports/profile.py @@ -211,7 +211,7 @@ def changes(service, data, ticker, state): def footer(service, data, ticker, exchange): """Links to the company on Google Finance, Yahoo and Simply Wall St, with hyperlinkFooter (and hyperlinks) on.""" - if 'profile_website' not in data or not (config.prefs.hyperlinkFooter and config.prefs.hyperlink): + if 'profile_website' not in data or not (config.settings.hyperlinkFooter and config.settings.hyperlink): return [] google = links.gfinance_link(ticker, exchange, service, brief=True, text='google') sws = service.link(simplywallst.get_url(ticker, data.get('profile_title', ''), exchange), 'simplywall.st') diff --git a/lib/reports/rankings.py b/lib/reports/rankings.py index f296bfb0..b38ab66b 100644 --- a/lib/reports/rankings.py +++ b/lib/reports/rankings.py @@ -45,7 +45,7 @@ def prepare_rating_payload(service, chat, action, length=15): ratings, the one more analysts give first.""" found = [] # (rating, analysts, line, ticker) for ticker, data in yahoo.fetch_details(watchlist.with_holdings(chat)).items(): - if 'recommend' not in data or data['recommend_analysts'] < config.prefs.min_analysts: + if 'recommend' not in data or data['recommend_analysts'] < config.settings.min_analysts: continue recommend = data['recommend'] if (action == 'buy' and 'buy' in recommend) or (action == 'sell' and recommend in ('sell', 'underperform')): @@ -55,8 +55,8 @@ def prepare_rating_payload(service, chat, action, length=15): found = sorted(found, key=lambda row: (row[0] if action == 'buy' else -row[0], -row[1]))[:length] payload = [line for _, _, line, _ in found] if not payload: - return [f"No tracked stocks rated {action} by {config.prefs.min_analysts}+ analysts"], None - message = f"Top {length} analyst {action} ratings for tracked stocks ({config.prefs.min_analysts}+ analysts)" + return [f"No tracked stocks rated {action} by {config.settings.min_analysts}+ analysts"], None + message = f"Top {length} analyst {action} ratings for tracked stocks ({config.settings.min_analysts}+ analysts)" shown = [(formats.short_ticker(ticker), rating) for rating, _, _, ticker in found] low, high = min(r for _, r in shown), max(r for _, r in shown) spread = (high - low) or 0.2 @@ -96,7 +96,7 @@ def ratio_list(service, chat, kind, mode='top', length=15): covered = None # for a forward P/E list: the tickers enough analysts cover if kind == 'forward pe': covered = {ticker for ticker, detail in yahoo.fetch_details(market_data).items() - if detail.get('recommend_analysts', 0) >= config.prefs.min_analysts} + if detail.get('recommend_analysts', 0) >= config.settings.min_analysts} rows = [] # (ratio, chart label, line), one sorted list for the caption and the chart for ticker, data in market_data.items(): ratio = data.get(field) @@ -110,9 +110,9 @@ def ratio_list(service, chat, kind, mode='top', length=15): trail = f'negative {kind}' if mode == 'negative' else kind # what the heading calls the ratio if not rows: if covered is not None: - return [f"No tracked stocks with {config.prefs.min_analysts}+ analysts have a {trail} ratio"], None + return [f"No tracked stocks with {config.settings.min_analysts}+ analysts have a {trail} ratio"], None return [f"{kind if mode == 'top' else f'{mode} {kind}'}: no data found"], None - coverage = f" ({config.prefs.min_analysts}+ analysts)" if covered is not None else '' + coverage = f" ({config.settings.min_analysts}+ analysts)" if covered is not None else '' heading = f"{'Bottom' if mode == 'bottom' else 'Top'} {length} tracked stocks by {trail} ratio{coverage}" bars = tuple((label, float(ratio)) for ratio, label, _ in rows) chart = render.Chart('bars', heading, rows=bars, value_format='number') diff --git a/lib/reports/sharesight_warm.py b/lib/reports/sharesight_warm.py index eae344aa..ae52052f 100644 --- a/lib/reports/sharesight_warm.py +++ b/lib/reports/sharesight_warm.py @@ -6,10 +6,10 @@ def run(): """Fetch afresh the portfolio list and each portfolio's holdings report, which every report starts from: the holdings report is cached for performance_cache_seconds (40 minutes by default); the default schedule runs this - every 30 minutes, and a flush at once. Nothing without Sharesight configured, or with the cache preference off + every 30 minutes, and a flush at once. Nothing without Sharesight configured, or with the cache setting off (nothing would be kept). A failure is only logged: the caches it would have replaced are served while fresh, and the next run tries again. Returns how many portfolios it refreshed.""" - if not (config.prefs.cache and sharesight.configured()): + if not (config.settings.cache and sharesight.configured()): return 0 try: return len(sharesight.warm()) diff --git a/lib/reports/shorts.py b/lib/reports/shorts.py index 255c6bfc..8f63ee7e 100644 --- a/lib/reports/shorts.py +++ b/lib/reports/shorts.py @@ -8,11 +8,11 @@ from lib import shortman def run(dest=None, *, threshold=None, specific_stock=None): - """Post the stocks a chat follows whose short interest is over `threshold` percent (the shorts_percent preference + """Post the stocks a chat follows whose short interest is over `threshold` percent (the shorts_percent setting when None), or one stock's (Yahoo's symbol): to the chat that asked or whose job it is (dest).""" interactive = delivery.interactive(dest) if threshold is None: - threshold = config.prefs.shorts_percent + threshold = config.settings.shorts_percent for chat in delivery.targets(dest): tickers = watchlist.covered(chat.key, specific_stock) market_data = gather(tickers, specific_stock) diff --git a/lib/reports/trades.py b/lib/reports/trades.py index 20e8ab9d..5f047b4e 100644 --- a/lib/reports/trades.py +++ b/lib/reports/trades.py @@ -35,7 +35,7 @@ def run(dest=None, *, days=None, user='', portfolio_select=None): sharesight.PortfolioNotFound for a name that is none of them.""" interactive = delivery.interactive(dest) if days is None: - days = config.prefs.past_days + days = config.settings.past_days chats = delivery.targets(dest) told = {chat.key: set() if interactive else set(state.load_for(chat.key, STATE_FILE, [])) for chat in chats} trades = gather(days, portfolio_select, set.intersection(*told.values())) # every chat knows those: not fetched @@ -57,7 +57,7 @@ def gather(days, portfolio_select, known): raw = [trade for portfolio in sharesight.portfolios(portfolio_select) for trade in portfolio.trades(days) if trade['transaction_type'] in {'BUY', 'SELL'} and int(trade['id']) not in known] quotes = {} - if raw and config.prefs.trades_use_yahoo: + if raw and config.settings.trades_use_yahoo: tickers = {markets.transform_to_yahoo(trade['symbol'], trade['market']) for trade in raw} try: log.info("Fetching tickers from Yahoo:", ', '.join(sorted(tickers))) @@ -71,7 +71,7 @@ def gather(days, portfolio_select, known): # the currency by Sharesight's market code, which the table knows, not Yahoo's name for the exchange (NasdaqGS) currency = markets.currency_from_market(market) or quotes.get(ticker, {}).get('currency') or \ trade['brokerage_currency_code'] - if config.prefs.trades_use_yahoo: + if config.settings.trades_use_yahoo: try: market = quotes[ticker]['profile_exchange'] except Exception as e: diff --git a/lib/reports/yahoo_warm.py b/lib/reports/yahoo_warm.py index 7683e1bf..cf0f9376 100644 --- a/lib/reports/yahoo_warm.py +++ b/lib/reports/yahoo_warm.py @@ -6,9 +6,9 @@ def run(): """Fetch afresh the price history of every stock followed (every chat's watchlist, and the holdings where Sharesight is configured), so each is cached for cache_seconds (6 hours by default) from now: the system job runs - this every 4 hours, before any runs out, and a flush at once. With the cache preference off nothing would be kept, + this every 4 hours, before any runs out, and a flush at once. With the cache setting off nothing would be kept, so nothing is fetched. Returns how many it fetched.""" - if not config.prefs.cache: + if not config.settings.cache: return 0 tickers = watchlist.all_watched() if sharesight.configured(): diff --git a/lib/sharesight.py b/lib/sharesight.py index 761135d2..a2600b8e 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -16,7 +16,7 @@ MAX_CONCURRENT = 1 # Sharesight refuses parallel requests beyond a few per account: one at a time, whoever asks _request_slots = threading.BoundedSemaphore(MAX_CONCURRENT) TOKEN = 'finbot_sharesight_token.json' # good until the expiry it carries -PORTFOLIOS_CACHE = 'finbot_sharesight_portfolios.json' # good for the cache_seconds preference +PORTFOLIOS_CACHE = 'finbot_sharesight_portfolios.json' # good for the cache_seconds setting def _request(path, what, expect, **kwargs): @@ -52,12 +52,12 @@ def _request(path, what, expect, **kwargs): def _token(): """An access token for Sharesight's API: the cached one while it has more than 20 seconds left, else a new one - (cached while the cache preference is on).""" - cached = cache.load(TOKEN) if config.prefs.cache else None + (cached while the cache setting is on).""" + cached = cache.load(TOKEN) if config.settings.cache else None if cached and cached['created_at'] + cached['expires_in'] - time.time() > 20: return cached['access_token'] data = _request('oauth2/token', 'auth', 'access_token', data=config.env.sharesight_auth) - if config.prefs.cache: + if config.settings.cache: cache.write(TOKEN, data) return data['access_token'] @@ -86,13 +86,13 @@ def _since(days): def _performance(portfolio_id, days=None, seconds=None): """Sharesight's performance report of a portfolio since `days` ago (0: today's holdings; past_days when None), from - the cache while under `seconds` old (the performance_cache_seconds preference when None).""" + the cache while under `seconds` old (the performance_cache_seconds setting when None).""" if days is None: - days = config.prefs.past_days + days = config.settings.past_days query = f'v3/portfolios/{portfolio_id}/performance?grouping=ungrouped&start_date={_since(days)}' fetch = lambda: _api(query, f'performance of portfolio {portfolio_id}', 'report') if seconds is None: - seconds = config.prefs.performance_cache_seconds + seconds = config.settings.performance_cache_seconds return cache.cached(performance_cache_file(portfolio_id, days), seconds, fetch)['report'] @@ -100,7 +100,7 @@ def _trades(portfolio_id, days=None): """Sharesight's trades in a portfolio over the past `days` (past_days when None), from the cache while under 5 minutes old.""" if days is None: - days = config.prefs.past_days + days = config.settings.past_days query = f'v2/portfolios/{portfolio_id}/trades.json?start_date={_since(days)}' fetch = lambda: _api(query, 'trades', 'trades') return cache.cached(f"finbot_sharesight_trades_{portfolio_id}_{days}.json", 299, fetch)['trades'] @@ -132,7 +132,7 @@ class Portfolio: id: int def gain(self, days=None): - """Its capital gain over the past `days` (the past_days preference when None), in percent.""" + """Its capital gain over the past `days` (the past_days setting when None), in percent.""" return float(_performance(self.id, days)['capital_gain_percent']) def holdings(self): @@ -147,13 +147,13 @@ def tickers(self): each['market_code']) for each in self.holdings().values()}) def trades(self, days=None): - """Its trades of the past `days` (the past_days preference when None), each with the portfolio's name added.""" + """Its trades of the past `days` (the past_days setting when None), each with the portfolio's name added.""" return [trade | {'portfolio': self.name} for trade in _trades(self.id, days)] def _chosen(listed): """The portfolios of Sharesight's list to report on, after include_portfolios and exclude_portfolios.""" - include, exclude = config.prefs.include_portfolios, config.prefs.exclude_portfolios + include, exclude = config.settings.include_portfolios, config.settings.exclude_portfolios return [Portfolio(each['name'], each['id']) for each in listed if str(each['id']) not in exclude and (not include or str(each['id']) in include)] diff --git a/lib/watchlist.py b/lib/watchlist.py index 3aee9397..4689372a 100644 --- a/lib/watchlist.py +++ b/lib/watchlist.py @@ -16,11 +16,11 @@ def tickers(chat): def watched(chat, typed): """The ticker of a chat's watchlist that a ticker as written names: BHP.AX as written, or BHP on the local exchange - (the exchange_suffix preference) when only BHP.AX is watched; else Yahoo's spelling of what was written. Yahoo is + (the exchange_suffix setting) when only BHP.AX is watched; else Yahoo's spelling of what was written. Yahoo is not asked, so a ticker it no longer knows can still be removed.""" ticker, watching = markets.transform_to_yahoo(typed.upper()), tickers(chat) - local = f'{ticker}.{config.prefs.exchange_suffix}' - if ticker not in watching and config.prefs.exchange_suffix and '.' not in ticker and local in watching: + local = f'{ticker}.{config.settings.exchange_suffix}' + if ticker not in watching and config.settings.exchange_suffix and '.' not in ticker and local in watching: return local return ticker diff --git a/lib/when.py b/lib/when.py index 1f9038dc..94a4130c 100644 --- a/lib/when.py +++ b/lib/when.py @@ -487,7 +487,7 @@ def time_of(hours, mins, half): def parse(text, zone, default_at=DEFAULT_AT): """The Schedule that a chat typed ("weekly at 9am", "every 20 mins weekdays from 10am to 9pm New York"), in the - zone it names last, else in zone (the timezone preference's); at default_at where it says no time. Raises + zone it names last, else in zone (the timezone setting's); at default_at where it says no time. Raises ScheduleError, with examples, for what it cannot read.""" words = text.lower().replace('–', '-').replace('—', '-').replace(',', ' ').split() named = None diff --git a/lib/worker.py b/lib/worker.py index 4c2e46ba..32d2abe8 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -418,7 +418,7 @@ def on_price(request, args): top = 10 if args.keyword else None # top or bottom: one chart shows both ends price.run(request.dest, mode='period' if args.days else 'interday', threshold=0 if top else args.percent, days=args.days, top=top, user=request.user, specific_stock=symbol) - threshold = 0 if top else config.prefs.price_percent if args.percent is None else args.percent + threshold = 0 if top else config.settings.price_percent if args.percent is None else args.percent prime_periods(request, 'l', [f"{threshold:g}", str(top or 0)], buttons.period_for_days(args.days, 'd' if not args.days else None)) # the price list's period buttons (price.run's) @@ -574,7 +574,7 @@ def prime_periods(request, kind, args, shown): are drawn in the background to fill the cache, where the chat can use them: a command asked for, with charts and buttons.""" dest = request.dest - if dest.takes_buttons and dest.takes_charts and config.prefs.graph and delivery.interactive(dest): + if dest.takes_buttons and dest.takes_charts and config.settings.graph and delivery.interactive(dest): charts.prime_in_background(kind, args, request.service, dest.key, shown) def on_plan(request, args): diff --git a/lib/yahoo.py b/lib/yahoo.py index ced4b6b5..7ee6a0af 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -19,7 +19,7 @@ CREDENTIALS = 'finbot_yahoo_credentials.json' # [cookie, crumb]: Yahoo issues the crumb for the cookie: they go together def credentials(renew=False): - """(cookie, crumb) for Yahoo's API, from one cache file kept 14 days whatever the cache preference, and renewed + """(cookie, crumb) for Yahoo's API, from one cache file kept 14 days whatever the cache setting, and renewed together (renew=True: now, for a pair Yahoo refused). Raises RuntimeError when Yahoo gives no cookie or no crumb.""" with _auth: if not renew and (cached := cache.read(CREDENTIALS, 14 * 86400)): @@ -431,10 +431,10 @@ def fetch_details(tickers, strict=False): def symbols(typed): """Yahoo's symbols for tickers as written in a chat: BHP:ASX, BHP.ASX and bhp.ax give BHP.AX (see markets.transform_to_yahoo). A bare symbol Yahoo does not know is tried again on the local exchange, with the - exchange_suffix preference (AX: BHP.AX), unless that is empty; a symbol Yahoo knows neither way stays as written. + exchange_suffix setting (AX: BHP.AX), unless that is empty; a symbol Yahoo knows neither way stays as written. Two requests at most, however many tickers. Raises RuntimeError when Yahoo cannot be reached.""" tickers = [markets.transform_to_yahoo(ticker.upper()) for ticker in typed] - suffix = config.prefs.exchange_suffix + suffix = config.settings.exchange_suffix bare = [ticker for ticker in tickers if '.' not in ticker] if not (suffix and bare): return tickers diff --git a/tests/common.py b/tests/common.py index e6bc47b4..26dd1f42 100644 --- a/tests/common.py +++ b/tests/common.py @@ -36,9 +36,9 @@ def scheduled(key='telegram:1'): def bare_environment(): - """os.environ without finbot's settings (the startup keys and the preferences): for a process that must start from + """os.environ without finbot's settings (the startup keys and the settings): for a process that must start from the defaults.""" - settings = {*config.STARTUP_KEYS, *config.PREFERENCE_KEYS} + settings = {*config.STARTUP_KEYS, *config.SETTING_KEYS} return {k: v for k, v in os.environ.items() if k not in settings} @@ -119,9 +119,9 @@ def env(self, **changes): """Startup settings for this test: config.env with these fields changed.""" self.patch(config, 'env', dataclasses.replace(config.env, **changes)) - def prefs(self, **changes): - """Preferences for this test: config.prefs with these fields changed (what a reload would do).""" - self.patch(config, 'prefs', dataclasses.replace(config.prefs, **changes)) + def settings(self, **changes): + """Settings for this test: config.settings with these fields changed (what a reload would do).""" + self.patch(config, 'settings', dataclasses.replace(config.settings, **changes)) def setUp(self): """A fresh data dir and the offline fixtures: no request leaves the machine, and whatever the bot sends is diff --git a/tests/test_charts.py b/tests/test_charts.py index 3d04613f..79787d17 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -23,7 +23,7 @@ def uncached(chart): """render.draw(chart) without the cache, for the tests that have no fixture's temporary cache dir.""" - with mock.patch.object(config, 'prefs', dataclasses.replace(config.prefs, cache=False)): + with mock.patch.object(config, 'settings', dataclasses.replace(config.settings, cache=False)): return render.draw(chart) diff --git a/tests/test_config.py b/tests/test_config.py index 066dd1e3..6d706345 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,9 +11,9 @@ REPO = Path(config.__file__).resolve().parent.parent -def fields(prefs): - """A Prefs as {name: value}, without the file's own lines.""" - return {name: getattr(prefs, name) for name in config.PREFERENCE_KEYS} +def fields(settings): + """A Settings as {name: value}, without the file's own lines.""" + return {name: getattr(settings, name) for name in config.SETTING_KEYS} def keys_in(path): @@ -23,9 +23,9 @@ def keys_in(path): class Tables(unittest.TestCase): def test_the_two_key_sets_are_disjoint_and_cover_everything_config_reads(self): - """Startup and preference keys do not overlap: config.py reads each startup key by name, and each preference - as a field of Prefs.""" - self.assertEqual(set(config.STARTUP_KEYS) & set(config.PREFERENCE_KEYS), set()) + """Startup and setting keys do not overlap: config.py reads each startup key by name, and each setting + as a field of Settings.""" + self.assertEqual(set(config.STARTUP_KEYS) & set(config.SETTING_KEYS), set()) source = (REPO / 'lib' / 'config.py').read_text() read = set(re.findall(r"""(?:\br\.\w+|getenv)\(\s*['"]([A-Za-z_]+)['"]""", source)) read -= {'STATE_DIRECTORY'} # systemd's own variable, a source for DATA_DIR @@ -36,34 +36,34 @@ def test_secrets_are_startup_keys(self): self.assertTrue(set(config.SECRET_KEYS) <= set(config.STARTUP_KEYS)) self.assertEqual(len(config.SECRET_KEYS), 13) - def test_unset_preferences_take_their_defaults(self): - """An unset preference takes its field's default, as it is: reading tidies nothing away.""" - values = config.preference_values(lambda name, default=None: default) - self.assertEqual(fields(values), fields(config.Prefs())) + def test_unset_settings_take_their_defaults(self): + """An unset setting takes its field's default, as it is: reading tidies nothing away.""" + values = config.setting_values(lambda name, default=None: default) + self.assertEqual(fields(values), fields(config.Settings())) def test_templates_have_every_key_and_the_real_defaults(self): """defaults/settings.conf lists every key, commented out at its built-in default, so that it sets none; finbot.env lists every key; both are valid as shipped.""" template = REPO / 'defaults' / 'settings.conf' - self.assertEqual(sorted(keys_in(template)), sorted(config.PREFERENCE_KEYS)) + self.assertEqual(sorted(keys_in(template)), sorted(config.SETTING_KEYS)) self.assertEqual(sorted(keys_in(REPO / 'defaults' / 'finbot.env')), sorted(config.STARTUP_KEYS)) - self.assertEqual((config.read_preferences(template), config.template_keys()), ({}, [])) # it sets nothing + self.assertEqual((config.read_settings(template), config.template_keys()), ({}, [])) # it sets nothing shown = dict(re.findall(r'^#([A-Za-z_][A-Za-z0-9_]*)=(.*)$', template.read_text(), re.M)) # #key=value lines - self.assertEqual(sorted(shown), sorted(config.PREFERENCE_KEYS)) - self.assertEqual(fields(config.preference_values(config._getenv_for(shown))), fields(config.Prefs())) + self.assertEqual(sorted(shown), sorted(config.SETTING_KEYS)) + self.assertEqual(fields(config.setting_values(config._getenv_for(shown))), fields(config.Settings())) startup = config.read_startup_file(REPO / 'defaults' / 'finbot.env') config.startup_values(lambda name, default=None: startup.get(name, default)) # and is valid as it stands class Parsing(unittest.TestCase): def parse(self, text): - """(what read_preferences makes of text, the file it was written to).""" + """(what read_settings makes of text, the file it was written to).""" path = Path(common.temp_dir(self), 'settings.conf') path.write_text(text) - return config.read_preferences(path), path + return config.read_settings(path), path def errors(self, text): - """The errors read_preferences raises for text.""" + """The errors read_settings raises for text.""" with self.assertRaises(config.ConfigError) as caught: self.parse(text) return caught.exception.errors @@ -77,7 +77,7 @@ def test_comments_blanks_quotes_and_equals_in_values(self): def test_missing_file_is_none(self): """No settings.conf is not an error: None.""" - self.assertIsNone(config.read_preferences(Path(common.temp_dir(self), 'nope'))) + self.assertIsNone(config.read_settings(Path(common.temp_dir(self), 'nope'))) def test_unknown_key_gets_a_hint_and_a_line_number(self): """An unknown key is reported with its line number and the key it was probably meant to be.""" @@ -85,7 +85,7 @@ def test_unknown_key_gets_a_hint_and_a_line_number(self): self.assertEqual(len(errors), 1) self.assertIn('line 2: unknown key futur_days (did you mean future_days?)', errors[0]) - def test_a_startup_key_in_preferences_says_where_it_belongs(self): + def test_a_startup_key_in_settings_says_where_it_belongs(self): """A startup key in settings.conf is sent to finbot.env, without printing its value.""" errors = self.errors('slackOutgoingWebhook=https://x\nip=0.0.0.0\nport=5\nscheduler_catchup=3\n') self.assertEqual(len(errors), 4) @@ -108,9 +108,9 @@ def test_every_problem_is_reported_not_just_the_first(self): def test_invalid_values_are_named(self): """A value of the wrong type is reported with its key and value.""" - prefs = {'future_days': 'abc', 'price_percent': 'x'} + settings = {'future_days': 'abc', 'price_percent': 'x'} with self.assertRaises(config.ConfigError) as caught: - config.preference_values(config._getenv_for(prefs)) + config.setting_values(config._getenv_for(settings)) self.assertEqual(len(caught.exception.errors), 2) self.assertIn("future_days='abc' is not a whole number", caught.exception.errors[0]) @@ -119,30 +119,30 @@ def test_booleans_take_the_usual_words_and_refuse_a_typo(self): for word, value in (('True', True), ('yes', True), ('ON', True), ('1', True), ('false', False), ('No', False), ('off', False), ('0', False)): with self.subTest(word=word): - self.assertIs(config.preference_values(config._getenv_for({'graph': word})).graph, value) + self.assertIs(config.setting_values(config._getenv_for({'graph': word})).graph, value) with self.assertRaises(config.ConfigError) as caught: # it used to mean False without a word - config.preference_values(config._getenv_for({'graph': 'Ture'})) + config.setting_values(config._getenv_for({'graph': 'Ture'})) self.assertIn("graph='Ture' is not true or false", caught.exception.errors[0]) def test_a_blank_value_means_the_default(self): """A key with a blank value takes its default, but for exchange_suffix, where blank means no suffix to try.""" - values = config.preference_values(config._getenv_for({'cache': '', 'future_days': '', 'country_code': ''})) + values = config.setting_values(config._getenv_for({'cache': '', 'future_days': '', 'country_code': ''})) self.assertEqual((values.cache, values.future_days, values.country_code), (True, 30, 'AU')) for given, suffix in (('', ''), ('AX', 'AX'), ('.ax', 'AX'), (' NZ ', 'NZ')): with self.subTest(given=given): - values = config.preference_values(config._getenv_for({'exchange_suffix': given})) + values = config.setting_values(config._getenv_for({'exchange_suffix': given})) self.assertEqual(values.exchange_suffix, suffix) - self.assertEqual(config.preference_values(config._getenv_for({})).exchange_suffix, 'AX') # unset: the default + self.assertEqual(config.setting_values(config._getenv_for({})).exchange_suffix, 'AX') # unset: the default def test_log_level_is_one_of_syslogs_levels_in_any_case(self): """log_level takes ERROR, WARN, INFO or DEBUG, in any case, INFO by default; anything else is a mistake that names the levels.""" for given, level in (('warn', 'WARN'), ('Debug', 'DEBUG'), ('ERROR', 'ERROR'), ('', 'INFO')): with self.subTest(log_level=given): - values = config.preference_values(config._getenv_for({'log_level': given})) + values = config.setting_values(config._getenv_for({'log_level': given})) self.assertEqual(values.log_level, level) with self.assertRaises(config.ConfigError) as caught: - config.preference_values(config._getenv_for({'log_level': 'loud'})) + config.setting_values(config._getenv_for({'log_level': 'loud'})) self.assertEqual(caught.exception.errors, ["log_level='loud' is not one of ERROR, WARN, INFO, DEBUG"]) def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): @@ -151,8 +151,8 @@ def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): instead.""" path = Path(common.temp_dir(self), 'settings.conf') path.write_text(''.join(f'{key}=True\n' for key in config.RETIRED) + 'future_days=45\n') - values = config.read_preferences(path) - self.assertEqual(config.preference_values(config._getenv_for(values)).future_days, 45) + values = config.read_settings(path) + self.assertEqual(config.setting_values(config._getenv_for(values)).future_days, 45) notes = [f'{key} in settings.conf does nothing any more: {instead}' for key, instead in config.RETIRED.items()] self.assertEqual(config.retired_notes(values), notes) @@ -171,28 +171,28 @@ def test_a_retired_key_is_accepted_and_said_to_do_nothing(self): '\n'.join(report)) # there are no home chats def test_unreadable_file_is_an_error_naming_it(self): - """A preferences file that cannot be read is an error that names it.""" + """A settings file that cannot be read is an error that names it.""" path = Path(common.temp_dir(self), 'settings.conf') path.mkdir() # reading a directory fails like a permission error would with self.assertRaises(config.ConfigError) as caught: - config.read_preferences(path) + config.read_settings(path) self.assertIn(f'cannot read {path}', caught.exception.errors[0]) def test_precedence_file_then_environment_then_default(self): - """A preference comes from the file, else the environment, else its default.""" + """A setting comes from the file, else the environment, else its default.""" with mock.patch.dict(os.environ, {'future_days': '5', 'past_days': '4'}): - values = config.preference_values(config._getenv_for({'future_days': '30'})) + values = config.setting_values(config._getenv_for({'future_days': '30'})) self.assertEqual((values.future_days, values.past_days, values.shorts_percent), (30, 4, 15)) def test_the_old_multiplier_name_is_gone_everywhere(self): """volatile_multiplier is no longer read, and in the environment it stops the start with the new name.""" with mock.patch.dict(os.environ, {'volatile_multiplier': '3'}): # not read any more - self.assertEqual(config.preference_values(config._getenv_for({})).volatility_multiplier, 2.0) + self.assertEqual(config.setting_values(config._getenv_for({})).volatility_multiplier, 2.0) with self.assertRaises(config.ConfigError) as caught: config.check_startup_environment() self.assertIn('volatile_multiplier was renamed to volatility_multiplier', caught.exception.errors[0]) - values = config.preference_values(config._getenv_for({'volatility_multiplier': '4'})) + values = config.setting_values(config._getenv_for({'volatility_multiplier': '4'})) self.assertEqual(values.volatility_multiplier, 4.0) def test_startup_file_linting(self): @@ -209,6 +209,22 @@ def test_startup_file_linting(self): path.write_text('port=80\n') self.assertEqual(check_config.check(path, Path(common.temp_dir(self), 'none')), []) + def test_check_config_warns_when_finbot_env_is_not_600_root(self): + """util/check_config.py warns, naming the fix, when finbot.env is not mode 600 owned by root:root, and says + nothing for one that is, or for a file it cannot stat.""" + def stat_of(mode, uid=0, gid=0): + """What os.stat says of a file with this mode, owner and group.""" + return types.SimpleNamespace(st_mode=0o100000 | mode, st_uid=uid, st_gid=gid) + with mock.patch.object(check_config.os, 'stat', return_value=stat_of(0o600)): + self.assertEqual(check_config.permission_warnings('/opt/finbot.env'), []) + with mock.patch.object(check_config.os, 'stat', return_value=stat_of(0o644)): + warning, = check_config.permission_warnings('/opt/finbot.env') + self.assertIn('/opt/finbot.env is 644 root:root, not 600 root:root', warning) + self.assertIn('chmod 600 /opt/finbot.env && chown root:root /opt/finbot.env', warning) + with mock.patch.object(check_config.os, 'stat', return_value=stat_of(0o600, uid=54321, gid=54321)): + self.assertIn('600 54321:54321, not 600 root:root', check_config.permission_warnings('x')[0]) + self.assertEqual(check_config.permission_warnings('/no/such/finbot.env'), []) + def test_check_config_gives_the_startup_warnings_of_a_valid_file(self): """check_config shows what bot.py would warn about at start for the file it checks: here a Telegram token for a webhook that is not set (pull mode).""" @@ -226,7 +242,7 @@ def setUp(self): """An empty settings.conf of this test's own, put back to the defaults afterwards.""" self.file = Path(common.temp_dir(self), 'settings.conf') self.file.write_text('') - patch = mock.patch.object(config, 'preferences_path', str(self.file)) + patch = mock.patch.object(config, 'settings_path', str(self.file)) patch.start() self.addCleanup(patch.stop) self.addCleanup(self.back_to_defaults) @@ -237,7 +253,7 @@ def back_to_defaults(self): config.reload() def write(self, text): - """Write the preferences file.""" + """Write the settings file.""" self.file.write_text(text) def test_a_change_is_seen_at_once(self): @@ -245,18 +261,18 @@ def test_a_change_is_seen_at_once(self): self.write('future_days=45\nprice_percent=7\nshorts_percent=12\nexclude_portfolios=1156263\n' 'exchange_suffix=nz\nhyperlinkProvider=yahoo\nhyperlinkFooter=false\nlog_level=debug\ngraph=0\n' 'demote_volatile=0\nvolatility_multiplier=3\npast_days=14\nmin_analysts=3\n') - before = config.prefs + before = config.settings changes = config.reload() self.assertEqual(len(changes), 13) self.assertIn('future_days 30 -> 45', changes) - self.assertIsNot(config.prefs, before) # one new object: no request sees half a reload - self.assertEqual((config.prefs.future_days, config.prefs.past_days, config.prefs.price_percent, - config.prefs.min_analysts), (45, 14, 7.0, 3)) - prefs = config.prefs - self.assertEqual((prefs.exclude_portfolios, prefs.exchange_suffix, prefs.hyperlinkProvider, - prefs.hyperlinkFooter), (('1156263',), 'NZ', 'yahoo', False)) - self.assertEqual((config.prefs.log_level, config.prefs.graph, config.prefs.demote_volatile, - config.prefs.volatility_multiplier), ('DEBUG', False, False, 3.0)) + self.assertIsNot(config.settings, before) # one new object: no request sees half a reload + self.assertEqual((config.settings.future_days, config.settings.past_days, config.settings.price_percent, + config.settings.min_analysts), (45, 14, 7.0, 3)) + settings = config.settings + self.assertEqual((settings.exclude_portfolios, settings.exchange_suffix, settings.hyperlinkProvider, + settings.hyperlinkFooter), (('1156263',), 'NZ', 'yahoo', False)) + self.assertEqual((config.settings.log_level, config.settings.graph, config.settings.demote_volatile, + config.settings.volatility_multiplier), ('DEBUG', False, False, 3.0)) self.assertEqual(config.reload(), []) # nothing more to change def test_a_value_removed_from_the_file_goes_back_to_its_default(self): @@ -265,9 +281,9 @@ def test_a_value_removed_from_the_file_goes_back_to_its_default(self): config.reload() self.write('# empty\n') self.assertEqual(config.reload(), ['future_days 45 -> 30']) - self.assertEqual(config.prefs.future_days, 30) + self.assertEqual(config.settings.future_days, 30) - def test_defaults_that_follow_a_preference_are_read_when_called(self): + def test_defaults_that_follow_a_setting_are_read_when_called(self): """A cache read without seconds follows cache_seconds as reloaded; an explicit value always wins.""" cache_file = Path(config.env.cache_dir, 'finbot_test_reload.json') cache_file.write_text('{"a": 1}') @@ -291,7 +307,7 @@ def test_an_error_applies_nothing(self): self.write(text) with self.assertRaises(config.ConfigError): config.reload() - self.assertEqual(config.prefs.future_days, 45) + self.assertEqual(config.settings.future_days, 45) def test_a_missing_file_is_an_error_and_keeps_the_running_values(self): """A reload with the file gone is an error and keeps the running values.""" @@ -301,15 +317,15 @@ def test_a_missing_file_is_an_error_and_keeps_the_running_values(self): with self.assertRaises(config.ConfigError) as caught: config.reload() self.assertIn('does not exist; nothing reloaded', caught.exception.errors[0]) - self.assertEqual(config.prefs.future_days, 45) + self.assertEqual(config.settings.future_days, 45) self.file.write_text('') - def test_nothing_copies_the_config_or_freezes_a_preference(self): - """Modules read config.prefs. when they run, so that a reload is seen: no star import (which copied every - name into each module), and no function default or module constant computed from a preference.""" - def reads_prefs(node): - """Whether an expression reads config.prefs.""" - return any(isinstance(n, ast.Attribute) and n.attr == 'prefs' and isinstance(n.value, + def test_nothing_copies_the_config_or_freezes_a_setting(self): + """Modules read config.settings. when they run, so that a reload is seen: no star import (which copied + every name into each module), and no function default or module constant computed from a setting.""" + def reads_settings(node): + """Whether an expression reads config.settings.""" + return any(isinstance(n, ast.Attribute) and n.attr == 'settings' and isinstance(n.value, ast.Name) and n.value.id == 'config' for n in ast.walk(node)) offenders = [] for path in sorted([*(REPO / 'lib').rglob('*.py'), *REPO.glob('*.py'), *(REPO / 'util').glob('*.py')]): @@ -320,52 +336,52 @@ def reads_prefs(node): offenders.append(f'{path.name}:{node.lineno} from lib.config import {names}') if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]: - if reads_prefs(default): + if reads_settings(default): offenders.append(f'{path.name}:{node.lineno} default of {node.name}') for node in tree.body: - if isinstance(node, ast.Assign) and reads_prefs(node.value): + if isinstance(node, ast.Assign) and reads_settings(node.value): offenders.append(f'{path.name}:{node.lineno} module constant') self.assertEqual(offenders, []) - def test_a_scheduled_job_runs_on_the_preferences_in_force_not_the_edited_file(self): - """A scheduled job runs in the bot's process, on the preferences of the last reload: a file edited since, and + def test_a_scheduled_job_runs_on_the_settings_in_force_not_the_edited_file(self): + """A scheduled job runs in the bot's process, on the settings of the last reload: a file edited since, and not reloaded, is not read.""" from lib import jobs seen = [] self.write('future_days=45\n') config.reload() self.write('future_days=99\n') # edited, no reload sent - module = types.SimpleNamespace(run=lambda: seen.append(config.prefs.future_days)) + module = types.SimpleNamespace(run=lambda: seen.append(config.settings.future_days)) jobs.run_in_process('look.py', module, {}) self.assertEqual(seen, [45]) class Startup(unittest.TestCase): - def run_config(self, code='from lib import config as c; print(c.prefs.future_days)', prefs=None, **env): - """Run code in a new interpreter: own data dir, prefs as settings.conf, no settings in the environment.""" + def run_config(self, code='from lib import config as c; print(c.settings.future_days)', settings=None, **env): + """Run code in a new interpreter: own data dir, settings as settings.conf, no settings in the environment.""" data = common.temp_dir(self) - if prefs is not None: - Path(data, 'settings.conf').write_text(prefs) + if settings is not None: + Path(data, 'settings.conf').write_text(settings) env = {**common.bare_environment(), 'DATA_DIR': data, **env} return subprocess.run([sys.executable, '-c', code], cwd=REPO, env=env, capture_output=True, text=True) - def test_no_preferences_file_is_fine(self): + def test_no_settings_file_is_fine(self): """finbot starts without a settings.conf, on the defaults.""" out = self.run_config() self.assertEqual((out.returncode, out.stdout.split()[-1]), (0, '30')) - def test_a_preferences_file_is_read_at_start(self): + def test_a_settings_file_is_read_at_start(self): """settings.conf is read at start.""" - self.assertEqual(self.run_config(prefs='future_days=45\n').stdout.split()[-1], '45') + self.assertEqual(self.run_config(settings='future_days=45\n').stdout.split()[-1], '45') - def test_a_broken_preferences_file_refuses_to_start(self): + def test_a_broken_settings_file_refuses_to_start(self): """A bad value, unknown key, startup key or malformed line in settings.conf stops the start, saying why.""" - for prefs, expect in (('future_days=abc\n', "future_days='abc' is not a whole number"), + for settings, expect in (('future_days=abc\n', "future_days='abc' is not a whole number"), ('futur_days=1\n', 'unknown key futur_days'), ('slackBotToken=x\n', 'belongs in finbot.env'), ('what\n', 'expected KEY=value')): - out = self.run_config(prefs=prefs) - self.assertEqual(out.returncode, 1, prefs) + out = self.run_config(settings=settings) + self.assertEqual(out.returncode, 1, settings) self.assertIn('ERROR config:', out.stderr) self.assertIn(expect, out.stderr) @@ -377,12 +393,12 @@ def test_the_template_is_never_read_and_a_key_set_there_is_pointed_elsewhere(sel shutil.copy(REPO / 'lib' / 'config.py', tree / 'lib') (tree / 'defaults').mkdir() (tree / 'defaults' / 'settings.conf').write_text('# a comment\n#past_days=28\nfuture_days=45\n') - code = 'from lib import config as c; print(c.prefs.future_days); print(*c.startup_warnings(), sep="\\n")' + code = 'from lib import config as c; print(c.settings.future_days); print(*c.startup_warnings(), sep="\\n")' for made in (False, True): data = common.temp_dir(self) if made: Path(data, 'settings.conf').write_text('#future_days=30\n') - with self.subTest(preferences_conf=made): + with self.subTest(settings_conf=made): out = subprocess.run([sys.executable, '-c', code], cwd=tree, env={**common.bare_environment(), 'DATA_DIR': data}, capture_output=True, text=True) conf = f'{data}/settings.conf' @@ -390,7 +406,7 @@ def test_the_template_is_never_read_and_a_key_set_there_is_pointed_elsewhere(sel self.assertEqual(out.stdout.split('\n')[:2], ['30', f'defaults/settings.conf sets future_days, but finbot never reads it: {fix}'], out.stderr) - def test_an_unreadable_preferences_file_refuses_to_start(self): + def test_an_unreadable_settings_file_refuses_to_start(self): """A settings.conf that cannot be read stops the start.""" data = common.temp_dir(self) Path(data, 'settings.conf').mkdir() @@ -405,24 +421,24 @@ def test_a_bad_startup_value_refuses_to_start(self): self.assertEqual(out.returncode, 1) self.assertIn("port='http' is not a whole number", out.stderr) - def test_a_preference_in_the_environment_is_refused_by_the_bot(self): - """A preference in the bot's environment (finbot.env) is refused, but a job process gets its preferences that + def test_a_setting_in_the_environment_is_refused_by_the_bot(self): + """A setting in the bot's environment (finbot.env) is refused, but a job process gets its settings that way, and startup keys belong there.""" with mock.patch.dict(os.environ, {'future_days': '30', 'log_level': 'DEBUG'}): with self.assertRaises(config.ConfigError) as caught: config.check_startup_environment() self.assertEqual(len(caught.exception.errors), 2) - self.assertIn('future_days is a preference; move it from finbot.env to settings.conf', + self.assertIn('future_days is a setting; move it from finbot.env to settings.conf', '\n'.join(caught.exception.errors)) with mock.patch.dict(os.environ, {'ip': '1.2.3.4', 'port': '80'}): config.check_startup_environment() # startup keys are what the environment is for - def test_the_bot_refuses_to_start_with_a_preference_in_its_environment(self): - """bot.py exits 1 when finbot.env holds a preference.""" + def test_the_bot_refuses_to_start_with_a_setting_in_its_environment(self): + """bot.py exits 1 when finbot.env holds a setting.""" out = subprocess.run([sys.executable, 'bot.py'], cwd=REPO, capture_output=True, text=True, timeout=60, env={**os.environ, 'DATA_DIR': common.temp_dir(self), 'future_days': '30'}) self.assertEqual(out.returncode, 1) - self.assertIn('future_days is a preference', out.stderr) + self.assertIn('future_days is a setting', out.stderr) def test_a_leftover_env_in_the_checkout_refuses_to_start(self): """An old .env in the checkout stops the start, pointing to util/split_env.py.""" @@ -501,7 +517,7 @@ def test_discord_settings_that_leave_part_of_it_unable_to_work_are_warned_about( self.assertEqual(config.startup_warnings(), warnings) def test_startup_report_is_a_short_count_never_names_or_values(self): - """The startup report counts the startup keys set and the preferences, and never names them or shows their + """The startup report counts the startup keys set and the settings, and never names them or shows their values.""" with mock.patch.dict(os.environ, {'slackOutgoingWebhook': 'https://secret.example/abc', 'port': '5000'}, clear=True): @@ -514,28 +530,28 @@ def test_startup_report_is_a_short_count_never_names_or_values(self): conf = Path(common.temp_dir(self), 'settings.conf') conf.write_text('graph = false\n') for source, count in (({'graph': 'false'}, '1 key'), ({'log_level': 'DEBUG', 'graph': 'false'}, '2 keys')): - with self.subTest(source=source), mock.patch.object(config, 'preferences_path', str(conf)), \ - mock.patch.object(config, 'prefs', dataclasses.replace(config.prefs, source=source)): + with self.subTest(source=source), mock.patch.object(config, 'settings_path', str(conf)), \ + mock.patch.object(config, 'settings', dataclasses.replace(config.settings, source=source)): self.assertEqual(config.startup_report()[1], f'{conf} ({count} set; SIGHUP to reload)') - with mock.patch.object(config, 'preferences_path', str(conf.with_name('missing.conf'))): + with mock.patch.object(config, 'settings_path', str(conf.with_name('missing.conf'))): self.assertEqual(config.startup_report()[1], f'no settings.conf in {config.env.data_dir}, reload' ' unavailable') class SigHup(unittest.TestCase): def restore(self, file): - """Empty the preferences file and reload: back to the defaults.""" - with mock.patch.object(config, 'preferences_path', str(file)): + """Empty the settings file and reload: back to the defaults.""" + with mock.patch.object(config, 'settings_path', str(file)): file.write_text('') config.reload() - def test_reload_all_reloads_preferences_and_keeps_them_on_an_error(self): - """SIGHUP reloads preferences; on an error (logged) the running ones stay, and the bot survives it. The jobs + def test_reload_all_reloads_settings_and_keeps_them_on_an_error(self): + """SIGHUP reloads settings; on an error (logged) the running ones stay, and the bot survives it. The jobs need no reload: a change made with .jobs counts at once.""" file = Path(common.temp_dir(self), 'settings.conf') file.write_text('future_days=45\n') self.addCleanup(self.restore, file) - with mock.patch.object(config, 'preferences_path', str(file)): + with mock.patch.object(config, 'settings_path', str(file)): err = io.StringIO() with contextlib.redirect_stderr(err): bot.reload_all() @@ -545,7 +561,7 @@ def test_reload_all_reloads_preferences_and_keeps_them_on_an_error(self): with contextlib.redirect_stderr(err): bot.reload_all() self.assertIn('ERROR config:', err.getvalue()) - self.assertEqual(config.prefs.future_days, 45) + self.assertEqual(config.settings.future_days, 45) def test_a_reload_points_a_key_set_in_the_template_at_the_file_to_set_it_in(self): """A key set in defaults/settings.conf, which finbot never reads, is reported on a reload too, with the file @@ -555,21 +571,22 @@ def test_a_reload_points_a_key_set_in_the_template_at_the_file_to_set_it_in(self self.addCleanup(self.restore, file) template = Path(common.temp_dir(self), 'template.conf') template.write_text('#past_days=28\nlog_level=DEBUG\n') - with mock.patch.object(config, 'preferences_path', str(file)), mock.patch.object(config, 'TEMPLATE', template): + with mock.patch.object(config, 'settings_path', str(file)), mock.patch.object(config, 'TEMPLATE', template): with contextlib.redirect_stderr(io.StringIO()) as err: bot.reload_all() self.assertIn(f'WARN config: defaults/settings.conf sets log_level, but finbot never reads it: edit {file}' ' instead', err.getvalue()) - self.assertEqual(config.prefs.log_level, 'INFO') + self.assertEqual(config.settings.log_level, 'INFO') class Tools(unittest.TestCase): def test_split_env(self): - """split_env sorts an old .env into startup keys and prefs, renames volatile_multiplier, lists what it drops.""" - startup, prefs, notes = split_env.split('slackBotToken=x\nfuture_days=3\nvolatile_multiplier=2\nslackToken=y\n' - 'STATE_DIRECTORY=/a\nbogus=1\nnot a setting\n# c\nport=80\nslack_webhook=z\n') + """split_env sorts an old .env into startup keys and settings, renames volatile_multiplier, lists what it + drops.""" + startup, settings, notes = split_env.split('slackBotToken=x\nfuture_days=3\nvolatile_multiplier=2\n' + 'slackToken=y\nSTATE_DIRECTORY=/a\nbogus=1\nnot a setting\n# c\nport=80\nslack_webhook=z\n') self.assertEqual(startup, ['slackBotToken=x', 'port=80']) - self.assertEqual(prefs, ['future_days=3', 'volatility_multiplier=2']) + self.assertEqual(settings, ['future_days=3', 'volatility_multiplier=2']) self.assertEqual(len(notes), 5) self.assertTrue(any('slackToken dropped' in n for n in notes) and any('unknown key bogus' in n for n in notes)) self.assertIn("line 10: slack_webhook dropped: each chat's jobs (/jobs) post there as the bot", notes[-1]) @@ -578,10 +595,10 @@ def test_split_env_reads_the_old_dotenv_format_too(self): """split_env reads the old dotenv format: quotes, export, comments after values and # inside URLs.""" old = ("# c\nfuture_days = '30' # note\nexport slackOutgoingWebhook = \"https://x/#frag\"\nport = 80 # note\n" "bare\ndiscordOutgoingWebhook=http://y/#z\n\n") - startup, prefs, notes = split_env.split(old) + startup, settings, notes = split_env.split(old) self.assertEqual(startup, ['slackOutgoingWebhook=https://x/#frag', 'port=80', 'discordOutgoingWebhook=http://y/#z']) - self.assertEqual(prefs, ['future_days=30']) + self.assertEqual(settings, ['future_days=30']) self.assertEqual(notes, ['.env line 5: not KEY=value, left out']) def test_split_env_writes_next_to_the_file_and_never_overwrites(self): diff --git a/tests/test_datadir.py b/tests/test_datadir.py index f471f5f6..ad41b6d7 100644 --- a/tests/test_datadir.py +++ b/tests/test_datadir.py @@ -39,7 +39,7 @@ def test_data_dir_moves_var_and_cache(self): def test_config_reads_environment_only(self): """Settings come from the environment; python-dotenv is not loaded.""" - code = "import sys; from lib import config as c; print(c.prefs.price_percent, 'dotenv' in sys.modules)" + code = "import sys; from lib import config as c; print(c.settings.price_percent, 'dotenv' in sys.modules)" out = run_config(code, price_percent='3.3') self.assertEqual(out.split()[-2:], ['3.3', 'False']) @@ -94,7 +94,7 @@ def test_sync_defaults(self): self.assertTrue(Path(config.env.data_dir, 'finbot_adr.json').exists()) copied = Path(config.env.data_dir, 'settings.conf') # the template as it is: every key commented out self.assertEqual(copied.read_bytes(), (config.REPO_DIR / 'defaults' / 'settings.conf').read_bytes()) - self.assertEqual(config.read_preferences(copied), {}) + self.assertEqual(config.read_settings(copied), {}) # the startup file is never copied into the data dir self.assertFalse(Path(config.env.data_dir, 'finbot.env').exists()) mine = Path(config.env.data_dir, 'finbot_adr.json') diff --git a/tests/test_edges.py b/tests/test_edges.py index c64b9685..37b77d80 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -265,13 +265,13 @@ def test_a_ticker_yahoo_does_not_know_is_tried_on_the_local_exchange(self): self.patch(yahoo, 'fetch', lambda ts: asked.append(sorted(ts)) or {t: market_entry(t) for t in ts if '.' in t}) self.assertEqual(yahoo.symbols(['BHP', 'BHP.L', 'cba', 'CBA:ASX']), ['BHP.AX', 'BHP.L', 'CBA.AX', 'CBA.AX']) self.assertEqual(asked, [['BHP', 'CBA'], ['BHP.AX', 'CBA.AX']]) # two requests, however many tickers - self.prefs(exchange_suffix='NZ') + self.settings(exchange_suffix='NZ') self.assertEqual(yahoo.symbol('BHP'), 'BHP.NZ') - self.prefs(exchange_suffix='') + self.settings(exchange_suffix='') asked.clear() self.assertEqual(yahoo.symbol('BHP'), 'BHP') self.assertEqual(asked, []) # nothing to try, so Yahoo is not asked - self.prefs(exchange_suffix='AX') + self.settings(exchange_suffix='AX') for message, shown in (('.price BHP', 'BHP.AX'), ('.history BHP', 'BHP.AX'), ('.earnings BHP', 'BHP.AX'), ('.shorts BHP', 'BHP'), ('.pe BHP', 'BHP'), ('.marketcap BHP', 'BHP.AX'), ('.BHP', 'BHP.AX')): with self.subTest(message=message): @@ -367,7 +367,7 @@ class LogLevels(FinbotCase): def logged(self, level, run): """What run() logs with log_level at this level.""" - self.prefs(log_level=level) + self.settings(log_level=level) with contextlib.redirect_stderr(io.StringIO()) as err: run() return err.getvalue().splitlines() @@ -428,17 +428,17 @@ def test_each_request_is_a_short_line_without_secrets(self): 'POST api.telegram.org/bot/sendMessage', 'PATCH discord.com/api/v10/channels/7/messages/9', 'GET query2.finance.yahoo.com/v10/finance/quoteSummary/ARM.L?modules=ca…']) self.assertEqual(len(lines[3]), 71) - self.prefs(log_level='WARN') + self.settings(log_level='WARN') with contextlib.redirect_stderr(io.StringIO()) as err: net.get('https://example.com/') self.assertEqual(err.getvalue(), '') def test_log_width_sets_where_a_long_line_is_cut(self): - """The log_width preference is where a long line is cut, its last character an ellipsis; at 0 none is.""" + """The log_width setting is where a long line is cut, its last character an ellipsis; at 0 none is.""" whole = 'GET query2.finance.yahoo.com/v10/finance/quoteSummary/ARM.L?modules=calendarEvents' # 82 characters for width, line in ((20, 'GET query2.finance.…'), (81, whole[:80] + '…'), (82, whole), (0, whole), (1, '…')): with self.subTest(width=width): - self.prefs(log_width=width) + self.settings(log_width=width) with contextlib.redirect_stderr(io.StringIO()) as err: net.get('https://' + whole[4:]) self.assertEqual(err.getvalue().splitlines(), [line]) @@ -460,7 +460,7 @@ def logged(update_id, chat=55): whole = 'Telegram @rob /jobs add message daily at 9am "Morning! The ASX opens at 10am, so coffee first"' self.assertEqual(logged(1), [whole[:70] + '…']) self.assertEqual(logged(2), ['Telegram @rob sent it again within 1 s, not run again: /jobs add messa…']) - self.prefs(log_width=0) + self.settings(log_width=0) self.assertEqual(logged(3, chat=66), [whole]) # another chat's: taken diff --git a/tests/test_finbot.py b/tests/test_finbot.py index c161f55b..a4ed5e6b 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -60,7 +60,7 @@ def post(message): out = bot.main(env, lambda *a, **k: None) return out, err.getvalue() self.assertEqual(post({'sticker': {'file_id': 'x'}}), ([b''], '')) - self.prefs(log_level='DEBUG') + self.settings(log_level='DEBUG') self.assertEqual(post({'sticker': {'file_id': 'x'}}), ([b''], 'POST from telegram: we answered 200 OK\n')) self.assertGreaterEqual(len(bot.git_version()), 7) head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=bot.__file__.rsplit('/', 1)[0], capture_output=True, diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 3fe6bdf9..06a0fd48 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -156,7 +156,7 @@ def test_an_update_is_taken_however_it_arrived(self): def test_button_presses_are_logged(self): """A button press is logged at DEBUG (the routine of a menu) with who pressed it (handle, first name or unknown), the chat and the button.""" - self.prefs(log_level='DEBUG') + self.settings(log_level='DEBUG') calls = [] self.patch(worker, 'process_callback', lambda *a: calls.append(a)) secret = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} diff --git a/tests/test_reports.py b/tests/test_reports.py index 43e95574..af19c163 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -19,7 +19,7 @@ def test_text_commands(self): def test_exdividend_is_the_ex_dividend_report(self): """.exdividend and .ex-dividend are .dividend: the ex-dividend dates, not a stock named EXDIVIDEND.""" - self.prefs(graph=False) + self.settings(graph=False) for text in ('.dividend', '.exdividend', '.ex-dividend'): with self.subTest(text=text): self.assertIn('Upcoming ex-dividends', str(self.command(text))) @@ -33,7 +33,7 @@ def test_a_period_list_and_one_stocks_view_give_the_same_figure(self): def t1(text): """T1's figure in what the command sends as text.""" return re.search(r'Co T1 \(.*?\) ([+-]\d+%)', str(self.command(text))).group(1) - self.prefs(graph=False) # the list as text: a chart's caption is recorded by its heading + self.settings(graph=False) # the list as text: a chart's caption is recorded by its heading self.assertEqual((t1('.price 7d'), t1('.price T1 7d')), ('-36%', '-36%')) def test_chart_commands(self): @@ -165,11 +165,11 @@ def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | ({'regularMarketPrice': 1380.0} if t == 'KRW=X' else {'currency': 'KRW', 'market_cap': 1e11}) for t in ts}) - self.prefs(demote_volatile=True) + self.settings(demote_volatile=True) quote = price.gather(TICKERS)['T1'] self.assertAlmostEqual(quote['market_cap_usd'], 1e11 / 1380.0) # US$72M - self.assertEqual(price.trigger_bar('T1', quote, 5, {}), 5 * config.prefs.volatility_multiplier) + self.assertEqual(price.trigger_bar('T1', quote, 5, {}), 5 * config.settings.volatility_multiplier) def test_a_short_top_list_repeats_none_of_its_best_as_the_worst(self): """With fewer than twice `top` stocks, the Bottom list leaves out those in the Top list (it repeated them), and @@ -189,7 +189,7 @@ def test_the_small_caps_betas_are_fetched_together(self): self.patch(yahoo, 'fetch_details', lambda tickers, strict=False: calls.append(sorted(tickers)) or {t: market_entry(t) for t in tickers}) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: self.fail('a beta fetched on its own')) - self.prefs(demote_volatile=True) + self.settings(demote_volatile=True) self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'market_cap': 5e8} for t in ts}) price.run(scheduled()) @@ -213,7 +213,7 @@ def listed(mode='top'): self.assertIn('forward pe ratio (7+ analysts)', heading) self.assertEqual(listed('negative')[1], ['T3']) self.assertIn('Co T2', rankings.ratio_line(TELEGRAM, 'T2', 'forward pe')) # one ticker asked for: shown - self.prefs(min_analysts=13) # the one setting moves .buy and .forward pe together + self.settings(min_analysts=13) # the one setting moves .buy and .forward pe together self.assertEqual(listed()[0], 'No tracked stocks with 13+ analysts have a forward pe ratio') self.assertEqual(rankings.prepare_rating_payload(TELEGRAM, 'telegram:55', 'buy')[0], ['No tracked stocks rated buy by 13+ analysts']) @@ -776,9 +776,9 @@ def test_unknown_or_missing_portfolio_lists_known(self): self.assertEqual(self.command('.pie')[0][0], 'photo') # the buttons of the portfolios self.assertEqual(self.command('.pie Nope'), [('text', ['Nope portfolio not found. I only know about:', 'Rob'])]) - def test_with_the_graph_preference_off_a_chart_goes_as_text(self): + def test_with_the_graph_setting_off_a_chart_goes_as_text(self): """With graph off, a report's chart is left out and its lines go as text, on every network.""" - self.prefs(graph=False) + self.settings(graph=False) for service in (TELEGRAM, SLACK, DISCORD): worker.Request(service, 'C1', '@u', 'U', BOT).reply(['x'], render.Chart('pie', 't', rows=(('a', 1.0),))) self.assertEqual(self.sent[-1], ('text', ['x'])) @@ -1051,7 +1051,7 @@ def test_without_holdings_the_watchlist_is_offered(self): def test_with_the_graph_off_the_picker_is_text_and_edits_as_text(self): """Where charts are not drawn, the answer and its buttons are a text message, edited as text.""" - self.prefs(graph=False) + self.settings(graph=False) self.portfolios('Amy', 'Zed') self.command('.pie') self.assertEqual(self.sent[-1][0], 'text') @@ -1152,7 +1152,7 @@ def test_a_pie_or_treemap_primes_the_other_charts_in_the_background(self): for text in ('.pie', '.treemap sector', '.history', '.compare', '.holdings'): self.command(text) self.assertEqual([args[1:] for args in started], [('p', '0', ['Amy', 'Zed'], 0), ('t', 's', ['Amy', 'Zed'], 0)]) - self.prefs(graph=False) + self.settings(graph=False) self.command('.pie') self.assertEqual(len(started), 2) @@ -1203,7 +1203,7 @@ def test_every_chart_with_a_period_row_primes_its_other_periods(self): self.assertEqual(started, [('c', ['T1', 'T2'], 'x'), ('f', [''], 'm'), ('l', ['5', '0'], 'd'), ('l', ['0', '10'], 'd'), ('c', ['T0', 'T1'], 'w')]) started.clear() - self.prefs(graph=False) + self.settings(graph=False) self.command('.compare T1 T2') self.assertEqual(started, []) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index f32cb56e..8a48c12d 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -215,7 +215,7 @@ def test_the_default_jobs_as_a_melbourne_chat_sees_them(self): 'message: SPCX 5th mensiversary. End of (statistically) most … · once, 12 Nov 2026 at 8:30am Sydney']) def test_a_message_for_one_country_only_there_with_its_flag(self): - """A message for one country is a default job only where it is the country_code preference, with its flag.""" + """A message for one country is a default job only where it is the country_code setting, with its flag.""" au, nz = ({item.key for item in catalog.items(country)} for country in ('AU', 'NZ')) self.assertEqual(sorted(au - nz), ['message-eofy-checklist', 'message-intent-to-claim', 'message-tax-due-soon', 'message-tax-due-today', 'message-ubank-draw-6']) @@ -249,7 +249,7 @@ def test_the_end_of_financial_year_checklist(self): 'DGR-registered') def test_the_birthday_counts_the_years(self): - """finbot's birthday says how old it is that day, by the timezone preference's clock.""" + """finbot's birthday says how old it is that day, by the timezone setting's clock.""" birthday = next(item for item in catalog.ITEMS if item.key == 'message-birthday') for today, said in ((datetime.date(2026, 8, 18), "It's my 4th birthday! 🥳"), (datetime.date(2026, 8, 17), "It's my 3rd birthday! 🥳")): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e13188f3..6e68bfad 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -106,8 +106,8 @@ def unreachable(): self.assertEqual((yahoo_warm.run(), len(asked)), (1, 1)) def test_with_the_cache_off_nothing_is_fetched(self): - """With the cache preference off nothing would be kept, so nothing is fetched.""" - self.prefs(cache=False) + """With the cache setting off nothing would be kept, so nothing is fetched.""" + self.settings(cache=False) self.patch(yahoo, '_chart_answer', None) self.assertEqual(yahoo_warm.run(), 0) @@ -115,13 +115,13 @@ def test_with_the_cache_off_nothing_is_fetched(self): class SharesightWarmJob(FinbotCase): def test_the_portfolios_are_refreshed_where_sharesight_is_configured(self): """sharesight_warm refreshes the portfolio list and the holdings (sharesight.warm) and counts the portfolios; - without Sharesight configured, or with the cache preference off (nothing would be kept), it fetches nothing.""" + without Sharesight configured, or with the cache setting off (nothing would be kept), it fetches nothing.""" self.patch(sharesight, 'warm', lambda: ['Rob', 'RobSMSF']) self.env(sharesight_auth={'client_id': 'id'}) self.assertEqual(sharesight_warm.run(), 2) - self.prefs(cache=False) + self.settings(cache=False) self.assertEqual(sharesight_warm.run(), 0) - self.prefs(cache=True) + self.settings(cache=True) self.env(sharesight_auth={'client_id': None}) self.assertEqual(sharesight_warm.run(), 0) @@ -229,8 +229,8 @@ def test_the_system_jobs_are_fixed_and_no_chats(self): self.assertEqual({job.label: job.schedule.describe('Australia/Melbourne') for job in s.system_jobs()}, {'sharesight_warm': 'every 30 mins from :14 UTC', 'yahoo_warm': 'every 4 hours at :07 UTC', 'cleanup 30': 'weekly, Saturdays at 18:51 UTC'}) - self.assertLess(30 * 60, config.prefs.performance_cache_seconds) - self.assertLess(4 * 3600, config.prefs.cache_seconds) + self.assertLess(30 * 60, config.settings.performance_cache_seconds) + self.assertLess(4 * 3600, config.settings.cache_seconds) self.assertEqual(s.chat_jobs(''), s.system_jobs()) for name in jobs.SYSTEM_ACTIONS: with self.subTest(name=name): @@ -548,7 +548,7 @@ def test_without_sharesight_no_job_reports_on_it(self): self.assertIn('price', labels) def test_add_reads_english_and_the_period_follows_how_often(self): - """A job is a report, what it takes, and a schedule in English, by the timezone preference's clock unless it + """A job is a report, what it takes, and a schedule in English, by the timezone setting's clock unless it names a zone; performance and price cover the days between runs where they name no period.""" for text, line, args in (('price weekly', 'price · weekly, Saturdays at 9:00am', ['7']), ('performance first day of each month at 9am', 'performance · monthly, the 1st at 9:00am', ['30']), @@ -859,7 +859,7 @@ def test_the_older_job_buttons_still_work(self): def test_add_by_buttons_back_and_cancel(self): """Add takes the report (by its name alone), how often, then the day, the hour and the minute from buttons, by - the timezone preference's clock, each choice's default marked † and the key under it in italics; Cancel goes + the timezone setting's clock, each choice's default marked † and the key under it in italics; Cancel goes back to the list, and after the report Back a step back; its last step adds the job and says so, the same job as typed.""" lines, rows, data = self.press(f'm|add|{self.crc()}') diff --git a/tests/test_sending.py b/tests/test_sending.py index e9c462d7..e3097968 100644 --- a/tests/test_sending.py +++ b/tests/test_sending.py @@ -218,9 +218,9 @@ def test_scheduled_reports_carry_their_charts_on_every_network(self): report(scheduled(key)) self.assertEqual(sorted(set(sent)), [('discord', 'chart'), ('slack', 'chart'), ('telegram', 'chart')]) - def test_the_graph_preference_off_sends_no_chart_anywhere(self): + def test_the_graph_setting_off_sends_no_chart_anywhere(self): """graph=False means no charts, as doc/configuration.md says; it held back only the price-line charts.""" - self.prefs(graph=False) + self.settings(graph=False) self.env(slackBotToken='xoxb-test') diff --git a/tests/test_sharesight.py b/tests/test_sharesight.py index 1d094a1a..2864a9b5 100644 --- a/tests/test_sharesight.py +++ b/tests/test_sharesight.py @@ -18,10 +18,10 @@ def test_portfolios_from_real_response(self): def test_portfolio_exclusion_and_inclusion(self): """exclude_portfolios and include_portfolios pick the portfolios reported on.""" self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) - self.prefs(exclude_portfolios=['445825']) + self.settings(exclude_portfolios=['445825']) self.assertEqual(portfolios(), {'RobSMSF': 684141}) - self.prefs(exclude_portfolios=[], include_portfolios=['445825']) + self.settings(exclude_portfolios=[], include_portfolios=['445825']) self.assertEqual(portfolios(), {'Rob': 445825}) def test_portfolio_failures_raise(self): diff --git a/tests/test_util.py b/tests/test_util.py index 78ac5591..c4d91723 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -237,7 +237,7 @@ class Links(FinbotCase): def test_google_links_go_to_yahoo_where_google_has_no_page(self): """With the google provider an index, a crypto pair and a Taipei listing link to Yahoo; the rest to Google, with the chart window nearest the period.""" - self.prefs(hyperlinkProvider='google') + self.settings(hyperlinkProvider='google') for symbol, exchange in (('^GSPC', 'SNP'), ('BTC-USD', 'CCC'), ('6488.TWO', 'Taipei Exchange')): with self.subTest(symbol=symbol): self.assertIn('au.finance.yahoo.com/quote/' + symbol, links.finance_link(symbol, exchange, TELEGRAM)) @@ -245,11 +245,11 @@ def test_google_links_go_to_yahoo_where_google_has_no_page(self): self.assertIn('google.com/finance/quote/BHP:ASX?window=1M', month) def test_without_hyperlinks_a_link_is_its_text(self): - """With the hyperlink preference off, either provider shows the link's text: BHP, not BHP.AX.""" - self.prefs(hyperlink=False) + """With the hyperlink setting off, either provider shows the link's text: BHP, not BHP.AX.""" + self.settings(hyperlink=False) for provider in ('google', 'yahoo'): with self.subTest(provider=provider): - self.prefs(hyperlinkProvider=provider) + self.settings(hyperlinkProvider=provider) self.assertEqual(links.finance_link('BHP.AX', 'ASX', TELEGRAM), 'BHP') @@ -363,7 +363,7 @@ def at(self, zone, *args): return datetime.datetime(*args, tzinfo=zoneinfo.ZoneInfo(zone)) def test_english_becomes_a_schedule(self): - """Each form a chat may type becomes the schedule it says, in the zone it names or the timezone preference's.""" + """Each form a chat may type becomes the schedule it says, in the zone it names or the timezone setting's.""" for text, stored in (('daily', {'days': 'daily', 'at': '09:00'}), ('at 9am', {'days': 'daily', 'at': '09:00'}), ('weekdays at 4:30pm', {'days': 'weekdays', 'at': '16:30'}), ('weekly', {'days': ['sat'], 'at': '09:00'}), diff --git a/tests/test_yahoo.py b/tests/test_yahoo.py index 0096573b..901c129e 100644 --- a/tests/test_yahoo.py +++ b/tests/test_yahoo.py @@ -60,8 +60,8 @@ def test_the_cache_holds_yahoos_answer_so_a_parser_fix_needs_no_flush(self): self.assertEqual(yahoo.fetch(['AAPL'])['AAPL']['short_name'], 'Apple Inc.') def test_with_the_cache_off_every_fetch_asks_yahoo(self): - """With the cache preference off, every fetch asks Yahoo, and nothing is written to the cache.""" - self.prefs(cache=False) + """With the cache setting off, every fetch asks Yahoo, and nothing is written to the cache.""" + self.settings(cache=False) getter = self.http(yahoo, Resp(sample('yahoo', 'sample_yahoo_v7.json'))) yahoo.fetch(['AAPL']) yahoo.fetch(['AAPL']) @@ -100,7 +100,7 @@ def test_a_listing_guessed_at_that_yahoo_does_not_know_is_logged_at_debug(self): self.http(yahoo, *[Resp({'quoteSummary': {'result': None, 'error': None}}) for _ in cases]) for ticker, guess, level, shown in cases: with self.subTest(guess=guess, log_level=level): - self.prefs(log_level=level) + self.settings(log_level=level) err = io.StringIO() with contextlib.redirect_stderr(err): self.assertEqual(yahoo.fetch_detail(ticker, guess=guess), {}) @@ -291,7 +291,7 @@ def get(url, **kwargs): self.patch(yahoo.net, 'get', get) def slack(text): """Run a command on Slack, its report as text (no charts), and return the text.""" - self.prefs(graph=False) + self.settings(graph=False) self.sent.clear(); self.errors.clear() worker.process_request(inbound.Message('slack', 'C1', '<@U1>', text, BOT, 'U')) self.assertEqual(self.errors, [], text) @@ -317,9 +317,9 @@ def test_the_chart_request_carries_the_cookie_of_its_crumb(self): self.assertEqual(headers[0].get('Cookie'), 'A=B') # the crumb used to go without its cookie def test_with_the_cache_off_no_chart_is_written(self): - """With the cache preference off, a chart is drawn but not written to the cache.""" + """With the cache setting off, a chart is drawn but not written to the cache.""" self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - self.prefs(cache=False) + self.settings(cache=False) self.http(yahoo, Resp(self.chart([100.0] * 30 + [110.0]))) changes, line = yahoo.price_history('T1', 7) self.assertTrue(render.draw(history.line_chart('T1', changes, line, 7)).read().startswith(b'\x89PNG')) diff --git a/util/check_config.py b/util/check_config.py index fa2fc7b3..9626f2e2 100755 --- a/util/check_config.py +++ b/util/check_config.py @@ -3,54 +3,64 @@ util/check_config.py /opt/finbot.env [/var/lib/finbot/settings.conf] -finbot.env (the startup file) is never read by the bot, so this is the only place its typos are caught: an unknown key, -a preference key in it, a malformed line or a bad value. settings.conf defaults to /settings.conf and is -checked the way the bot checks it at start and on reload. Exits 1 and prints every problem if either file is wrong, so a -launcher can refuse to start (see doc/install.md and doc/docker.md).""" +finbot.env (the startup file) is never read by the bot, so this is the only place its +typos are caught: an unknown key, a setting key in it, a malformed line or a bad value. +settings.conf defaults to /settings.conf and is checked as the bot checks it +at start and on reload. It also warns when finbot.env is not 600 root:root, as it holds +the credentials. Exits 1 and prints every problem if either file is wrong, so a launcher +can refuse to start (see doc/install.md and doc/docker.md).""" import argparse +import grp import os +import pwd +import stat import sys from pathlib import Path if __name__ == '__main__': - # importing lib.config must not create the data directories (this may run as root, from systemd) + # importing lib.config must not create the data directories (may run as root, from systemd) os.environ['FINBOT_CHECK_ONLY'] = '1' sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from lib import config -def check(startup_path, preferences_path): - """Every problem in the two files, as a list of lines (a key that does nothing any more is a note, not a problem: - notes() has those).""" +def _startup_values(path): + """The startup settings that a finbot.env gives. Raises config.ConfigError.""" + values = config.read_startup_file(path) + return config.startup_values(lambda name, default=None: values.get(name, default)) + + +def check(startup_path, settings_path): + """Every problem in the two files, as a list of lines (a key that does nothing any more is a note, not + a problem: notes() has those).""" problems = [] if startup_path: try: - values = config.read_startup_file(startup_path) - config.startup_values(lambda name, default=None: values.get(name, default)) + _startup_values(startup_path) except config.ConfigError as e: problems += e.errors try: - prefs = config.read_preferences(preferences_path) - if prefs is not None: - config.preference_values(config._getenv_for(prefs)) + settings = config.read_settings(settings_path) + if settings is not None: + config.setting_values(config._getenv_for(settings)) except config.ConfigError as e: problems += e.errors return problems def warnings(startup_path): - """What a valid finbot.env sets that keeps part of the bot from working or does nothing, a line each: what bot.py - logs at start.""" + """What a valid finbot.env sets that keeps part of the bot from working or does nothing, a line each: + what bot.py logs at start.""" try: - values = config.read_startup_file(startup_path) - return config.startup_warnings(config.startup_values(lambda name, default=None: values.get(name, default))) + return config.startup_warnings(_startup_values(startup_path)) except config.ConfigError: return [] -def notes(preferences_path, startup_path=None): - """The keys of the two files, where valid, that do nothing any more, a line each with what to do instead.""" +def notes(settings_path, startup_path=None): + """The keys of the two files, where valid, that do nothing any more, a line each with what to do + instead.""" lines = [] if startup_path: try: @@ -58,25 +68,50 @@ def notes(preferences_path, startup_path=None): except config.ConfigError: pass try: - return lines + config.retired_notes(config.read_preferences(preferences_path) or {}) + return lines + config.retired_notes(config.read_settings(settings_path) or {}) except config.ConfigError: return lines +def _name(lookup, number): + """The name of a user or group by its number, or the number when it has none.""" + try: + return lookup(number)[0] + except KeyError: + return str(number) + + +def permission_warnings(path): + """A line saying so if the file is not 600 root:root, as finbot.env should be (it holds the credentials, + and systemd loads it as root); nothing if it is, or cannot be read.""" + try: + info = os.stat(path) + except OSError: + return [] + mode = stat.S_IMODE(info.st_mode) + if mode == 0o600 and (info.st_uid, info.st_gid) == (0, 0): + return [] + owner = f'{_name(pwd.getpwuid, info.st_uid)}:{_name(grp.getgrgid, info.st_gid)}' + return [f'{path} is {mode:o} {owner}, not 600 root:root: it holds credentials; ' + f'chmod 600 {path} && chown root:root {path}'] + + if __name__ == '__main__': ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('startup', help='the finbot.env to check (the startup file)') - ap.add_argument('preferences', nargs='?', default=config.preferences_path, + ap.add_argument('settings', nargs='?', default=config.settings_path, help='default: /settings.conf') args = ap.parse_args() - problems = check(args.startup, args.preferences) + problems = check(args.startup, args.settings) for line in problems: print('ERROR config:', line, file=sys.stderr) + for line in permission_warnings(args.startup): + print('WARN config:', line, file=sys.stderr) if not problems: - print('config: ok', args.startup, - args.preferences if Path(args.preferences).is_file() else '(no settings.conf, defaults)') + has_settings = Path(args.settings).is_file() + print('config: ok', args.startup, args.settings if has_settings else '(no settings.conf, defaults)') for line in warnings(args.startup): print('config: warning:', line) - for line in notes(args.preferences, args.startup): + for line in notes(args.settings, args.startup): print('config:', line) sys.exit(1 if problems else 0) diff --git a/util/split_env.py b/util/split_env.py index 7a63f983..d043948a 100755 --- a/util/split_env.py +++ b/util/split_env.py @@ -44,8 +44,8 @@ def parse_line(line): def split(text, path='.env'): - """(startup lines, preference lines, notes) for the lines of an old env file.""" - startup, preferences, notes = [], [], [] + """(startup lines, setting lines, notes) for the lines of an old env file.""" + startup, settings, notes = [], [], [] for number, raw in enumerate(text.splitlines(), 1): parsed = parse_line(raw) if parsed is None: @@ -62,11 +62,11 @@ def split(text, path='.env'): notes.append(f'{path} line {number}: {key} dropped: {config.RETIRED[key]}') elif key in config.STARTUP_KEYS: startup.append(f'{key}={value}') - elif key in config.PREFERENCE_KEYS: - preferences.append(f'{key}={value}') + elif key in config.SETTING_KEYS: + settings.append(f'{key}={value}') else: notes.append(f'{path} line {number}: unknown key {key}, left out') - return startup, preferences, notes + return startup, settings, notes if __name__ == '__main__': @@ -77,12 +77,12 @@ def split(text, path='.env'): src = Path(args.file) if not src.is_file(): sys.exit(f'{src} does not exist: nothing to split') - startup, preferences, notes = split(src.read_text(), src) + startup, settings, notes = split(src.read_text(), src) outputs = { src.with_name('finbot.env.new'): ('# finbot.env: the startup file, loaded by your launcher; a change needs a restart.', startup, 0o600), src.with_name('settings.conf.new'): - ('# settings.conf: read by the bot; reload with systemctl reload finbot.', preferences, 0o644)} + ('# settings.conf: read by the bot; reload with systemctl reload finbot.', settings, 0o644)} if any(path.exists() for path in outputs): sys.exit('refusing to overwrite: ' + ', '.join(str(p) for p in outputs if p.exists())) for path, (header, lines, mode) in outputs.items(): diff --git a/util/sync_defaults.py b/util/sync_defaults.py index dde0b6e6..c6ea06ec 100755 --- a/util/sync_defaults.py +++ b/util/sync_defaults.py @@ -44,7 +44,7 @@ def sync(apply=False, force=False, dest=None, create=False): elif filecmp.cmp(src, target, shallow=False): results.append((name, 'skipped - destination identical')) elif name == 'settings.conf': - results.append((name, 'skipped - destination differs')) # configured preferences are never replaced + results.append((name, 'skipped - destination differs')) # configured settings are never replaced elif force: shutil.copy2(target, str(target) + '.bak') shutil.copy2(src, target) From 686777b292428e1511d17f97a69f27802f58a27e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:33:46 +0000 Subject: [PATCH 3/3] Docs: push mode, not HTTP mode (also the comments in compose.yaml and defaults/finbot.env) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- README.md | 4 ++-- compose.yaml | 4 ++-- defaults/finbot.env | 4 ++-- doc/commands.md | 2 +- doc/configuration.md | 18 +++++++++--------- doc/discord.md | 8 ++++---- doc/docker.md | 4 ++-- doc/install.md | 12 ++++++------ doc/scheduling.md | 2 +- doc/slack.md | 8 ++++---- doc/telegram.md | 10 +++++----- 11 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8631e4cf..f6554f6e 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ To run it as a service, under systemd on a Debian host, see [doc/install.md](doc ### The chat networks Each network needs a bot of its own. By default, finbot fetches its updates from each network itself (pull mode). -Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), HTTP mode -subscribes the bot to the updates each network pushes to it (each guide's _HTTP mode_). +Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), push mode +subscribes the bot to the updates each network pushes to it (each guide's _push mode_). * **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `telegramBotToken`, restart finbot ([guide](doc/telegram.md)). * **Slack:** create the app from the manifest that `util/slack_manifest.py` prints, add an app-level token, set diff --git a/compose.yaml b/compose.yaml index b1e79c87..b29ae0fb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,11 +13,11 @@ services: env_file: ../finbot.env environment: DATA_DIR: /data - ip: 0.0.0.0 # HTTP mode listens inside the container: publish its port below + ip: 0.0.0.0 # push mode listens inside the container: publish its port below volumes: - ../finbot-data:/data - ../finbot.env:/run/finbot.env:ro # checked before the bot starts - # ports: # HTTP mode only, on the host's loopback, behind your reverse proxy + # ports: # push mode only, on the host's loopback, behind your reverse proxy # - 127.0.0.1:5000:5000 logging: driver: ${FINBOT_LOG_DRIVER:-journald} # json-file where the host has no journald diff --git a/defaults/finbot.env b/defaults/finbot.env index 79e02631..859f32f7 100644 --- a/defaults/finbot.env +++ b/defaults/finbot.env @@ -15,7 +15,7 @@ # /var/lib/finbot). # DATA_DIR=/var/lib/finbot -# The listening socket, for a network in http mode (its webhook URL set below) +# The listening socket, for a network in push mode (its webhook URL set below) ip=127.0.0.1 port=5000 @@ -36,7 +36,7 @@ slackAppToken= # Discord's bot token: reports, and commands over the gateway API (pull mode, the default) discordBotToken= -# Chat networks: inbound over HTTPS (http mode). Telegram: set both to have it post its updates to your server +# Chat networks: inbound over HTTPS (push mode). Telegram: set both to have it post its updates to your server # instead of finbot fetching them telegramOutgoingWebhook= telegramOutgoingToken= diff --git a/doc/commands.md b/doc/commands.md index ae53fc22..de2a80dd 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -68,7 +68,7 @@ Trade notifications are peformed by polling the Sharesight trades API on a sched The other various reports can also run on a schedule (e.g. daily or weekly), or on demand with a command. They query the Yahoo Finance API for stock data based on current holdings across your Sharesight portfolios, your friends' Sharesight portfolios, plus a custom watch list. A scheduled report posts to the chat whose job it is, and a command's reply to the chat that sent it. -The bot answers commands in every chat it is in. By default, it fetches its updates from each network itself (pull mode); alternatively, in HTTP mode, each network pushes them to your HTTPS gateway, which passes them to the bot. It provides: +The bot answers commands in every chat it is in. By default, it fetches its updates from each network itself (pull mode); alternatively, in push mode, each network pushes them to your HTTPS gateway, which passes them to the bot. It provides: * Stock lookup (financials, company profile, price charts) * A watch list per chat, which anyone in it can change, picked up by the reports there * Listing of portfolios and portfolio performance diff --git a/doc/configuration.md b/doc/configuration.md index 7a7bcd8b..db80d255 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,8 +20,8 @@ of the chat network keys it needs. | Key | Default | Description | |---|---|---| | `DATA_DIR` | `var/` in the checkout (`$STATE_DIRECTORY` under systemd `StateDirectory=`) | Where state and cache live | -| `ip` | `127.0.0.1` | Address the bot listens on, in HTTP mode | -| `port` | `5000` | Port the bot listens on, in HTTP mode | +| `ip` | `127.0.0.1` | Address the bot listens on, in push mode | +| `port` | `5000` | Port the bot listens on, in push mode | | `scheduler_catchup` | `5` | Minutes: a run missed while the bot was down still fires if it was due this recently | | `sharesight_code` | | Sharesight API access | | `sharesight_client_id` | | Sharesight API access | @@ -29,14 +29,14 @@ of the chat network keys it needs. | `telegramBotToken` | | Telegram bot token: reports, and commands (finbot fetches its updates unless `telegramOutgoingWebhook` is set) | | `slackBotToken` | | Slack bot token, for replies and chart uploads in Slack | | `slackAppToken` | | Slack app-level token (`xapp-`): commands over a socket finbot opens to Slack (Socket Mode), when `slackOutgoingWebhook` is empty | -| `telegramOutgoingWebhook` | | The URL Telegram posts its updates to (HTTP mode); empty, finbot fetches them (pull mode) | -| `telegramOutgoingToken` | | Secret token Telegram must present (HTTP mode) | -| `slackOutgoingWebhook` | | The URL Slack posts its events and button presses to (HTTP mode); empty, Socket Mode with `slackAppToken` | -| `slackOutgoingToken` | | Token Slack must present (HTTP mode): the older way, which Slack has deprecated; not needed with `slackSigningSecret` | -| `slackSigningSecret` | | Secret Slack signs each request with (HTTP mode) | +| `telegramOutgoingWebhook` | | The URL Telegram posts its updates to (push mode); empty, finbot fetches them (pull mode) | +| `telegramOutgoingToken` | | Secret token Telegram must present (push mode) | +| `slackOutgoingWebhook` | | The URL Slack posts its events and button presses to (push mode); empty, Socket Mode with `slackAppToken` | +| `slackOutgoingToken` | | Token Slack must present (push mode): the older way, which Slack has deprecated; not needed with `slackSigningSecret` | +| `slackSigningSecret` | | Secret Slack signs each request with (push mode) | | `discordBotToken` | | Discord bot token: replies, and commands over the gateway API unless `discordOutgoingWebhook` is set | -| `discordOutgoingWebhook` | | The Interactions Endpoint URL Discord posts slash commands and button presses to (HTTP mode); empty, the gateway API (pull mode) | -| `discordPublicKey` | | The app's public key, which checks the signature of each interaction (HTTP mode) | +| `discordOutgoingWebhook` | | The Interactions Endpoint URL Discord posts slash commands and button presses to (push mode); empty, the gateway API (pull mode) | +| `discordPublicKey` | | The app's public key, which checks the signature of each interaction (push mode) | Retired keys do nothing, and only get a note at start (and from check_config) saying to delete the line: `telegramChatID`, `slackChannelID` and `discordChannelID` (there are no home chats: each chat has its own jobs, and an error that belongs to no chat goes to the log; an upgrade from before jobs per chat reads them once, as [Scheduling](scheduling.md) says), and `slack_webhook` and `discord_webhook`. diff --git a/doc/discord.md b/doc/discord.md index 9ac0d19a..e3d5c559 100644 --- a/doc/discord.md +++ b/doc/discord.md @@ -4,11 +4,11 @@ finbot talks to Discord as the bot user of a Discord application that you create posts each channel's scheduled reports there and answers commands: slash commands (`/price`) in both modes, and messages (`.price`, `@finbot price`) over the gateway API. -## Gateway API mode or HTTP mode? +## Gateway API mode or push mode? The bot does the same either way. What differs is how it hears from Discord: -| | Gateway API mode (the default) | HTTP mode | +| | Gateway API mode (the default) | Push mode | |---|---|---| | How commands and button presses reach finbot | Over Discord's gateway API, a connection finbot keeps open to Discord | Discord posts them to your HTTPS server | | Listens on the internet | No: finbot opens the connection to Discord | Yes, behind your HTTPS server: a domain name with a valid certificate | @@ -21,7 +21,7 @@ which redraw their message in place; linked tickers, without link previews and w and direct messages with the bot. A command in a message shows "finbot is typing…" while it works; a slash command shows Discord's "finbot is thinking…" until its answer takes its place. -`discordOutgoingWebhook` chooses the mode: set, HTTP mode; empty, gateway API mode. Discord sends the slash commands +`discordOutgoingWebhook` chooses the mode: set, push mode; empty, gateway API mode. Discord sends the slash commands and button presses to the app's _Interactions Endpoint URL_ while it is set, and over the gateway API while it is empty, so change both, and restart finbot, to switch. @@ -48,7 +48,7 @@ empty, so change both, and restart finbot, to switch. discordBotToken=your-bot-token ``` -## HTTP mode +## Push mode Discord posts the slash commands and button presses to finbot over HTTPS, each signed with the app's key. finbot listens on `http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a diff --git a/doc/docker.md b/doc/docker.md index 6e6a05c8..ec488abf 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -78,9 +78,9 @@ cd /opt/finbot && sudo git pull && sudo GIT_COMMIT=$(git rev-parse HEAD) docker ``` A change to `/opt/finbot.env` needs a restart: `sudo docker compose up -d`. -## HTTP mode +## Push mode -In pull mode, the default, finbot fetches its updates and needs no port. In HTTP mode (a network's webhook URL set), +In pull mode, the default, finbot fetches its updates and needs no port. In push mode (a network's webhook URL set), it listens inside the container on port 5000 of every interface (`ip: 0.0.0.0` in `compose.yaml`). Publish the port on the host's loopback only, by uncommenting `ports` in `compose.yaml`: ``` diff --git a/doc/install.md b/doc/install.md index 15721bc4..27187c92 100644 --- a/doc/install.md +++ b/doc/install.md @@ -11,12 +11,12 @@ systemd, as below. To run it in a container instead, see [docker.md](docker.md). * By default, finbot fetches its updates from each network itself (pull mode: Telegram's long polling, Slack's Socket Mode, Discord's gateway API). It only makes outgoing connections, so nothing has to listen on the internet. Alternatively, if you prefer to run an HTTPS gateway (a web server with a domain name and a matching certificate), - HTTP mode subscribes the bot to push updates: each network posts its updates to the gateway, which passes them to - the bot (each guide's _HTTP mode_). + Push mode subscribes the bot to push updates: each network posts its updates to the gateway, which passes them to + the bot (each guide's _push mode_). * Python 3.13.5 (the version in `.python-version`; 3.12 is the minimum), and these modules, pinned to versions with 3.13 support in `requirements.txt`: python-dateutil, requests, matplotlib and numpy. -* For a network in HTTP mode (its webhook URL set), also waitress; for Slack in Socket Mode, websocket-client; for - Discord's gateway API, discord.py, and for its HTTP mode, PyNaCl. These are in `requirements-optional.txt`, each +* For a network in push mode (its webhook URL set), also waitress; for Slack in Socket Mode, websocket-client; for + Discord's gateway API, discord.py, and for its push mode, PyNaCl. These are in `requirements-optional.txt`, each imported only by the mode that needs it. Docker and a Python venv install them all, Docker in its image and a venv from that file, so every mode works without choosing; on a Debian host, install the packages of the modes you use. @@ -24,10 +24,10 @@ systemd, as below. To run it in a container instead, see [docker.md](docker.md). 1. The packages, and those of the modes you use: ``` sudo apt install git python3 python3-requests python3-dateutil python3-matplotlib python3-numpy - sudo apt install python3-waitress # any network in HTTP mode + sudo apt install python3-waitress # any network in push mode sudo apt install python3-websocket # Slack's Socket Mode sudo apt install python3-discord # Discord's gateway API - sudo apt install python3-nacl # Discord's HTTP mode + sudo apt install python3-nacl # Discord's push mode ``` 2. The code: ``` diff --git a/doc/scheduling.md b/doc/scheduling.md index c0ae0032..a85fbff0 100644 --- a/doc/scheduling.md +++ b/doc/scheduling.md @@ -163,7 +163,7 @@ move as either side's daylight saving changes. ## How jobs run * A job is kept by its chat's network and id, in `finbot_chat_jobs.json` in the data directory, so switching a network - between pull and HTTP mode keeps it. Only a chat's switches, the default jobs it removed and the jobs it added are + between pull and push mode keeps it. Only a chat's switches, the default jobs it removed and the jobs it added are stored: the default jobs come from finbot's code, so a change to them reaches every chat, and a new one arrives in every chat. * Each chat keeps its own record of what a report has told it (trades, rating changes, record highs, podcast diff --git a/doc/slack.md b/doc/slack.md index 70823393..68c2b496 100644 --- a/doc/slack.md +++ b/doc/slack.md @@ -7,18 +7,18 @@ works (Slack has no typing notice for bots), and period buttons under a chart th ## The bot -### Socket Mode or HTTP mode? +### Socket Mode or push mode? The bot does the same either way. What differs is how it hears from Slack: -| | Socket Mode (the default) | HTTP mode | +| | Socket Mode (the default) | Push mode | |---|---|---| | How messages, slash commands and button presses reach finbot | Over a socket that finbot opens to Slack | Slack posts them to your HTTPS server | | Listens on the internet | No: finbot opens the socket to Slack | Yes, behind your HTTPS server: a domain name with a valid certificate | | Python module | `websocket-client` (Debian: `python3-websocket`) | `waitress` (Debian: `python3-waitress`) | | `finbot.env` keys | `slackBotToken`, `slackAppToken` | `slackBotToken`, `slackOutgoingWebhook`, `slackSigningSecret` | -`slackOutgoingWebhook` chooses the mode: set, HTTP mode; empty, Socket Mode. The app's own _Socket Mode_ setting must +`slackOutgoingWebhook` chooses the mode: set, push mode; empty, Socket Mode. The app's own _Socket Mode_ setting must agree, as Slack sends to one or the other: to finbot's socket while it is on, to the Request URLs while it is off. To switch, change both (paste the manifest for the other mode, which sets the app's side) and restart finbot. @@ -42,7 +42,7 @@ slackAppToken=xapp-your-app-level-token slackBotToken=xoxb-your-bot-token ``` -### HTTP mode +### Push mode Slack delivers the messages, the button presses and the slash commands to finbot over HTTPS. finbot listens on `http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a domain name diff --git a/doc/telegram.md b/doc/telegram.md index d9d3d8e8..77cb5db1 100644 --- a/doc/telegram.md +++ b/doc/telegram.md @@ -2,12 +2,12 @@ finbot talks to Telegram as a bot user that you create with BotFather. -## Pull mode or HTTP mode? +## Pull mode or push mode? The bot posts the scheduled reports and answers commands in your chats either way. What differs is how it hears from Telegram: -| | Pull mode (the default) | HTTP mode | +| | Pull mode (the default) | Push mode | |---|---|---| | How messages and button presses reach finbot | finbot asks Telegram for them (long polling) | Telegram posts them to your HTTPS server | | Listens on the internet | No: finbot only makes outgoing requests | Yes, behind your HTTPS server: a domain name with a valid certificate, on port 443, 80, 88 or 8443 | @@ -23,8 +23,8 @@ As finbot starts, it sets the bot's command menu, which Telegram shows when `/` left out of the menu, and work when typed, as every name of a command does. In a group, `/price@finbot_bot` is the same as `/price`. -`telegramOutgoingWebhook` chooses the mode: set, HTTP mode; empty, pull mode. A restart switches. As finbot starts in -pull mode it deletes the webhook, and in HTTP mode it sets it; the updates sent meanwhile are kept by Telegram and +`telegramOutgoingWebhook` chooses the mode: set, push mode; empty, pull mode. A restart switches. As finbot starts in +pull mode it deletes the webhook, and in push mode it sets it; the updates sent meanwhile are kept by Telegram and arrive after the switch. ## Pull mode @@ -46,7 +46,7 @@ arrive after the switch. telegramBotToken=0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAA ``` -## HTTP mode +## Push mode Telegram delivers the messages in your chats, and each button press, to finbot over HTTPS. finbot listens on `http://127.0.0.1:5000` (`ip` and `port` in `finbot.env`), so it needs a web server in front of it, on a domain name