diff --git a/.gitignore b/.gitignore index 4411e41..ddf4605 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ dist/ .coverage htmlcov/ -.tox/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index e2fc260..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -image: python:latest - -variables: - PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - POETRY_CACHE_DIR: "$CI_PROJECT_DIR/.cache/poetry" - -cache: - paths: - - .cache/pip - - .cache/poetry - -before_script: - - python -V - - pip install poetry - - poetry install - -pre-commit: - script: - - make pre-commit - -test: - script: - - poetry run pytest - -test-live: - script: - - tests/live.sh - -coverage: - script: - - poetry run coverage run --source=pricehist -m pytest - - poetry run coverage report - coverage: '/^TOTAL.+?(\d+\%)$/' diff --git a/Makefile b/Makefile index 49bbeb9..c76460c 100644 --- a/Makefile +++ b/Makefile @@ -2,47 +2,23 @@ help: ## List make targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -.PHONY: format +lint: ## Lint source code + poetry run flake8 + format: ## Format source code poetry run isort . poetry run black . -.PHONY: lint -lint: ## Lint source code - poetry run flake8 src tests - .PHONY: test -test: ## Run tests - poetry run pytest --color=yes +test: ## Run non-live tests + poetry run pytest -m "not live" --color=yes .PHONY: test-live test-live: ## Run live tests - tests/live.sh + poetry run pytest -m live --color=yes .PHONY: coverage coverage: ## Generate and open coverage report poetry run coverage run --source=pricehist -m pytest poetry run coverage html xdg-open htmlcov/index.html - -.PHONY: install-pre-commit-hook -install-pre-commit-hook: ## Install the git pre-commit hook - echo -e "#!/bin/bash\nmake pre-commit" > .git/hooks/pre-commit - chmod +x .git/hooks/pre-commit - -.PHONY: pre-commit -pre-commit: ## Checks to run before each commit - poetry run isort src tests --check - poetry run black src tests --check - poetry run flake8 src tests - -.PHONY: tox -tox: ## Run tests via tox - poetry run tox - -.PHONY: fetch-iso-data -fetch-iso-data: ## Fetch the latest copy of the ISO 4217 currency data - wget -O src/pricehist/resources/list-one.xml \ - https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml - wget -O src/pricehist/resources/list-three.xml \ - https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-three.xml diff --git a/README.md b/README.md index 1b6b04c..49857ee 100644 --- a/README.md +++ b/README.md @@ -3,28 +3,18 @@ A command-line tool for fetching and formatting historical price data, with support for multiple data sources and output formats. -[![Pipeline status](https://gitlab.com/chrisberkhout/pricehist/badges/master/pipeline.svg)](https://gitlab.com/chrisberkhout/pricehist/-/commits/master) -[![Coverage report](https://gitlab.com/chrisberkhout/pricehist/badges/master/coverage.svg)](https://gitlab.com/chrisberkhout/pricehist/-/commits/master) -[![PyPI version](https://badge.fury.io/py/pricehist.svg)](https://badge.fury.io/py/pricehist) -[![Downloads](https://pepy.tech/badge/pricehist)](https://pepy.tech/project/pricehist) -[![License](https://img.shields.io/pypi/l/pricehist)](https://gitlab.com/chrisberkhout/pricehist/-/blob/master/LICENSE) -[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgitlab.com%2Fchrisberkhout%2Fpricehist&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) - ## Installation -Install via pip or -[pipx](https://pypa.github.io/pipx/). +Install via [pip](https://pip.pypa.io/en/stable/) or +[pipx](https://pypa.github.io/pipx/): -``` +```bash pipx install pricehist ``` ## Sources - **`alphavantage`**: [Alpha Vantage](https://www.alphavantage.co/) -- **`bankofcanada`**: [Bank of Canada daily exchange rates](https://www.bankofcanada.ca/valet/docs) -- **`coinbasepro`**: [Coinbase Pro](https://pro.coinbase.com/) - **`coindesk`**: [CoinDesk Bitcoin Price Index](https://www.coindesk.com/coindesk-api) - **`coinmarketcap`**: [CoinMarketCap](https://coinmarketcap.com/) - **`ecb`**: [European Central Bank Euro foreign exchange reference rates](https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html) @@ -34,69 +24,43 @@ pipx install pricehist - **`beancount`**: [Beancount](http://furius.ca/beancount/) - **`csv`**: [Comma-separated values](https://en.wikipedia.org/wiki/Comma-separated_values) -- **`json`**: [JSON](https://en.wikipedia.org/wiki/JSON) -- **`jsonl`**: [JSON lines](https://en.wikipedia.org/wiki/JSON_streaming) - **`gnucash-sql`**: [GnuCash](https://www.gnucash.org/) SQL - **`ledger`**: [Ledger](https://www.ledger-cli.org/) and [hledger](https://hledger.org/) -## Reactions +## Examples -> This is my new favourite price fetcher, by far. -> -- _Simon Michael, creator of [hledger](https://hledger.org/) ([ref](https://groups.google.com/g/hledger/c/SCLbNiKl9D8/m/0ReYmDppAAAJ))_ +Show usage information: -> This is great! -> -- _Martin Blais, creator of [Beancount](https://beancount.github.io/) ([ref](https://groups.google.com/g/beancount/c/cCJc9OhIlNg/m/QGRvNowcAwAJ))_ - -## How to - -### Fetch prices - -Fetch prices by choosing a source, a pair and, optionally, a time interval. - -``` -pricehist fetch ecb EUR/AUD -s 2021-01-04 -e 2021-01-08 +```bash +pricehist -h ``` ``` -date,base,quote,amount,source,type -2021-01-04,EUR,AUD,1.5928,ecb,reference -2021-01-05,EUR,AUD,1.5927,ecb,reference -2021-01-06,EUR,AUD,1.5824,ecb,reference -2021-01-07,EUR,AUD,1.5836,ecb,reference -2021-01-08,EUR,AUD,1.5758,ecb,reference +usage: pricehist [-h] [--version] [--verbose] {sources,source,fetch} ... + +Fetch historical price data + +optional arguments: + -h, --help show this help message and exit + --version show version information + --verbose show all log messages + +commands: + {sources,source,fetch} + sources list sources + source show source details + fetch fetch prices ``` -The default output format is CSV, which is suitable for use in spreadsheets and -with other tools. For example, you can generate a price chart from the command -line as follows (or using [an alias](https://gitlab.com/-/snippets/2163031)). - -``` -pricehist fetch coindesk BTC/USD -s 2021-01-01 | \ - sed 1d | \ - cut -d, -f1,4 | \ - gnuplot -p -e ' - set datafile separator ","; - set xdata time; - set timefmt "%Y-%m-%d"; - set format x "%b\n%Y"; - plot "/dev/stdin" using 1:2 with lines title "BTC/USD" - ' -``` - -![BTC/USD prices](https://gitlab.com/chrisberkhout/pricehist/-/raw/master/example-gnuplot.png) - -### Show usage information - -Add `-h` to any command to see usage information. +Show usage information for the `fetch` command: ``` pricehist fetch -h ``` ``` -usage: pricehist fetch SOURCE PAIR [-h] [-vvv] [-t TYPE] [-s DATE | -sx DATE] [-e DATE | -ex DATE] -[-o beancount|csv|json|jsonl|gnucash-sql|ledger] [--invert] [--quantize INT] +usage: pricehist fetch SOURCE PAIR [-h] [-t TYPE] [-s DATE | -sx DATE] [-e DATE | -ex DATE] +[-o beancount|csv|gnucash-sql|ledger] [--invert] [--quantize INT] [--fmt-base SYM] [--fmt-quote SYM] [--fmt-time TIME] [--fmt-decimal CHAR] [--fmt-thousands CHAR] -[--fmt-symbol rightspace|right|leftspace|left] [--fmt-datesep CHAR] -[--fmt-csvdelim CHAR] [--fmt-jsonnums] +[--fmt-symbol rightspace|right|leftspace|left] [--fmt-datesep CHAR] [--fmt-csvdelim CHAR] positional arguments: SOURCE the source identifier @@ -104,7 +68,6 @@ positional arguments: optional arguments: -h, --help show this help message and exit - -vvv, --verbose show all log messages -t TYPE, --type TYPE price type, e.g. close -s DATE, --start DATE start date, inclusive (default: source start) -sx DATE, --startx DATE start date, exclusive @@ -121,16 +84,30 @@ optional arguments: --fmt-symbol LOCATION commodity symbol placement in output (default: rightspace) --fmt-datesep CHAR date separator in output (default: '-') --fmt-csvdelim CHAR field delimiter for CSV output (default: ',') - --fmt-jsonnums numbers not strings for JSON output (default: False) ``` -### Choose and customize the output format - -As the output format you can choose one of `beancount`, `csv`, `json`, `jsonl`, -`ledger` or `gnucash-sql`. +Fetch prices after 2021-01-04, ending 2021-01-15, as CSV: +```bash +pricehist fetch ecb EUR/AUD -sx 2021-01-04 -e 2021-01-15 -o csv ``` -pricehist fetch ecb EUR/AUD -s 2021-01-04 -e 2021-01-08 -o ledger +``` +date,base,quote,amount,source,type +2021-01-05,EUR,AUD,1.5927,ecb,reference +2021-01-06,EUR,AUD,1.5824,ecb,reference +2021-01-07,EUR,AUD,1.5836,ecb,reference +2021-01-08,EUR,AUD,1.5758,ecb,reference +2021-01-11,EUR,AUD,1.5783,ecb,reference +2021-01-12,EUR,AUD,1.5742,ecb,reference +2021-01-13,EUR,AUD,1.5734,ecb,reference +2021-01-14,EUR,AUD,1.5642,ecb,reference +2021-01-15,EUR,AUD,1.568,ecb,reference +``` + +In Ledger format: + +```bash +pricehist fetch ecb EUR/AUD -s 2021-01-01 -o ledger | head ``` ``` P 2021-01-04 00:00:00 EUR 1.5928 AUD @@ -138,313 +115,27 @@ P 2021-01-05 00:00:00 EUR 1.5927 AUD P 2021-01-06 00:00:00 EUR 1.5824 AUD P 2021-01-07 00:00:00 EUR 1.5836 AUD P 2021-01-08 00:00:00 EUR 1.5758 AUD +P 2021-01-11 00:00:00 EUR 1.5783 AUD +P 2021-01-12 00:00:00 EUR 1.5742 AUD +P 2021-01-13 00:00:00 EUR 1.5734 AUD +P 2021-01-14 00:00:00 EUR 1.5642 AUD +P 2021-01-15 00:00:00 EUR 1.568 AUD ``` -Formatting options let you control certain details of the output. +Generate SQL for a GnuCash database and apply it immediately: -``` -pricehist fetch ecb EUR/AUD -s 2021-01-04 -e 2021-01-08 -o ledger \ - --fmt-time '' --fmt-datesep / --fmt-base € --fmt-quote $ --fmt-symbol left -``` -``` -P 2021/01/04 € $1.5928 -P 2021/01/05 € $1.5927 -P 2021/01/06 € $1.5824 -P 2021/01/07 € $1.5836 -P 2021/01/08 € $1.5758 -``` - -### Fetch new prices only - -You can update an existing file without refetching the prices you already have. -First find the date of the last price, then fetch from there, drop the header -line if present and append the rest to the existing file. - -``` -last=$(tail -1 prices-eur-usd.csv | cut -d, -f1) -pricehist fetch ecb EUR/USD -sx $last -o csv | sed 1d >> prices-eur-usd.csv -``` - -### Load prices into GnuCash - -You can generate SQL for a GnuCash database and apply it immediately with one -of the following commands. - -``` +```bash pricehist fetch ecb EUR/AUD -s 2021-01-01 -o gnucash-sql | sqlite3 Accounts.gnucash pricehist fetch ecb EUR/AUD -s 2021-01-01 -o gnucash-sql | mysql -u username -p -D databasename pricehist fetch ecb EUR/AUD -s 2021-01-01 -o gnucash-sql | psql -U username -d databasename -v ON_ERROR_STOP=1 ``` -Beware that the GnuCash project itself does not support integration at the -database level, so there is a risk that the SQL generated by `pricehist` will -be ineffective or even damaging for some version of GnuCash. In practice, this -strategy has been used successfully by other projects. Reading the SQL and -keeping regular database backups is recommended. +## Design choices -The GnuCash database must already contain commodities with mnemonics matching -the base and quote of new prices, otherwise the SQL will fail without making -changes. - -Each price entry is given a GUID based on its content (date, base, quote, -source, type and amount) and existing GUIDs are skipped in the final insert, so -you can apply identical or overlapping SQL files multiple times without -creating duplicate entries in the database. - -### Show source information - -The `source` command shows information about a source. - -``` -pricehist source alphavantage -``` -``` -ID : alphavantage -Name : Alpha Vantage -Description : Provider of market data for stocks, forex and cryptocurrencies -URL : https://www.alphavantage.co/ -Start : 1995-01-01 -Types : close, open, high, low, adjclose, mid -Notes : Alpha Vantage has data on... -``` - -Available symbols can be listed for most sources, either as full pairs or as -separate base and quote symbols that will work in certain combinations. - -``` -pricehist source ecb --symbols -``` -``` -EUR/AUD Euro against Australian Dollar -EUR/BGN Euro against Bulgarian Lev -EUR/BRL Euro against Brazilian Real -EUR/CAD Euro against Canadian Dollar -EUR/CHF Euro against Swiss Franc -... -``` - -It may also be possible to search for symbols. - -``` -pricehist source alphavantage --search Tesla -``` -``` -TL0.DEX Tesla, Equity, XETRA, EUR -TL0.FRK Tesla, Equity, Frankfurt, EUR -TSLA34.SAO Tesla, Equity, Brazil/Sao Paolo, BRL -TSLA Tesla Inc, Equity, United States, USD -TXLZF Tesla Exploration Ltd, Equity, United States, USD -``` - -### Inspect source interactions - -You can see extra information by adding the verbose option (`--verbose` or -`-vvv`), including `curl` commands that reproduce each request to a source. - -``` -pricehist fetch coindesk BTC/USD -s 2021-01-01 -e 2021-01-05 -vvv -``` -``` -DEBUG Began pricehist run at 2021-08-12 14:38:26.630357. -DEBUG Starting new HTTPS connection (1): api.coindesk.com:443 -DEBUG https://api.coindesk.com:443 "GET /v1/bpi/historical/close.json?currency=USD&start=2021-01-01&end=2021-01-05 HTTP/1.1" 200 319 -DEBUG curl -X GET -H 'Accept: */*' -H 'Accept-Encoding: gzip, deflate' -H 'Connection: keep-alive' -H 'User-Agent: python-requests/2.25.1' --compressed 'https://api.coindesk.com/v1/bpi/historical/close.json?currency=USD&start=2021-01-01&end=2021-01-05' -DEBUG Available data covers the interval [2021-01-01--2021-01-05], as requested. -date,base,quote,amount,source,type -2021-01-01,BTC,USD,29391.775,coindesk,close -2021-01-02,BTC,USD,32198.48,coindesk,close -2021-01-03,BTC,USD,33033.62,coindesk,close -2021-01-04,BTC,USD,32017.565,coindesk,close -2021-01-05,BTC,USD,34035.0067,coindesk,close -DEBUG Ended pricehist run at 2021-08-12 14:38:26.709428. -``` - -Running a logged `curl` command shows exactly what data is returned by the -source. - -``` -pricehist fetch coindesk BTC/USD -s 2021-01-01 -e 2021-01-05 -vvv 2>&1 \ - | grep '^DEBUG curl' | sed 's/^DEBUG //' | bash | jq . -``` -```json -{ - "bpi": { - "2021-01-01": 29391.775, - "2021-01-02": 32198.48, - "2021-01-03": 33033.62, - "2021-01-04": 32017.565, - "2021-01-05": 34035.0067 - }, - "disclaimer": "This data was produced from the CoinDesk Bitcoin Price Index. BPI value data returned as USD.", - "time": { - "updated": "Jan 6, 2021 00:03:00 UTC", - "updatedISO": "2021-01-06T00:03:00+00:00" - } -} -``` - -### Use via `bean-price` - -Beancount users may wish to use `pricehist` sources via `bean-price`. To do so, -ensure the `pricehist` package is installed in an accessible location. - -You can fetch the latest price directly from the command line. - -``` -bean-price -e "USD:pricehist.beanprice.coindesk/BTC:USD" -``` -``` -2021-08-18 price BTC:USD 44725.12 USD -``` - -You can fetch a series of prices by providing a Beancount file as input. - -``` -; input.beancount -2021-08-14 commodity BTC - price: "USD:pricehist.beanprice.coindesk/BTC:USD:close" -``` - -``` -bean-price input.beancount --update --update-rate daily --inactive --clear-cache -``` -``` -2021-08-14 price BTC 47098.2633 USD -2021-08-15 price BTC 47018.9017 USD -2021-08-16 price BTC 45927.405 USD -2021-08-17 price BTC 44686.3333 USD -2021-08-18 price BTC 44725.12 USD -``` - -Adding `-v` will print progress information, `-vv` will print debug information, -including that from `pricehist`. - -A source map specification for `bean-price` has the form -`:/[^]`. Additional `/[^]` parts can -be appended, separated by commas. - -The module name will be of the form `pricehist.beanprice.`. - -The ticker symbol will be of the form `BASE:QUOTE:TYPE`. - -Any non-alphanumeric characters except the equals sign (`=`), hyphen (`-`), -period (`.`), or parentheses (`(` or `)`) are special characters that need to -be encoded as their a two-digit hexadecimal code prefixed with an underscore, -because `bean-price` ticker symbols don't allow all the characters used by -`pricehist` pairs. -[This page](https://replit.com/@chrisberkhout/bpticker) will do it for you. - -For example, the Yahoo! Finance symbol for the Dow Jones Industrial Average is -`^DJI`, and would have the source map specification -`USD:pricehist.beanprice.yahoo/_5eDJI`, or for the daily high price -`USD:pricehist.beanprice.yahoo/_5eDJI::high`. - -### Use as a library - -You may find `pricehist`'s source classes useful in your own scripts. - -``` -$ python -Python 3.9.6 (default, Jun 30 2021, 10:22:16) -[GCC 11.1.0] on linux -Type "help", "copyright", "credits" or "license" for more information. ->>> from pricehist.series import Series ->>> from pricehist.sources.ecb import ECB ->>> series = ECB().fetch(Series("EUR", "AUD", "reference", "2021-01-04", "2021-01-08")) ->>> series.prices -[Price(date='2021-01-04', amount=Decimal('1.5928')), Price(date='2021-01-05', amount=Decimal('1.5927')), Price(date='2021-01-06', amount=Decimal('1.5824')), Price(date='2021-01-07', amount=Decimal('1.5836')), Price(date='2021-01-08', amount=Decimal('1.5758'))] -``` - -A subclass of `pricehist.exceptions.SourceError` will be raised for any error. - -### Contribute - -Contributions are welcome! If you discover a bug or want to work on a -non-trivial change, please open a -[GitLab issue](https://gitlab.com/chrisberkhout/pricehist/-/issues) -to discuss it. - -Run `make install-pre-commit-hook` set up local pre-commit checks. -Set up your editor to run -[isort](https://pycqa.github.io/isort/), -[Black](https://black.readthedocs.io/en/stable/) and -[Flake8](https://flake8.pycqa.org/en/latest/), -or run them manually via `make format lint`. - -## Terminology - -A **source** is an upstream service that can provide a series of prices. - -Each **series** of prices is for one pair and price type. - -The [**pair**](https://en.wikipedia.org/wiki/Currency_pair) is made up of a -base and a quote, each given as a symbol. Sometimes you will give the base -only, and the quote will be determined with information from the source. The -available pairs, the symbols used in them and the available price types all -depend on the particular source used. - -The **base** is the currency or commodity being valued. Each price expresses -the value of one unit of the base. - -The **quote** is the unit used to express the value of the base. - -A **symbol** is a code or abbreviation for a currency or commodity. - -The **prices** in a series each have a date and an amount. - -The **amount** is the number of units of the quote that are equal to one unit -of the base. - -Consider the following command. - -``` -pricehist fetch coindesk BTC/USD --type close -``` - -- **`coindesk`** is the ID of the CoinDesk Bitcoin Price Index source. -- **`BTC`** is the symbol for Bitcoin, used here as the base. -- **`USD`** is the symbol for the United States Dollar, used here as the quote. -- **`BTC/USD`** is the pair Bitcoin against United States Dollar. -- **`close`** is the price type for the last price of each day. - -A BTC/USD price of the amount 29,391.775 can be written as -"BTC/USD = 29391.775" or "BTC 29391.775 USD", and means that one Bitcoin is -worth 29,391.775 United States Dollars. - -## Initial design choices - -To keep things simple, `pricehist` provides only univariate time series of -daily historical prices. It doesn't provide other types of market, financial or -economic data, real-time prices, or other temporal resolutions. Multiple or -multivariate series require multiple invocations. - -## Potential features - -In the future, `pricehist` may be extended to cover some of the following -features: - -- **Time of day**: Sources sometimes provide specific times for each day's - high/low prices and these could be preserved for output. This would require - changes to how dates are handled internally, clarification of time zone - handling and extension of the time formatting option. -- **Alternate resolutions**: Some sources can provide higher or lower - resolution data, such as hourly or weekly. These could be supported where - available. For other cases an option could be provided for downsampling data - before output. -- **Real-time prices**: These generally come from different source endpoints - than the historical data. Real-time prices will usually have a different - price type, such as `last`, `bid` or `ask`. Support for real-time prices - would allow adding sources that don't provide historical data. Start and end - times are irrelevant when requesting real-time prices. A "follow" option - could continuously poll for new prices. -- **Related non-price data**: Trading volume, spreads, split and dividend - events and other related data could be supported. The base/quote/type model - used for prices would work for some of this. Other things may require - extending the model. -- **Multivariate series**: Would allow, for example, fetching - high/low/open/close prices in a single invocation. -- **`format` command**: A command for rewriting existing CSV data into one of - the other output formats. +To keep things simple, at least for now, `pricehist` provides only univariate +time series of daily historical prices. It doesn't provide other types of +market, financial or economic data, real-time prices, or other temporal +resolutions. Multiple or multivariate series require multiple invocations. ## Alternatives @@ -459,3 +150,14 @@ method for fetching historical prices. The GnuCash wiki documents [wrapper scripts](https://wiki.gnucash.org/wiki/Stocks/get_prices) for the [Finance::QuoteHist](https://metacpan.org/pod/Finance::QuoteHist) Perl module. + +Other projects with related goals include: + +* [`hledger-stockquotes`](https://github.com/prikhi/hledger-stockquotes): + A CLI addon for hledger that reads a journal file and pulls the historical prices for commodities. +* [`ledger_get_prices`](https://github.com/nathankot/ledger-get-prices): + Uses Yahoo Finance to generate a price database based on your current Ledger commodities and time period. +* [LedgerStockUpdate](https://github.com/adchari/LedgerStockUpdate): + Locates any stocks you have in your ledger-cli file, then generates a price database of those stocks. +* [`market-prices`](https://github.com/barrucadu/hledger-scripts#market-prices): + Downloads market values of commodities from a few different sources. diff --git a/example-gnuplot.png b/example-gnuplot.png deleted file mode 100644 index f73ca28..0000000 Binary files a/example-gnuplot.png and /dev/null differ diff --git a/poetry.lock b/poetry.lock index 4016d9c..63df0c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,182 +1,397 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "20.3.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] name = "black" -version = "22.12.0" +version = "20.8b1" description = "The uncompromising code formatter." +category = "dev" optional = false -python-versions = ">=3.7" -files = [ - {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, - {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, - {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, - {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, - {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, - {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, - {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, - {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, - {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, - {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, - {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, - {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, -] +python-versions = ">=3.6" [package.dependencies] -click = ">=8.0.0" +appdirs = "*" +click = ">=7.1.2" mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} +pathspec = ">=0.6,<1" +regex = ">=2020.1.8" +toml = ">=0.10.1" +typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "certifi" -version = "2024.7.4" +version = "2020.12.5" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, -] +python-versions = "*" [[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "main" optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "click" -version = "8.1.7" +version = "7.1.2" description = "Composable command line interface toolkit" +category = "dev" optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "colorama" -version = "0.4.6" +version = "0.4.4" description = "Cross-platform colored terminal text." +category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" version = "5.5" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -files = [ + +[package.extras] +toml = ["toml"] + +[[package]] +name = "cssselect" +version = "1.1.0" +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "curlify" +version = "2.2.1" +description = "Library to convert python requests object to curl command." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = "*" + +[[package]] +name = "flake8" +version = "3.9.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.8.0" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] + +[[package]] +name = "lxml" +version = "4.6.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["beautifulsoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "20.9" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "pathspec" +version = "0.8.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.2.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "regex" +version = "2021.4.4" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.25.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<5" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + +[[package]] +name = "responses" +version = "0.13.3" +description = "A utility library for mocking out the `requests` Python library." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +requests = ">=2.0" +six = "*" +urllib3 = ">=1.25.10" + +[package.extras] +tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "typed-ast" +version = "1.4.3" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "da28bdba188bda9b955066da37862253e0aebadb52fa2d8aeecdea5ad3165efd" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, +] +black = [ + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, +] +certifi = [ + {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, + {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, +] +chardet = [ + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, @@ -230,568 +445,205 @@ files = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] - -[package.extras] -toml = ["toml"] - -[[package]] -name = "cssselect" -version = "1.2.0" -description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e"}, - {file = "cssselect-1.2.0.tar.gz", hash = "sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc"}, +cssselect = [ + {file = "cssselect-1.1.0-py2.py3-none-any.whl", hash = "sha256:f612ee47b749c877ebae5bb77035d8f4202c6ad0f0fc1271b3c18ad6c4468ecf"}, + {file = "cssselect-1.1.0.tar.gz", hash = "sha256:f95f8dedd925fd8f54edb3d2dfb44c190d9d18512377d3c1e2388d16126879bc"}, ] - -[[package]] -name = "curlify" -version = "2.2.1" -description = "Library to convert python requests object to curl command." -optional = false -python-versions = "*" -files = [ +curlify = [ {file = "curlify-2.2.1.tar.gz", hash = "sha256:0d3f02e7235faf952de8ef45ef469845196d30632d5838bcd5aee217726ddd6d"}, ] - -[package.dependencies] -requests = "*" - -[[package]] -name = "distlib" -version = "0.3.8" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +flake8 = [ + {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, + {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, ] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +idna = [ + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.15.4" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, - {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] -typing = ["typing-extensions (>=4.8)"] - -[[package]] -name = "flake8" -version = "7.1.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"}, - {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"}, +isort = [ + {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, + {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, ] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" - -[[package]] -name = "idna" -version = "3.7" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +lxml = [ + {file = "lxml-4.6.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f"}, + {file = "lxml-4.6.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d"}, + {file = "lxml-4.6.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2"}, + {file = "lxml-4.6.2-cp27-cp27m-win32.whl", hash = "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e"}, + {file = "lxml-4.6.2-cp27-cp27m-win_amd64.whl", hash = "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e"}, + {file = "lxml-4.6.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2"}, + {file = "lxml-4.6.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe"}, + {file = "lxml-4.6.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388"}, + {file = "lxml-4.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80"}, + {file = "lxml-4.6.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b"}, + {file = "lxml-4.6.2-cp35-cp35m-win32.whl", hash = "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8"}, + {file = "lxml-4.6.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780"}, + {file = "lxml-4.6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af"}, + {file = "lxml-4.6.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37"}, + {file = "lxml-4.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98"}, + {file = "lxml-4.6.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d"}, + {file = "lxml-4.6.2-cp36-cp36m-win32.whl", hash = "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf"}, + {file = "lxml-4.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939"}, + {file = "lxml-4.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e"}, + {file = "lxml-4.6.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711"}, + {file = "lxml-4.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089"}, + {file = "lxml-4.6.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01"}, + {file = "lxml-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2"}, + {file = "lxml-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc"}, + {file = "lxml-4.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d"}, + {file = "lxml-4.6.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3"}, + {file = "lxml-4.6.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644"}, + {file = "lxml-4.6.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308"}, + {file = "lxml-4.6.2-cp38-cp38-win32.whl", hash = "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505"}, + {file = "lxml-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a"}, + {file = "lxml-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931"}, + {file = "lxml-4.6.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03"}, + {file = "lxml-4.6.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5"}, + {file = "lxml-4.6.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a"}, + {file = "lxml-4.6.2-cp39-cp39-win32.whl", hash = "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75"}, + {file = "lxml-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf"}, + {file = "lxml-4.6.2.tar.gz", hash = "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc"}, ] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - -[[package]] -name = "lxml" -version = "5.2.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.6" -files = [ - {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632"}, - {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526"}, - {file = "lxml-5.2.2-cp310-cp310-win32.whl", hash = "sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30"}, - {file = "lxml-5.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7"}, - {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545"}, - {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b"}, - {file = "lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438"}, - {file = "lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be"}, - {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391"}, - {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836"}, - {file = "lxml-5.2.2-cp312-cp312-win32.whl", hash = "sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a"}, - {file = "lxml-5.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48"}, - {file = "lxml-5.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56"}, - {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9"}, - {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264"}, - {file = "lxml-5.2.2-cp36-cp36m-win32.whl", hash = "sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3"}, - {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, - {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, - {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, - {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, - {file = "lxml-5.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1"}, - {file = "lxml-5.2.2-cp38-cp38-win32.whl", hash = "sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30"}, - {file = "lxml-5.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6"}, - {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30"}, - {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9"}, - {file = "lxml-5.2.2-cp39-cp39-win32.whl", hash = "sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf"}, - {file = "lxml-5.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324"}, - {file = "lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87"}, +packaging = [ + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml-html-clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.10)"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +pathspec = [ + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +pycodestyle = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] - -[[package]] -name = "platformdirs" -version = "4.2.2" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, - {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +pyflakes = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] - -[package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] -type = ["mypy (>=1.8)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +pytest = [ + {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"}, + {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"}, ] - -[[package]] -name = "pycodestyle" -version = "2.12.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, - {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, +regex = [ + {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, + {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, + {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, + {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, + {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, + {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, + {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, + {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, + {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, + {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, + {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, + {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, + {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] - -[[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +requests = [ + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, ] - -[[package]] -name = "pytest" -version = "8.3.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +responses = [ + {file = "responses-0.13.3-py2.py3-none-any.whl", hash = "sha256:b54067596f331786f5ed094ff21e8d79e6a1c68ef625180a7d34808d6f36c11b"}, + {file = "responses-0.13.3.tar.gz", hash = "sha256:18a5b88eb24143adbf2b4100f328a2f5bfa72fbdacf12d97d41f07c26c45553d"}, ] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-mock" -version = "3.14.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "responses" -version = "0.13.4" -description = "A utility library for mocking out the `requests` Python library." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "responses-0.13.4-py2.py3-none-any.whl", hash = "sha256:d8d0f655710c46fd3513b9202a7f0dcedd02ca0f8cf4976f27fa8ab5b81e656d"}, - {file = "responses-0.13.4.tar.gz", hash = "sha256:9476775d856d3c24ae660bbebe29fb6d789d4ad16acd723efbfb6ee20990b899"}, -] - -[package.dependencies] -requests = ">=2.0" -six = "*" -urllib3 = ">=1.25.10" - -[package.extras] -tests = ["coverage (>=3.7.1,<6.0.0)", "flake8", "mypy", "pytest (>=4.6)", "pytest (>=4.6,<5.0)", "pytest-cov", "pytest-localserver", "types-mock", "types-requests", "types-six"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ +six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] - -[[package]] -name = "tox" -version = "3.28.0" -description = "tox is a generic virtualenv management and test command line tool" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -files = [ - {file = "tox-3.28.0-py2.py3-none-any.whl", hash = "sha256:57b5ab7e8bb3074edc3c0c0b4b192a4f3799d3723b2c5b76f1fa9f2d40316eea"}, - {file = "tox-3.28.0.tar.gz", hash = "sha256:d0d28f3fe6d6d7195c27f8b054c3e99d5451952b54abdae673b71609a581f640"}, +typed-ast = [ + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, + {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, + {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, + {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, + {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, + {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, + {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, + {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, + {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, + {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] - -[package.dependencies] -colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} -filelock = ">=3.0.0" -packaging = ">=14" -pluggy = ">=0.12.0" -py = ">=1.4.17" -six = ">=1.14.0" -tomli = {version = ">=2.0.1", markers = "python_version >= \"3.7\" and python_version < \"3.11\""} -virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" - -[package.extras] -docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, ] - -[[package]] -name = "urllib3" -version = "2.2.2" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +urllib3 = [ + {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, + {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, ] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "virtualenv" -version = "20.26.3" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, - {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8.1" -content-hash = "0d56bfdf88b0280475309ade51b6bb230ab96cc6111a7dbe8291c7aba12b5c20" diff --git a/pyproject.toml b/pyproject.toml index 5d555aa..bf385df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pricehist" -version = "1.4.12" +version = "0.1.3" description = "Fetch and format historical price data" authors = ["Chris Berkhout "] license = "MIT" @@ -10,25 +10,22 @@ homepage = "https://gitlab.com/chrisberkhout/pricehist" repository = "https://gitlab.com/chrisberkhout/pricehist" include = [ "LICENSE", - "example-gnuplot.png", ] [tool.poetry.dependencies] -python = "^3.8.1" +python = "^3.9" requests = "^2.25.1" -lxml = "^5.1.0" +lxml = "^4.6.2" cssselect = "^1.1.0" curlify = "^2.2.1" [tool.poetry.dev-dependencies] -pytest = "^8.3.2" -black = "^22.10.0" -flake8 = "^7.1.0" +pytest = "^6.2.2" +black = "^20.8b1" +flake8 = "^3.9.1" isort = "^5.8.0" responses = "^0.13.3" coverage = "^5.5" -pytest-mock = "^3.6.1" -tox = "^3.24.3" [build-system] requires = ["poetry-core>=1.0.0"] @@ -42,4 +39,6 @@ profile = "black" multi_line_output = 3 [tool.pytest.ini_options] -markers = [] +markers = [ + "live: makes a live request to a source" +] diff --git a/src/pricehist/__init__.py b/src/pricehist/__init__.py index 2736991..ae73625 100644 --- a/src/pricehist/__init__.py +++ b/src/pricehist/__init__.py @@ -1 +1 @@ -__version__ = "1.4.12" +__version__ = "0.1.3" diff --git a/src/pricehist/beanprice/__init__.py b/src/pricehist/beanprice/__init__.py deleted file mode 100644 index 151cfb2..0000000 --- a/src/pricehist/beanprice/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -import re -from datetime import date, datetime, timedelta, timezone -from decimal import Decimal -from typing import List, NamedTuple, Optional - -from pricehist import exceptions -from pricehist.series import Series - -SourcePrice = NamedTuple( - "SourcePrice", - [ - ("price", Decimal), - ("time", Optional[datetime]), - ("quote_currency", Optional[str]), - ], -) - - -def source(pricehist_source): - class Source: - def get_latest_price(self, ticker: str) -> Optional[SourcePrice]: - time_end = datetime.combine(date.today(), datetime.min.time()) - time_begin = time_end - timedelta(days=7) - prices = self.get_prices_series(ticker, time_begin, time_end) - if prices: - return prices[-1] - else: - return None - - def get_historical_price( - self, ticker: str, time: datetime - ) -> Optional[SourcePrice]: - prices = self.get_prices_series(ticker, time, time) - if prices: - return prices[-1] - else: - return None - - def get_prices_series( - self, - ticker: str, - time_begin: datetime, - time_end: datetime, - ) -> Optional[List[SourcePrice]]: - base, quote, type = self._decode(ticker) - - start = time_begin.date().isoformat() - end = time_end.date().isoformat() - - local_tz = datetime.now(timezone.utc).astimezone().tzinfo - user_tz = time_begin.tzinfo or local_tz - - try: - series = pricehist_source.fetch(Series(base, quote, type, start, end)) - except exceptions.SourceError: - return None - - return [ - SourcePrice( - price.amount, - datetime.fromisoformat(price.date).replace(tzinfo=user_tz), - series.quote, - ) - for price in series.prices - ] - - def _decode(self, ticker): - # https://github.com/beancount/beanprice/blob/b05203/beanprice/price.py#L166 - parts = [ - re.sub(r"_[0-9a-fA-F]{2}", lambda m: chr(int(m.group(0)[1:], 16)), part) - for part in ticker.split(":") - ] - base, quote, candidate_type = (parts + [""] * 3)[0:3] - type = candidate_type or pricehist_source.types()[0] - return (base, quote, type) - - return Source diff --git a/src/pricehist/beanprice/alphavantage.py b/src/pricehist/beanprice/alphavantage.py deleted file mode 100644 index 1f17a80..0000000 --- a/src/pricehist/beanprice/alphavantage.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.alphavantage import AlphaVantage - -Source = beanprice.source(AlphaVantage()) diff --git a/src/pricehist/beanprice/bankofcanada.py b/src/pricehist/beanprice/bankofcanada.py deleted file mode 100644 index 7c09ba7..0000000 --- a/src/pricehist/beanprice/bankofcanada.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.bankofcanada import BankOfCanada - -Source = beanprice.source(BankOfCanada()) diff --git a/src/pricehist/beanprice/coinbasepro.py b/src/pricehist/beanprice/coinbasepro.py deleted file mode 100644 index cb1a64a..0000000 --- a/src/pricehist/beanprice/coinbasepro.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.coinbasepro import CoinbasePro - -Source = beanprice.source(CoinbasePro()) diff --git a/src/pricehist/beanprice/coindesk.py b/src/pricehist/beanprice/coindesk.py deleted file mode 100644 index 8936456..0000000 --- a/src/pricehist/beanprice/coindesk.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.coindesk import CoinDesk - -Source = beanprice.source(CoinDesk()) diff --git a/src/pricehist/beanprice/coinmarketcap.py b/src/pricehist/beanprice/coinmarketcap.py deleted file mode 100644 index ae87a12..0000000 --- a/src/pricehist/beanprice/coinmarketcap.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.coinmarketcap import CoinMarketCap - -Source = beanprice.source(CoinMarketCap()) diff --git a/src/pricehist/beanprice/ecb.py b/src/pricehist/beanprice/ecb.py deleted file mode 100644 index 76109c9..0000000 --- a/src/pricehist/beanprice/ecb.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.ecb import ECB - -Source = beanprice.source(ECB()) diff --git a/src/pricehist/beanprice/exchangeratehost.py b/src/pricehist/beanprice/exchangeratehost.py deleted file mode 100644 index ad6525a..0000000 --- a/src/pricehist/beanprice/exchangeratehost.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.exchangeratehost import ExchangeRateHost - -Source = beanprice.source(ExchangeRateHost()) diff --git a/src/pricehist/beanprice/yahoo.py b/src/pricehist/beanprice/yahoo.py deleted file mode 100644 index 43d479c..0000000 --- a/src/pricehist/beanprice/yahoo.py +++ /dev/null @@ -1,4 +0,0 @@ -from pricehist import beanprice -from pricehist.sources.yahoo import Yahoo - -Source = beanprice.source(Yahoo()) diff --git a/src/pricehist/cli.py b/src/pricehist/cli.py index 9e4a69d..c5777b1 100644 --- a/src/pricehist/cli.py +++ b/src/pricehist/cli.py @@ -10,13 +10,13 @@ from pricehist.format import Format from pricehist.series import Series -def cli(argv=sys.argv): +def cli(args=None, output_file=sys.stdout): start_time = datetime.now() logger.init() parser = build_parser() - args = parser.parse_args(argv[1:]) + args = parser.parse_args() if args.verbose: logger.show_debug() @@ -25,105 +25,97 @@ def cli(argv=sys.argv): try: if args.version: - print(f"pricehist {__version__}") + print(f"pricehist {__version__}", file=output_file) elif args.command == "sources": result = sources.formatted() - print(result) + print(result, file=output_file) elif args.command == "source" and args.symbols: result = sources.by_id[args.source].format_symbols() - print(result, end="") + print(result, file=output_file, end="") elif args.command == "source" and args.search: result = sources.by_id[args.source].format_search(args.search) - print(result, end="") + print(result, file=output_file, end="") elif args.command == "source": total_width = shutil.get_terminal_size().columns result = sources.by_id[args.source].format_info(total_width) - print(result) + print(result, file=output_file) elif args.command == "fetch": source = sources.by_id[args.source] output = outputs.by_type[args.output] - if args.end < args.start: - parser.error( - f"The end date '{args.end}' preceeds the start date '{args.start}'!" - ) - if args.type not in source.types(): - parser.error( - f"The requested price type '{args.type}' is not " - f"recognized by the {source.id()} source!" + if args.start: + start = args.start + else: + start = source.start() + logging.info(f"Using the source default start date of {start}.") + if args.end < start: + logging.critical( + f"The end date '{args.end}' preceeds the start date '{start}'!" ) + sys.exit(1) series = Series( base=source.normalizesymbol(args.pair[0]), quote=source.normalizesymbol(args.pair[1]), - type=args.type, - start=args.start, + type=args.type or (source.types() + ["(none)"])[0], + start=start, end=args.end, ) + if series.type not in source.types(): + logging.critical( + f"The requested price type '{series.type}' is not " + f"recognized by the {source.id()} source!" + ) + sys.exit(1) fmt = Format.fromargs(args) result = fetch(series, source, output, args.invert, args.quantize, fmt) - print(result, end="") + print(result, end="", file=output_file) else: - parser.print_help() + parser.print_help(file=sys.stderr) except BrokenPipeError: logging.debug("The output pipe was closed early.") finally: logging.debug(f"Ended pricehist run at {datetime.now()}.") -def valid_pair(s): - base, quote = (s + "/").split("/")[0:2] - if base == "": - msg = f"No base found in the requested pair '{s}'." - raise argparse.ArgumentTypeError(msg) - return (base, quote) - - -def valid_date(s): - if s == "today": - return today() - try: - return datetime.strptime(s, "%Y-%m-%d").date().isoformat() - except ValueError: - msg = f"Not a valid YYYY-MM-DD date: '{s}'." - raise argparse.ArgumentTypeError(msg) - - -def valid_date_before(s): - return ( - datetime.strptime(valid_date(s), "%Y-%m-%d").date() - timedelta(days=1) - ).isoformat() - - -def valid_date_after(s): - return ( - datetime.strptime(valid_date(s), "%Y-%m-%d").date() + timedelta(days=1) - ).isoformat() - - -def valid_char(s): - if len(s) == 1: - return s - else: - msg = f"Not a single character: '{s}'." - raise argparse.ArgumentTypeError(msg) - - -def today(): - return datetime.now().date().isoformat() - - def build_parser(): + def valid_pair(s): + base, quote = (s + "/").split("/")[0:2] + if base == "": + msg = f"No base found in the requested pair '{s}'." + raise argparse.ArgumentTypeError(msg) + return (base, quote) + + def valid_date(s): + if s == "today": + return today() + try: + return datetime.strptime(s, "%Y-%m-%d").date().isoformat() + except ValueError: + msg = f"Not a valid YYYY-MM-DD date: '{s}'." + raise argparse.ArgumentTypeError(msg) + + def previous_valid_date(s): + return ( + datetime.strptime(valid_date(s), "%Y-%m-%d").date() - timedelta(days=1) + ).isoformat() + + def following_valid_date(s): + return ( + datetime.strptime(valid_date(s), "%Y-%m-%d").date() + timedelta(days=1) + ).isoformat() + + def today(): + return datetime.now().date().isoformat() + + def valid_char(s): + if len(s) == 1: + return s + else: + msg = f"Not a single character: '{s}'." + raise argparse.ArgumentTypeError(msg) + def formatter(prog): return argparse.HelpFormatter(prog, max_help_position=50) - class SetSourceDefaults(argparse.Action): - def __call__(self, parser, namespace, value, option_string=None): - source = sources.by_id[value] - setattr(namespace, self.dest, value) - if getattr(namespace, "type") is None: - setattr(namespace, "type", source.types()[0]) - if getattr(namespace, "start") is None: - setattr(namespace, "start", source.start()) - default_fmt = Format() parser = argparse.ArgumentParser( prog="pricehist", @@ -138,7 +130,6 @@ def build_parser(): ) parser.add_argument( - "-vvv", "--verbose", action="store_true", help="show all log messages", @@ -146,17 +137,11 @@ def build_parser(): subparsers = parser.add_subparsers(title="commands", dest="command") - sources_parser = subparsers.add_parser( + subparsers.add_parser( "sources", help="list sources", formatter_class=formatter, ) - sources_parser.add_argument( - "-vvv", - "--verbose", - action="store_true", - help="show all log messages", - ) source_parser = subparsers.add_parser( "source", @@ -171,12 +156,6 @@ def build_parser(): choices=sources.by_id.keys(), help="the source identifier", ) - source_parser.add_argument( - "-vvv", - "--verbose", - action="store_true", - help="show all log messages", - ) source_list_or_search = source_parser.add_mutually_exclusive_group(required=False) source_list_or_search.add_argument( @@ -198,14 +177,14 @@ def build_parser(): usage=( # Set usage manually to have positional arguments before options # and show allowed values where appropriate - "pricehist fetch SOURCE PAIR [-h] [-vvv] " + "pricehist fetch SOURCE PAIR [-h] " "[-t TYPE] [-s DATE | -sx DATE] [-e DATE | -ex DATE] " f"[-o {'|'.join(outputs.by_type.keys())}] " "[--invert] [--quantize INT] " "[--fmt-base SYM] [--fmt-quote SYM] [--fmt-time TIME] " "[--fmt-decimal CHAR] [--fmt-thousands CHAR] " "[--fmt-symbol rightspace|right|leftspace|left] [--fmt-datesep CHAR] " - "[--fmt-csvdelim CHAR] [--fmt-jsonnums]" + "[--fmt-csvdelim CHAR]" ), formatter_class=formatter, ) @@ -214,7 +193,6 @@ def build_parser(): metavar="SOURCE", type=str, choices=sources.by_id.keys(), - action=SetSourceDefaults, help="the source identifier", ) fetch_parser.add_argument( @@ -223,19 +201,13 @@ def build_parser(): type=valid_pair, help="pair, usually BASE/QUOTE, e.g. BTC/USD", ) - fetch_parser.add_argument( - "-vvv", - "--verbose", - action="store_true", - help="show all log messages", - ) fetch_parser.add_argument( "-t", "--type", dest="type", metavar="TYPE", type=str, - help="price type, e.g. close (default: first for source)", + help="price type, e.g. close", ) fetch_start_group = fetch_parser.add_mutually_exclusive_group(required=False) fetch_start_group.add_argument( @@ -251,7 +223,7 @@ def build_parser(): "--startx", dest="start", metavar="DATE", - type=valid_date_after, + type=following_valid_date, help="start date, exclusive", ) @@ -270,7 +242,7 @@ def build_parser(): "--endx", dest="end", metavar="DATE", - type=valid_date_before, + type=previous_valid_date, help="end date, exclusive", ) @@ -353,11 +325,5 @@ def build_parser(): type=valid_char, help=f"field delimiter for CSV output (default: '{default_fmt.csvdelim}')", ) - fetch_parser.add_argument( - "--fmt-jsonnums", - dest="formatjsonnums", - action="store_true", - help=f"numbers not strings for JSON output (default: {default_fmt.jsonnums})", - ) return parser diff --git a/src/pricehist/exceptions.py b/src/pricehist/exceptions.py index e207537..e393b97 100644 --- a/src/pricehist/exceptions.py +++ b/src/pricehist/exceptions.py @@ -1,18 +1,3 @@ -import logging -import sys -from contextlib import contextmanager - - -@contextmanager -def handler(): - try: - yield - except SourceError as e: - logging.debug("Critical exception encountered", exc_info=e) - logging.critical(str(e)) - sys.exit(1) - - class SourceError(Exception): """Base exception for errors rased by sources""" @@ -20,19 +5,17 @@ class SourceError(Exception): class InvalidPair(SourceError, ValueError): """An invalid pair was requested.""" - def __init__(self, base, quote, source, message=None): + def __init__(self, base, quote, source): self.base = base self.quote = quote self.source = source - pair = "/".join([s for s in [base, quote] if s]) - insert = message + " " if message else "" - - full_message = ( - f"Invalid pair '{pair}'. {insert}" + pair = "/".join([base, quote]) + message = ( + f"Invalid pair '{pair}'. " f"Run 'pricehist source {source.id()} --symbols' " f"for information about valid pairs." ) - super(InvalidPair, self).__init__(full_message) + super(InvalidPair, self).__init__(message) class InvalidType(SourceError, ValueError): @@ -40,37 +23,13 @@ class InvalidType(SourceError, ValueError): def __init__(self, type, base, quote, source): self.type = type - self.pair = "/".join([s for s in [base, quote] if s]) + self.pair = "/".join([base, quote]) message = ( f"Invalid price type '{type}' for pair '{self.pair}'. " - f"Run 'pricehist source {source.id()}' " + f"Run 'pricehist source {source.id()} " f"for information about valid types." ) - super(InvalidType, self).__init__(message) - - -class CredentialsError(SourceError): - """Access credentials are unavailable or invalid.""" - - def __init__(self, keys, source, msg=""): - self.keys = keys - self.source = source - message = ( - f"Access credentials for source '{source.id()}' are unavailable " - f"""or invalid. Set the environment variables '{"', '".join(keys)}' """ - f"correctly. Run 'pricehist source {source.id()}' for more " - f"information about credentials." - ) - if msg: - message += f" {msg}" - super(CredentialsError, self).__init__(message) - - -class RateLimit(SourceError): - """Source request rate limit reached.""" - - def __init__(self, message): - super(RateLimit, self).__init__(f"{self.__doc__} {message}") + super(InvalidPair, self).__init__(message) class RequestError(SourceError): diff --git a/src/pricehist/fetch.py b/src/pricehist/fetch.py index aba61f2..e1cc341 100644 --- a/src/pricehist/fetch.py +++ b/src/pricehist/fetch.py @@ -1,4 +1,5 @@ import logging +import sys from datetime import date, datetime, timedelta from pricehist import exceptions @@ -6,33 +7,33 @@ from pricehist import exceptions def fetch(series, source, output, invert: bool, quantize: int, fmt) -> str: if series.start < source.start(): - logging.warning( + logging.warn( f"The start date {series.start} preceeds the {source.name()} " f"source start date of {source.start()}." ) - with exceptions.handler(): + try: series = source.fetch(series) + except exceptions.SourceError as e: + logging.debug("Critical exception encountered", exc_info=e) + logging.critical(str(e)) + sys.exit(1) if len(series.prices) == 0: - logging.warning( - f"No data found for the interval [{series.start}--{series.end}]." - ) + logging.warn(f"No data found for the interval [{series.start}--{series.end}].") else: first = series.prices[0].date last = series.prices[-1].date - message = ( - f"Available data covers the interval [{first}--{last}], " - f"{_cov_description(series.start, series.end, first, last)}." - ) if first > series.start or last < series.end: + message = ( + f"Available data covers the interval [{first}--{last}], " + f"{_cov_description(series.start, series.end, first, last)}." + ) expected_end = _yesterday() if series.end == _today() else series.end if first == series.start and last == expected_end: logging.debug(message) # Missing today's price is expected else: - logging.warning(message) - else: - logging.debug(message) + logging.warn(message) if invert: series = series.invert() @@ -80,7 +81,5 @@ def _cov_description( f"and ends {end_uncovered} day{s(end_uncovered)} earlier " f"than requested" ) - elif start_uncovered == 0 and end_uncovered == 0: - return "as requested" else: - return "which doesn't match the request" + return "as requested" diff --git a/src/pricehist/format.py b/src/pricehist/format.py index 14207e7..fb7a8de 100644 --- a/src/pricehist/format.py +++ b/src/pricehist/format.py @@ -11,7 +11,6 @@ class Format: symbol: str = "rightspace" datesep: str = "-" csvdelim: str = "," - jsonnums: bool = False @classmethod def fromargs(cls, args): @@ -28,7 +27,6 @@ class Format: symbol=if_not_none(args.formatsymbol, default.symbol), datesep=if_not_none(args.formatdatesep, default.datesep), csvdelim=if_not_none(args.formatcsvdelim, default.csvdelim), - jsonnums=if_not_none(args.formatjsonnums, default.jsonnums), ) def format_date(self, date): diff --git a/src/pricehist/isocurrencies.py b/src/pricehist/isocurrencies.py index 624fe1d..fadee06 100644 --- a/src/pricehist/isocurrencies.py +++ b/src/pricehist/isocurrencies.py @@ -8,8 +8,8 @@ currencies are included and countries with no universal currency are ignored. The data is read from vendored copies of the XML files published by the maintainers of the standard: -* :file:`list-one.xml` (current currencies & funds) -* :file:`list-three.xml` (historical currencies & funds) +* :file:`list_one.xml` (current currencies & funds) +* :file:`list_three.xml` (historical currencies & funds) Classes: @@ -24,8 +24,7 @@ Functions: """ from dataclasses import dataclass, field -from importlib.resources import files -from typing import List +from importlib.resources import read_binary from lxml import etree @@ -37,34 +36,26 @@ class ISOCurrency: minor_units: int = None name: str = None is_fund: bool = False - countries: List[str] = field(default_factory=list) + countries: list[str] = field(default_factory=list) historical: bool = False withdrawal_date: str = None def current_data_date(): - one = etree.fromstring( - files("pricehist.resources").joinpath("list-one.xml").read_bytes() - ) + one = etree.fromstring(read_binary("pricehist.resources", "list_one.xml")) return one.cssselect("ISO_4217")[0].attrib["Pblshd"] def historical_data_date(): - three = etree.fromstring( - files("pricehist.resources").joinpath("list-three.xml").read_bytes() - ) + three = etree.fromstring(read_binary("pricehist.resources", "list_three.xml")) return three.cssselect("ISO_4217")[0].attrib["Pblshd"] def by_code(): result = {} - one = etree.fromstring( - files("pricehist.resources").joinpath("list-one.xml").read_bytes() - ) - three = etree.fromstring( - files("pricehist.resources").joinpath("list-three.xml").read_bytes() - ) + one = etree.fromstring(read_binary("pricehist.resources", "list_one.xml")) + three = etree.fromstring(read_binary("pricehist.resources", "list_three.xml")) for entry in three.cssselect("HstrcCcyNtry") + one.cssselect("CcyNtry"): if currency := _parse(entry): @@ -99,11 +90,13 @@ def _parse(entry): except (IndexError, ValueError): minor_units = None - name = None - is_fund = None - if name_tags := entry.cssselect("CcyNm"): + name_tags = entry.cssselect("CcyNm") + if name_tags: name = name_tags[0].text is_fund = name_tags[0].attrib.get("IsFund", "").upper() in ["TRUE", "WAHR"] + else: + name = None + is_fund = None countries = [t.text for t in entry.cssselect("CtryNm")] diff --git a/src/pricehist/logger.py b/src/pricehist/logger.py index d9bdf58..3a6143f 100644 --- a/src/pricehist/logger.py +++ b/src/pricehist/logger.py @@ -23,7 +23,6 @@ def init(): handler.setFormatter(Formatter()) logging.root.addHandler(handler) logging.root.setLevel(logging.INFO) - logging.getLogger("charset_normalizer").disabled = True def show_debug(): diff --git a/src/pricehist/outputs/__init__.py b/src/pricehist/outputs/__init__.py index 4b015c8..98e9547 100644 --- a/src/pricehist/outputs/__init__.py +++ b/src/pricehist/outputs/__init__.py @@ -1,7 +1,6 @@ from .beancount import Beancount from .csv import CSV from .gnucashsql import GnuCashSQL -from .json import JSON from .ledger import Ledger default = "csv" @@ -9,8 +8,6 @@ default = "csv" by_type = { "beancount": Beancount(), "csv": CSV(), - "json": JSON(), - "jsonl": JSON(jsonl=True), "gnucash-sql": GnuCashSQL(), "ledger": Ledger(), } diff --git a/src/pricehist/outputs/baseoutput.py b/src/pricehist/outputs/baseoutput.py index f803e14..f3cb58f 100644 --- a/src/pricehist/outputs/baseoutput.py +++ b/src/pricehist/outputs/baseoutput.py @@ -8,4 +8,4 @@ from pricehist.sources.basesource import BaseSource class BaseOutput(ABC): @abstractmethod def format(self, series: Series, source: BaseSource, fmt: Format) -> str: - pass # pragma: nocover + pass diff --git a/src/pricehist/outputs/beancount.py b/src/pricehist/outputs/beancount.py index 6ebd31e..366cc86 100644 --- a/src/pricehist/outputs/beancount.py +++ b/src/pricehist/outputs/beancount.py @@ -10,11 +10,9 @@ the Beancount format in mind. Relevant sections of the Beancount documentation: -* `Commodities / Currencies - `_ +* `Commodities / Currencies `_ * `Prices `_ -* `Fetching Prices in Beancount - `_ +* `Fetching Prices in Beancount `_ Classes: diff --git a/src/pricehist/outputs/gnucashsql.py b/src/pricehist/outputs/gnucashsql.py index 8ccbff7..0701908 100644 --- a/src/pricehist/outputs/gnucashsql.py +++ b/src/pricehist/outputs/gnucashsql.py @@ -40,9 +40,9 @@ Classes: import hashlib import logging -from datetime import datetime, timezone +from datetime import datetime from decimal import Decimal -from importlib.resources import files +from importlib.resources import read_text from pricehist import __version__ from pricehist.format import Format @@ -58,7 +58,6 @@ class GnuCashSQL(BaseOutput): self._warn_about_backslashes( { - "date": fmt.format_date("1970-01-01"), "time": fmt.time, "base": base, "quote": quote, @@ -111,7 +110,7 @@ class GnuCashSQL(BaseOutput): if too_big: # https://code.gnucash.org/docs/MAINT/group__Numeric.html # https://code.gnucash.org/docs/MAINT/structgnc__price__s.html - logging.warning( + logging.warn( "This SQL contains numbers outside of the int64 range required " "by GnuCash for the numerators and denominators of prices. " "Using the --quantize option to limit the number of decimal " @@ -119,18 +118,13 @@ class GnuCashSQL(BaseOutput): "well." ) - sql = ( - files("pricehist.resources") - .joinpath("gnucash.sql") - .read_text() - .format( - version=__version__, - timestamp=datetime.now(timezone.utc).isoformat()[:-6] + "Z", - base=self._sql_str(base), - quote=self._sql_str(quote), - values_comment=values_comment, - values=values, - ) + sql = read_text("pricehist.resources", "gnucash.sql").format( + version=__version__, + timestamp=datetime.utcnow().isoformat() + "Z", + base=self._sql_str(base), + quote=self._sql_str(quote), + values_comment=values_comment, + values=values, ) return sql @@ -138,10 +132,10 @@ class GnuCashSQL(BaseOutput): def _warn_about_backslashes(self, fields): hits = [name for name, value in fields.items() if "\\" in value] if hits: - logging.warning( + logging.warn( f"Before running this SQL, check the formatting of the " f"{self._english_join(hits)} strings. " - f"SQLite treats backslashes in strings as plain characters, but " + f"SQLite treats backslahes in strings as plain characters, but " f"MariaDB/MySQL and PostgreSQL may interpret them as escape " f"codes." ) @@ -174,9 +168,9 @@ class GnuCashSQL(BaseOutput): denom = str(1) else: numerator = sign + "".join([str(d) for d in tup.digits]) - denom = str(10**-tup.exponent) + denom = str(10 ** -tup.exponent) fit = self._fit_in_int64(Decimal(numerator), Decimal(denom)) return (numerator, denom, fit) def _fit_in_int64(self, *numbers): - return all(n >= -(2**63) and n <= (2**63) - 1 for n in numbers) + return all(n >= -(2 ** 63) and n <= (2 ** 63) - 1 for n in numbers) diff --git a/src/pricehist/outputs/json.py b/src/pricehist/outputs/json.py deleted file mode 100644 index 8983e8e..0000000 --- a/src/pricehist/outputs/json.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -JSON output - -Date, number and base/quote formatting options will be respected. - -Classes: - - JSON - -""" - -import io -import json - -from pricehist.format import Format - -from .baseoutput import BaseOutput - - -class JSON(BaseOutput): - def __init__(self, jsonl=False): - self.jsonl = jsonl - - def format(self, series, source, fmt=Format()): - data = [] - output = io.StringIO() - - base = fmt.base or series.base - quote = fmt.quote or series.quote - - for price in series.prices: - date = fmt.format_date(price.date) - if fmt.jsonnums: - amount = float(price.amount) - else: - amount = fmt.format_num(price.amount) - - data.append( - { - "date": date, - "base": base, - "quote": quote, - "amount": amount, - "source": source.id(), - "type": series.type, - } - ) - - if self.jsonl: - for row in data: - json.dump(row, output, ensure_ascii=False) - output.write("\n") - else: - json.dump(data, output, ensure_ascii=False, indent=2) - output.write("\n") - - return output.getvalue() diff --git a/src/pricehist/outputs/ledger.py b/src/pricehist/outputs/ledger.py index 84fcf81..6682677 100644 --- a/src/pricehist/outputs/ledger.py +++ b/src/pricehist/outputs/ledger.py @@ -11,10 +11,8 @@ format. Relevant sections of the Ledger manual: -* `Commodities and Currencies - `_ -* `Commoditized Amounts - `_ +* `Commodities and Currencies `_ +* `Commoditized Amounts `_ Relevant sections of the hledger manual: diff --git a/src/pricehist/resources/gnucash.sql b/src/pricehist/resources/gnucash.sql index aa2a39d..e22671a 100644 --- a/src/pricehist/resources/gnucash.sql +++ b/src/pricehist/resources/gnucash.sql @@ -35,10 +35,10 @@ WHERE tp.base = g1.mnemonic AND tp.guid NOT IN (SELECT guid FROM prices) ; --- Show the final relevant rows of the main prices table -SELECT 'final' AS status, p.* FROM prices p WHERE p.guid IN (SELECT guid FROM new_prices) ORDER BY p.date; - -- Show the summary. SELECT * FROM summary; +-- Show the final relevant rows of the main prices table +SELECT 'final' AS status, p.* FROM prices p WHERE p.guid IN (SELECT guid FROM new_prices) ORDER BY p.date; + COMMIT; diff --git a/src/pricehist/resources/list-one.xml b/src/pricehist/resources/list_one.xml similarity index 98% rename from src/pricehist/resources/list-one.xml rename to src/pricehist/resources/list_one.xml index 1f72a89..20be53b 100644 --- a/src/pricehist/resources/list-one.xml +++ b/src/pricehist/resources/list_one.xml @@ -1,5 +1,5 @@ - + AFGHANISTAN @@ -413,9 +413,9 @@ CROATIA - Euro - EUR - 978 + Kuna + HRK + 191 2 @@ -1493,13 +1493,6 @@ 694 2 - - SIERRA LEONE - Leone - SLE - 925 - 2 - SINGAPORE Singapore Dollar @@ -1708,7 +1701,7 @@ 3 - TÜRKİYE + TURKEY Turkish Lira TRY 949 @@ -1826,13 +1819,6 @@ 928 2 - - VENEZUELA (BOLIVARIAN REPUBLIC OF) - Bolívar Soberano - VED - 926 - 2 - VIET NAM Dong diff --git a/src/pricehist/resources/list-three.xml b/src/pricehist/resources/list_three.xml similarity index 99% rename from src/pricehist/resources/list-three.xml rename to src/pricehist/resources/list_three.xml index 584b33b..3959003 100644 --- a/src/pricehist/resources/list-three.xml +++ b/src/pricehist/resources/list_three.xml @@ -1,5 +1,5 @@ - - + + AFGHANISTAN @@ -253,13 +253,6 @@ 191 2015-06 - - CROATIA - Kuna - HRK - 191 - 2023-01 - CYPRUS Cyprus Pound diff --git a/src/pricehist/series.py b/src/pricehist/series.py index 88a1de3..d063b90 100644 --- a/src/pricehist/series.py +++ b/src/pricehist/series.py @@ -1,6 +1,5 @@ from dataclasses import dataclass, field, replace from decimal import Decimal, getcontext -from typing import List from pricehist.price import Price @@ -12,7 +11,7 @@ class Series: type: str start: str end: str - prices: List[Price] = field(default_factory=list) + prices: list[Price] = field(default_factory=list) def invert(self): return replace( diff --git a/src/pricehist/sources/__init__.py b/src/pricehist/sources/__init__.py index dfde1be..892de97 100644 --- a/src/pricehist/sources/__init__.py +++ b/src/pricehist/sources/__init__.py @@ -1,6 +1,4 @@ from .alphavantage import AlphaVantage -from .bankofcanada import BankOfCanada -from .coinbasepro import CoinbasePro from .coindesk import CoinDesk from .coinmarketcap import CoinMarketCap from .ecb import ECB @@ -8,15 +6,7 @@ from .yahoo import Yahoo by_id = { source.id(): source - for source in [ - AlphaVantage(), - BankOfCanada(), - CoinbasePro(), - CoinDesk(), - CoinMarketCap(), - ECB(), - Yahoo(), - ] + for source in [AlphaVantage(), CoinDesk(), CoinMarketCap(), ECB(), Yahoo()] } diff --git a/src/pricehist/sources/alphavantage.py b/src/pricehist/sources/alphavantage.py index 40d5b98..7b4db38 100644 --- a/src/pricehist/sources/alphavantage.py +++ b/src/pricehist/sources/alphavantage.py @@ -5,11 +5,9 @@ import logging import os from datetime import datetime, timedelta from decimal import Decimal -from typing import List, Tuple import requests -from pricehist import __version__, exceptions from pricehist.price import Price from .basesource import BaseSource @@ -17,7 +15,6 @@ from .basesource import BaseSource class AlphaVantage(BaseSource): QUERY_URL = "https://www.alphavantage.co/query" - API_KEY_NAME = "ALPHAVANTAGE_API_KEY" def id(self): return "alphavantage" @@ -38,26 +35,24 @@ class AlphaVantage(BaseSource): return ["close", "open", "high", "low", "adjclose", "mid"] def notes(self): - keystatus = "already set" if self._apikey(require=False) else "not yet set" + keystatus = "already set" if self._apikey(require=False) else "NOT YET set" return ( "Alpha Vantage has data on digital (crypto) currencies, physical " "(fiat) currencies and stocks.\n" - "You should obtain a free API key from " - "https://www.alphavantage.co/support/#api-key and set it in " - f"the {self.API_KEY_NAME} environment variable ({keystatus}), " - "otherwise, pricehist will attempt to use a generic key.\n" + "An API key is required. One can be obtained for free from " + "https://www.alphavantage.co/support/#api-key and should be made " + "available in the ALPHAVANTAGE_API_KEY environment variable " + f"({keystatus}).\n" "The PAIR for currencies should be in BASE/QUOTE form. The quote " "symbol must always be for a physical currency. The --symbols option " "will list all digital and physical currency symbols.\n" "The PAIR for stocks is the stock symbol only. The quote currency " f"will be determined automatically. {self._stock_symbols_message()}\n" - "The price type 'adjclose' is only available for stocks, and " - "requires an access key for which premium endpoints are unlocked.\n" - "Beware that digital currencies quoted in non-USD currencies may " - "be converted from USD data at one recent exchange rate rather " - "than using historical rates.\n" - "Alpha Vantage's standard API rate limit is 25 requests per day. " - "Note that retrieving prices for one stock consumes two API calls." + "The price type 'adjclose' is only available for stocks.\n" + "Alpha Vantage's standard API call frequency limits is 5 calls per " + "minute and 500 per day, so you may need to pause between successive " + "commands. Note that retrieving prices for one stock requires two " + "calls." ) def _stock_symbols_message(self): @@ -90,37 +85,19 @@ class AlphaVantage(BaseSource): output_quote = series.quote if series.quote == "": - output_quote, data = self._stock_data(series) + output_quote = self._stock_currency(output_base) + data = self._stock_data(series) else: if series.type == "adjclose": - raise exceptions.InvalidType( - series.type, series.base, series.quote, self + logging.critical( + "The 'adjclose' price type is only available for stocks. " + "Use 'close' instead." ) - - physical_symbols = [s for s, n in self._physical_symbols()] - - if series.quote not in physical_symbols: - raise exceptions.InvalidPair( - series.base, - series.quote, - self, - "When given, the quote must be a physical currency.", - ) - - if series.base in physical_symbols: + exit(1) + elif series.base in [s for s, n in self._physical_symbols()]: data = self._physical_data(series) - - elif series.base in [s for s, n in self._digital_symbols()]: - data = self._digital_data(series) - else: - raise exceptions.InvalidPair( - series.base, - series.quote, - self, - "When a quote currency is given, the base must be a known " - "physical or digital currency.", - ) + data = self._digital_data(series) prices = [ Price(day, amount) @@ -135,7 +112,7 @@ class AlphaVantage(BaseSource): def _amount(self, day, entries, series): if day < series.start or day > series.end: return None - elif series.type == "mid": + elif type == "mid": return sum([Decimal(entries["high"]), Decimal(entries["low"])]) / 2 else: return Decimal(entries[series.type]) @@ -145,7 +122,7 @@ class AlphaVantage(BaseSource): for match in data["bestMatches"]: if match["1. symbol"] == symbol: return match["8. currency"] - return None + return "Unknown" def _search_data(self, keywords: str): params = { @@ -153,91 +130,30 @@ class AlphaVantage(BaseSource): "keywords": keywords, "apikey": self._apikey(), } - - try: - response = self.log_curl(requests.get(self.QUERY_URL, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - self._raise_for_generic_errors(data) - - expected_keys = ["1. symbol", "2. name", "3. type", "4. region", "8. currency"] - if ( - type(data) is not dict - or "bestMatches" not in data - or type(data["bestMatches"]) is not list - or not all(k in m for k in expected_keys for m in data["bestMatches"]) - ): - raise exceptions.ResponseParsingError("Unexpected content.") - + response = self.log_curl(requests.get(self.QUERY_URL, params=params)) + data = json.loads(response.content) return data def _stock_data(self, series): - output_quote = self._stock_currency(series.base) or "UNKNOWN" - - if series.type == "adjclose": - function = "TIME_SERIES_DAILY_ADJUSTED" - else: - function = "TIME_SERIES_DAILY" - params = { - "function": function, + "function": "TIME_SERIES_DAILY_ADJUSTED", "symbol": series.base, "outputsize": self._outputsize(series.start), "apikey": self._apikey(), } - - try: - response = self.log_curl(requests.get(self.QUERY_URL, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - self._raise_for_generic_errors(data) - - if "Error Message" in data: - if output_quote == "UNKNOWN": - raise exceptions.InvalidPair( - series.base, series.quote, self, "Unknown stock symbol." - ) - else: - raise exceptions.BadResponse(data["Error Message"]) - - try: - normalized_data = { - day: { - "open": entries["1. open"], - "high": entries["2. high"], - "low": entries["3. low"], - "close": entries["4. close"], - "adjclose": "5. adjusted close" in entries - and entries["5. adjusted close"], - } - for day, entries in reversed(data["Time Series (Daily)"].items()) + response = self.log_curl(requests.get(self.QUERY_URL, params=params)) + data = json.loads(response.content) + normalized_data = { + day: { + "open": entries["1. open"], + "high": entries["2. high"], + "low": entries["3. low"], + "close": entries["4. close"], + "adjclose": entries["5. adjusted close"], } - except Exception as e: - raise exceptions.ResponseParsingError("Unexpected content.") from e - - return output_quote, normalized_data + for day, entries in reversed(data["Time Series (Daily)"].items()) + } + return normalized_data def _physical_data(self, series): params = { @@ -247,27 +163,8 @@ class AlphaVantage(BaseSource): "outputsize": self._outputsize(series.start), "apikey": self._apikey(), } - - try: - response = self.log_curl(requests.get(self.QUERY_URL, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - self._raise_for_generic_errors(data) - - if type(data) is not dict or "Time Series FX (Daily)" not in data: - raise exceptions.ResponseParsingError("Unexpected content.") - + response = self.log_curl(requests.get(self.QUERY_URL, params=params)) + data = json.loads(response.content) normalized_data = { day: {k[3:]: v for k, v in entries.items()} for day, entries in reversed(data["Time Series FX (Daily)"].items()) @@ -288,33 +185,14 @@ class AlphaVantage(BaseSource): "market": series.quote, "apikey": self._apikey(), } - - try: - response = self.log_curl(requests.get(self.QUERY_URL, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - self._raise_for_generic_errors(data) - - if type(data) is not dict or "Time Series (Digital Currency Daily)" not in data: - raise exceptions.ResponseParsingError("Unexpected content.") - + response = self.log_curl(requests.get(self.QUERY_URL, params=params)) + data = json.loads(response.content) normalized_data = { day: { - "open": entries["1. open"], - "high": entries["2. high"], - "low": entries["3. low"], - "close": entries["4. close"], + "open": entries[f"1a. open ({series.quote})"], + "high": entries[f"2a. high ({series.quote})"], + "low": entries[f"3a. low ({series.quote})"], + "close": entries[f"4a. close ({series.quote})"], } for day, entries in reversed( data["Time Series (Digital Currency Daily)"].items() @@ -323,57 +201,27 @@ class AlphaVantage(BaseSource): return normalized_data def _apikey(self, require=True): - key = os.getenv(self.API_KEY_NAME) + key_name = "ALPHAVANTAGE_API_KEY" + key = os.getenv(key_name) if require and not key: - generic_key = f"pricehist_{__version__}" - logging.debug( - f"{self.API_KEY_NAME} not set. " - f"Defaulting to generic key '{generic_key}'." + logging.critical( + f"The environment variable {key_name} is empty. " + "Get a free API key from https://www.alphavantage.co/support/#api-key, " + f'export {key_name}="YOUR_OWN_API_KEY" and retry.' ) - return generic_key + exit(1) return key - def _raise_for_generic_errors(self, data): - if type(data) is dict: - if "Information" in data and "daily rate limits" in data["Information"]: - raise exceptions.RateLimit(data["Information"]) - if ( - "Information" in data - and "unlock" in data["Information"] - and "premium" in data["Information"] - ): - msg = "You were denied access to a premium endpoint." - raise exceptions.CredentialsError([self.API_KEY_NAME], self, msg) - if "Error Message" in data and "apikey " in data["Error Message"]: - raise exceptions.CredentialsError([self.API_KEY_NAME], self) - - def _physical_symbols(self) -> List[Tuple[str, str]]: + def _physical_symbols(self) -> list[(str, str)]: url = "https://www.alphavantage.co/physical_currency_list/" - return self._get_symbols(url, "Physical: ") + response = self.log_curl(requests.get(url)) + lines = response.content.decode("utf-8").splitlines() + data = csv.reader(lines[1:], delimiter=",") + return [(s, f"Physical: {n}") for s, n in data] - def _digital_symbols(self) -> List[Tuple[str, str]]: + def _digital_symbols(self) -> list[(str, str)]: url = "https://www.alphavantage.co/digital_currency_list/" - return self._get_symbols(url, "Digital: ") - - def _get_symbols(self, url, prefix) -> List[Tuple[str, str]]: - try: - response = self.log_curl(requests.get(url)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - lines = response.content.decode("utf-8").splitlines() - data = csv.reader(lines[1:], delimiter=",") - results = [(s, f"{prefix}{n}") for s, n in data] - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if len(results) == 0: - raise exceptions.ResponseParsingError("Symbols data missing.") - - return results + response = self.log_curl(requests.get(url)) + lines = response.content.decode("utf-8").splitlines() + data = csv.reader(lines[1:], delimiter=",") + return [(s, f"Digital: {n}") for s, n in data] diff --git a/src/pricehist/sources/bankofcanada.py b/src/pricehist/sources/bankofcanada.py deleted file mode 100644 index 67b3b59..0000000 --- a/src/pricehist/sources/bankofcanada.py +++ /dev/null @@ -1,118 +0,0 @@ -import dataclasses -import json -from decimal import Decimal - -import requests - -from pricehist import exceptions -from pricehist.price import Price - -from .basesource import BaseSource - - -class BankOfCanada(BaseSource): - def id(self): - return "bankofcanada" - - def name(self): - return "Bank of Canada" - - def description(self): - return "Daily exchange rates of the Canadian dollar from the Bank of Canada" - - def source_url(self): - return "https://www.bankofcanada.ca/valet/docs" - - def start(self): - return "2017-01-03" - - def types(self): - return ["default"] - - def notes(self): - return ( - "Currently, only daily exchange rates are supported. They are " - "published once each business day by 16:30 ET. " - "All Bank of Canada exchange rates are indicative rates only.\n" - "To request support for other data provided by the " - "Bank of Canada Valet Web Services, please open an " - "issue in pricehist's Gitlab project. " - ) - - def symbols(self): - url = "https://www.bankofcanada.ca/valet/lists/series/json" - - try: - response = self.log_curl(requests.get(url)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - series_names = data["series"].keys() - fx_series_names = [ - n for n in series_names if len(n) == 8 and n[0:2] == "FX" - ] - results = [ - (f"{n[2:5]}/{n[5:9]}", data["series"][n]["description"]) - for n in sorted(fx_series_names) - ] - - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if not results: - raise exceptions.ResponseParsingError("Expected data not found") - else: - return results - - def fetch(self, series): - if len(series.base) != 3 or len(series.quote) != 3: - raise exceptions.InvalidPair(series.base, series.quote, self) - - series_name = f"FX{series.base}{series.quote}" - data = self._data(series, series_name) - - prices = [] - for o in data.get("observations", []): - prices.append(Price(o["d"], Decimal(o[series_name]["v"]))) - - return dataclasses.replace(series, prices=prices) - - def _data(self, series, series_name): - url = f"https://www.bankofcanada.ca/valet/observations/{series_name}/json" - params = { - "start_date": series.start, - "end_date": series.end, - "order_dir": "asc", - } - - try: - response = self.log_curl(requests.get(url, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - code = response.status_code - text = response.text - - try: - result = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if code == 404 and "not found" in text: - raise exceptions.InvalidPair(series.base, series.quote, self) - elif code == 400 and "End date must be greater than the Start date" in text: - raise exceptions.BadResponse(result["message"]) - else: - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - return result diff --git a/src/pricehist/sources/basesource.py b/src/pricehist/sources/basesource.py index cab423f..52f0df8 100644 --- a/src/pricehist/sources/basesource.py +++ b/src/pricehist/sources/basesource.py @@ -1,11 +1,9 @@ import logging from abc import ABC, abstractmethod from textwrap import TextWrapper -from typing import List, Tuple import curlify -from pricehist import exceptions from pricehist.series import Series @@ -31,7 +29,7 @@ class BaseSource(ABC): pass # pragma: nocover @abstractmethod - def types(self) -> List[str]: + def types(self) -> list[str]: pass # pragma: nocover @abstractmethod @@ -42,10 +40,10 @@ class BaseSource(ABC): return str.upper() @abstractmethod - def symbols(self) -> List[Tuple[str, str]]: + def symbols(self) -> list[(str, str)]: pass # pragma: nocover - def search(self, query) -> List[Tuple[str, str]]: + def search(self, query) -> list[(str, str)]: pass # pragma: nocover @abstractmethod @@ -54,27 +52,19 @@ class BaseSource(ABC): def log_curl(self, response): curl = curlify.to_curl(response.request, compressed=True) - logging.debug(curl) + logging.debug(f"Request to {self.id()}: {curl}") return response def format_symbols(self) -> str: - with exceptions.handler(): - symbols = self.symbols() - + symbols = self.symbols() width = max([len(sym) for sym, desc in symbols] + [0]) lines = [sym.ljust(width + 4) + desc + "\n" for sym, desc in symbols] return "".join(lines) def format_search(self, query) -> str: - with exceptions.handler(): - symbols = self.search(query) - - if symbols is None: + if (symbols := self.search(query)) is None: logging.error(f"Symbol search is not possible for the {self.id()} source.") exit(1) - elif symbols == []: - logging.info(f"No results found for query '{query}'.") - return "" else: width = max([len(sym) for sym, desc in symbols] + [0]) lines = [sym.ljust(width + 4) + desc + "\n" for sym, desc in symbols] @@ -82,18 +72,15 @@ class BaseSource(ABC): def format_info(self, total_width=80) -> str: k_width = 11 - with exceptions.handler(): - parts = [ - self._fmt_field("ID", self.id(), k_width, total_width), - self._fmt_field("Name", self.name(), k_width, total_width), - self._fmt_field( - "Description", self.description(), k_width, total_width - ), - self._fmt_field("URL", self.source_url(), k_width, total_width, False), - self._fmt_field("Start", self.start(), k_width, total_width), - self._fmt_field("Types", ", ".join(self.types()), k_width, total_width), - self._fmt_field("Notes", self.notes(), k_width, total_width), - ] + parts = [ + self._fmt_field("ID", self.id(), k_width, total_width), + self._fmt_field("Name", self.name(), k_width, total_width), + self._fmt_field("Description", self.description(), k_width, total_width), + self._fmt_field("URL", self.source_url(), k_width, total_width, False), + self._fmt_field("Start", self.start(), k_width, total_width), + self._fmt_field("Types", ", ".join(self.types()), k_width, total_width), + self._fmt_field("Notes", self.notes(), k_width, total_width), + ] return "\n".join(filter(None, parts)) def _fmt_field(self, key, value, key_width, total_width, force=True): diff --git a/src/pricehist/sources/coinbasepro.py b/src/pricehist/sources/coinbasepro.py deleted file mode 100644 index c56efbd..0000000 --- a/src/pricehist/sources/coinbasepro.py +++ /dev/null @@ -1,164 +0,0 @@ -import dataclasses -import json -from datetime import datetime, timedelta, timezone -from decimal import Decimal - -import requests - -from pricehist import exceptions -from pricehist.price import Price - -from .basesource import BaseSource - - -class CoinbasePro(BaseSource): - def id(self): - return "coinbasepro" - - def name(self): - return "Coinbase Pro" - - def description(self): - return "The Coinbase Pro feed API provides market data to the public." - - def source_url(self): - return "https://docs.pro.coinbase.com/" - - def start(self): - return "2015-07-20" - - def types(self): - return ["mid", "open", "high", "low", "close"] - - def notes(self): - return ( - "This source uses Coinbase's Pro APIs, not the v2 API.\n" - "No key or other authentication is requried because it only uses " - "the feed APIs that provide market data and are public." - ) - - def symbols(self): - products_url = "https://api.pro.coinbase.com/products" - currencies_url = "https://api.pro.coinbase.com/currencies" - - try: - products_response = self.log_curl(requests.get(products_url)) - currencies_response = self.log_curl(requests.get(currencies_url)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - products_response.raise_for_status() - currencies_response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - products_data = json.loads(products_response.content) - currencies_data = json.loads(currencies_response.content) - currencies = {c["id"]: c for c in currencies_data} - - results = [] - for i in sorted(products_data, key=lambda i: i["id"]): - base = i["base_currency"] - quote = i["quote_currency"] - base_name = currencies[base]["name"] if currencies[base] else base - quote_name = currencies[quote]["name"] if currencies[quote] else quote - results.append((f"{base}/{quote}", f"{base_name} against {quote_name}")) - - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if not results: - raise exceptions.ResponseParsingError("Expected data not found") - else: - return results - - def fetch(self, series): - data = [] - for seg_start, seg_end in self._segments(series.start, series.end): - data.extend(self._data(series.base, series.quote, seg_start, seg_end)) - - prices = [] - for item in data: - prices.append(Price(item["date"], self._amount(item, series.type))) - - return dataclasses.replace(series, prices=prices) - - def _segments(self, start, end, length=290): - start = datetime.fromisoformat(start).date() - end = max(datetime.fromisoformat(end).date(), start) - - segments = [] - seg_start = start - while seg_start <= end: - seg_end = min(seg_start + timedelta(days=length - 1), end) - segments.append((seg_start.isoformat(), seg_end.isoformat())) - seg_start = seg_end + timedelta(days=1) - - return segments - - def _data(self, base, quote, start, end): - product = f"{base}-{quote}" - url = f"https://api.pro.coinbase.com/products/{product}/candles" - params = { - "start": start, - "end": end, - "granularity": "86400", - } - - try: - response = self.log_curl(requests.get(url, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - code = response.status_code - text = response.text - if code == 400 and "aggregations requested exceeds" in text: - raise exceptions.BadResponse("Too many data points requested.") - elif code == 400 and "start must be before end" in text: - raise exceptions.BadResponse("The end can't preceed the start.") - elif code == 400 and "is too old" in text: - raise exceptions.BadResponse("The requested interval is too early.") - elif code == 404 and "NotFound" in text: - raise exceptions.InvalidPair(base, quote, self) - elif code == 429: - raise exceptions.RateLimit( - "The rate limit has been exceeded. For more information see " - "https://docs.pro.coinbase.com/#rate-limit." - ) - else: - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - result = reversed( - [ - { - "date": self._ts_to_date(candle[0]), - "low": candle[1], - "high": candle[2], - "open": candle[3], - "close": candle[4], - } - for candle in json.loads(response.content) - if start <= self._ts_to_date(candle[0]) <= end - ] - ) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - return result - - def _ts_to_date(self, ts): - return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() - - def _amount(self, item, type): - if type in ["mid"]: - high = Decimal(str(item["high"])) - low = Decimal(str(item["low"])) - return sum([high, low]) / 2 - else: - return Decimal(str(item[type])) diff --git a/src/pricehist/sources/coindesk.py b/src/pricehist/sources/coindesk.py index 7947d1a..a7e751e 100644 --- a/src/pricehist/sources/coindesk.py +++ b/src/pricehist/sources/coindesk.py @@ -1,6 +1,5 @@ import dataclasses import json -import logging from decimal import Decimal import requests @@ -20,9 +19,7 @@ class CoinDesk(BaseSource): def description(self): return ( - "WARNING: This source is deprecated. Data stops at 2022-07-10.\n" - "The documentation URL now redirects to the main page.\n" - "An average of Bitcoin prices across leading global exchanges.\n" + "An average of Bitcoin prices across leading global exchanges. \n" "Powered by CoinDesk, https://www.coindesk.com/price/bitcoin" ) @@ -67,8 +64,6 @@ class CoinDesk(BaseSource): return results def fetch(self, series): - logging.warning("This source is deprecated. Data stops at 2022-07-10.") - if series.base != "BTC" or series.quote in ["BTC", "XBT"]: # BTC is the only valid base. # BTC as the quote will return BTC/USD, which we don't want. @@ -110,7 +105,7 @@ class CoinDesk(BaseSource): raise exceptions.BadResponse( "No results returned from database. This can happen when data " "for a valid quote currency (e.g. CUP) doesn't go all the way " - "back to the start date, and potentially for other reasons." + "back to the start date, or potentially for other reasons." ) else: try: diff --git a/src/pricehist/sources/coinmarketcap.py b/src/pricehist/sources/coinmarketcap.py index 58eeaaf..0d04b62 100644 --- a/src/pricehist/sources/coinmarketcap.py +++ b/src/pricehist/sources/coinmarketcap.py @@ -1,12 +1,10 @@ import dataclasses import json -from datetime import datetime, timezone +from datetime import datetime from decimal import Decimal -from functools import lru_cache import requests -from pricehist import exceptions from pricehist.price import Price from .basesource import BaseSource @@ -33,16 +31,13 @@ class CoinMarketCap(BaseSource): def notes(self): return ( - "This source makes unoffical use of endpoints that power " - "CoinMarketCap's public web interface.\n" - "CoinMarketCap currency symbols are not necessarily unique. " - "Each symbol you give will be coverted an ID by checking fiat and " - "metals first, then crypto by CoinMarketCap rank. " - "The symbol data is hard-coded for fiat and metals, but fetched " - "live for crypto.\n" - "You can directly use IDs, which can be listed via the --symbols " - "option. For example, 'ETH/BTC' is 'id=1027/id=1'. " - "The corresponding symbols will be used in output, when available." + "This source makes unoffical use of endpoints that power CoinMarketCap's " + "public web interface. The price data comes from a public equivalent of " + "the OHLCV Historical endpoint found in CoinMarketCap's official API.\n" + "CoinMarketCap currency symbols are not necessarily unique, so it " + "is recommended that you use IDs, which can be listed via the " + "--symbols option. For example, 'ETH/BTC' is 'id=1027/id=1'. The " + "corresponding symbols will be used in output." ) def symbols(self): @@ -52,17 +47,13 @@ class CoinMarketCap(BaseSource): return list(zip(ids, descriptions)) def fetch(self, series): - if series.base == "ID=" or not series.quote or series.quote == "ID=": - raise exceptions.InvalidPair(series.base, series.quote, self) - data = self._data(series) prices = [] - for item in data.get("quotes", []): - d = item["timeOpen"][0:10] - amount = self._amount(item["quote"], series.type) - if amount is not None: - prices.append(Price(d, amount)) + for item in data["data"]["quotes"]: + d = item["time_open"][0:10] + amount = self._amount(next(iter(item["quote"].values())), series.type) + prices.append(Price(d, amount)) output_base, output_quote = self._output_pair(series.base, series.quote, data) @@ -71,257 +62,60 @@ class CoinMarketCap(BaseSource): ) def _data(self, series): - url = "https://api.coinmarketcap.com/data-api/v3.1/cryptocurrency/historical" + url = "https://web-api.coinmarketcap.com/v1/cryptocurrency/ohlcv/historical" params = {} if series.base.startswith("ID="): params["id"] = series.base[3:] else: - params["id"] = self._id_from_symbol(series.base, series) + params["symbol"] = series.base if series.quote.startswith("ID="): - params["convertId"] = series.quote[3:] + params["convert_id"] = series.quote[3:] else: - params["convertId"] = self._id_from_symbol(series.quote, series) + params["convert"] = series.quote - params["timeStart"] = int( - int( - datetime.strptime(series.start, "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - .timestamp() - ) - - 24 * 60 * 60 - # Start one period earlier since the start is exclusive. + params["time_start"] = int( + int(datetime.strptime(series.start, "%Y-%m-%d").timestamp()) ) - params["timeEnd"] = int( - datetime.strptime(series.end, "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - .timestamp() - ) # Don't round up since it's inclusive of the period covering the end time. + params["time_end"] = ( + int(datetime.strptime(series.end, "%Y-%m-%d").timestamp()) + 24 * 60 * 60 + ) # round up to include the last day - params["interval"] = "daily" + response = self.log_curl(requests.get(url, params=params)) - try: - response = self.log_curl(requests.get(url, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - code = response.status_code - text = response.text - - if code == 400 and "No items found." in text: - raise exceptions.InvalidPair( - series.base, series.quote, self, "Bad base ID." - ) - - elif code == 400 and 'Invalid value for \\"convert_id\\"' in text: - raise exceptions.InvalidPair( - series.base, series.quote, self, "Bad quote ID." - ) - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - parsed = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if ( - "status" in parsed - and "error_code" in parsed["status"] - and parsed["status"]["error_code"] == "500" - and "The system is busy" in parsed["status"]["error_message"] - ): - raise exceptions.BadResponse( - "The server indicated a general error. " - "There may be problem with your request." - ) - - if type(parsed) is not dict or "data" not in parsed: - raise exceptions.ResponseParsingError("Unexpected content.") - - elif len(parsed["data"]) == 0: - raise exceptions.ResponseParsingError( - "The data section was empty. This can happen when the quote " - "currency symbol can't be found, and potentially for other reasons." - ) - - return parsed["data"] + return json.loads(response.content) def _amount(self, data, type): - if type in ["mid"] and data["high"] is not None and data["low"] is not None: + if type in ["mid"]: high = Decimal(str(data["high"])) low = Decimal(str(data["low"])) return sum([high, low]) / 2 - elif type in data and data[type] is not None: - return Decimal(str(data[type])) else: - return None + return Decimal(str(data[type])) def _output_pair(self, base, quote, data): - data_base = data["symbol"] + data_base = data["data"]["symbol"] + data_quote = next(iter(data["data"]["quotes"][0]["quote"].keys())) - symbols = {i["id"]: (i["symbol"] or i["code"]) for i in self._symbol_data()} - - data_quote = None - if len(data["quotes"]) > 0: - data_quote = symbols[int(data["quotes"][0]["quote"]["name"])] - - lookup_quote = None + lookup_quote = False if quote.startswith("ID="): + symbols = {i["id"]: (i["symbol"] or i["code"]) for i in self._symbol_data()} lookup_quote = symbols[int(quote[3:])] output_base = data_base - output_quote = data_quote or lookup_quote or quote + output_quote = lookup_quote or data_quote return (output_base, output_quote) - def _id_from_symbol(self, symbol, series): - for i in self._symbol_data(): - if i["symbol"] == symbol: - return i["id"] - raise exceptions.InvalidPair( - series.base, series.quote, self, f"Invalid symbol '{symbol}'." - ) - - @lru_cache(maxsize=1) def _symbol_data(self): - - base_url = "https://api.coinmarketcap.com/data-api/v1/" - crypto_url = f"{base_url}cryptocurrency/map?sort=cmc_rank" - - crypto = self._get_json_data(crypto_url) - - # fmt: off - fiat = [ - {"id": 2781, "symbol": "USD", "name": "United States Dollar"}, - {"id": 3526, "symbol": "ALL", "name": "Albanian Lek"}, - {"id": 3537, "symbol": "DZD", "name": "Algerian Dinar"}, - {"id": 2821, "symbol": "ARS", "name": "Argentine Peso"}, - {"id": 3527, "symbol": "AMD", "name": "Armenian Dram"}, - {"id": 2782, "symbol": "AUD", "name": "Australian Dollar"}, - {"id": 3528, "symbol": "AZN", "name": "Azerbaijani Manat"}, - {"id": 3531, "symbol": "BHD", "name": "Bahraini Dinar"}, - {"id": 3530, "symbol": "BDT", "name": "Bangladeshi Taka"}, - {"id": 3533, "symbol": "BYN", "name": "Belarusian Ruble"}, - {"id": 3532, "symbol": "BMD", "name": "Bermudan Dollar"}, - {"id": 2832, "symbol": "BOB", "name": "Bolivian Boliviano"}, - {"id": 3529, "symbol": "BAM", "name": "Bosnia-Herzegovina Convertible Mark"}, # noqa: E501 - {"id": 2783, "symbol": "BRL", "name": "Brazilian Real"}, - {"id": 2814, "symbol": "BGN", "name": "Bulgarian Lev"}, - {"id": 3549, "symbol": "KHR", "name": "Cambodian Riel"}, - {"id": 2784, "symbol": "CAD", "name": "Canadian Dollar"}, - {"id": 2786, "symbol": "CLP", "name": "Chilean Peso"}, - {"id": 2787, "symbol": "CNY", "name": "Chinese Yuan"}, - {"id": 2820, "symbol": "COP", "name": "Colombian Peso"}, - {"id": 3534, "symbol": "CRC", "name": "Costa Rican Colón"}, - {"id": 2815, "symbol": "HRK", "name": "Croatian Kuna"}, - {"id": 3535, "symbol": "CUP", "name": "Cuban Peso"}, - {"id": 2788, "symbol": "CZK", "name": "Czech Koruna"}, - {"id": 2789, "symbol": "DKK", "name": "Danish Krone"}, - {"id": 3536, "symbol": "DOP", "name": "Dominican Peso"}, - {"id": 3538, "symbol": "EGP", "name": "Egyptian Pound"}, - {"id": 2790, "symbol": "EUR", "name": "Euro"}, - {"id": 3539, "symbol": "GEL", "name": "Georgian Lari"}, - {"id": 3540, "symbol": "GHS", "name": "Ghanaian Cedi"}, - {"id": 3541, "symbol": "GTQ", "name": "Guatemalan Quetzal"}, - {"id": 3542, "symbol": "HNL", "name": "Honduran Lempira"}, - {"id": 2792, "symbol": "HKD", "name": "Hong Kong Dollar"}, - {"id": 2793, "symbol": "HUF", "name": "Hungarian Forint"}, - {"id": 2818, "symbol": "ISK", "name": "Icelandic Króna"}, - {"id": 2796, "symbol": "INR", "name": "Indian Rupee"}, - {"id": 2794, "symbol": "IDR", "name": "Indonesian Rupiah"}, - {"id": 3544, "symbol": "IRR", "name": "Iranian Rial"}, - {"id": 3543, "symbol": "IQD", "name": "Iraqi Dinar"}, - {"id": 2795, "symbol": "ILS", "name": "Israeli New Shekel"}, - {"id": 3545, "symbol": "JMD", "name": "Jamaican Dollar"}, - {"id": 2797, "symbol": "JPY", "name": "Japanese Yen"}, - {"id": 3546, "symbol": "JOD", "name": "Jordanian Dinar"}, - {"id": 3551, "symbol": "KZT", "name": "Kazakhstani Tenge"}, - {"id": 3547, "symbol": "KES", "name": "Kenyan Shilling"}, - {"id": 3550, "symbol": "KWD", "name": "Kuwaiti Dinar"}, - {"id": 3548, "symbol": "KGS", "name": "Kyrgystani Som"}, - {"id": 3552, "symbol": "LBP", "name": "Lebanese Pound"}, - {"id": 3556, "symbol": "MKD", "name": "Macedonian Denar"}, - {"id": 2800, "symbol": "MYR", "name": "Malaysian Ringgit"}, - {"id": 2816, "symbol": "MUR", "name": "Mauritian Rupee"}, - {"id": 2799, "symbol": "MXN", "name": "Mexican Peso"}, - {"id": 3555, "symbol": "MDL", "name": "Moldovan Leu"}, - {"id": 3558, "symbol": "MNT", "name": "Mongolian Tugrik"}, - {"id": 3554, "symbol": "MAD", "name": "Moroccan Dirham"}, - {"id": 3557, "symbol": "MMK", "name": "Myanma Kyat"}, - {"id": 3559, "symbol": "NAD", "name": "Namibian Dollar"}, - {"id": 3561, "symbol": "NPR", "name": "Nepalese Rupee"}, - {"id": 2811, "symbol": "TWD", "name": "New Taiwan Dollar"}, - {"id": 2802, "symbol": "NZD", "name": "New Zealand Dollar"}, - {"id": 3560, "symbol": "NIO", "name": "Nicaraguan Córdoba"}, - {"id": 2819, "symbol": "NGN", "name": "Nigerian Naira"}, - {"id": 2801, "symbol": "NOK", "name": "Norwegian Krone"}, - {"id": 3562, "symbol": "OMR", "name": "Omani Rial"}, - {"id": 2804, "symbol": "PKR", "name": "Pakistani Rupee"}, - {"id": 3563, "symbol": "PAB", "name": "Panamanian Balboa"}, - {"id": 2822, "symbol": "PEN", "name": "Peruvian Sol"}, - {"id": 2803, "symbol": "PHP", "name": "Philippine Peso"}, - {"id": 2805, "symbol": "PLN", "name": "Polish Złoty"}, - {"id": 2791, "symbol": "GBP", "name": "Pound Sterling"}, - {"id": 3564, "symbol": "QAR", "name": "Qatari Rial"}, - {"id": 2817, "symbol": "RON", "name": "Romanian Leu"}, - {"id": 2806, "symbol": "RUB", "name": "Russian Ruble"}, - {"id": 3566, "symbol": "SAR", "name": "Saudi Riyal"}, - {"id": 3565, "symbol": "RSD", "name": "Serbian Dinar"}, - {"id": 2808, "symbol": "SGD", "name": "Singapore Dollar"}, - {"id": 2812, "symbol": "ZAR", "name": "South African Rand"}, - {"id": 2798, "symbol": "KRW", "name": "South Korean Won"}, - {"id": 3567, "symbol": "SSP", "name": "South Sudanese Pound"}, - {"id": 3573, "symbol": "VES", "name": "Sovereign Bolivar"}, - {"id": 3553, "symbol": "LKR", "name": "Sri Lankan Rupee"}, - {"id": 2807, "symbol": "SEK", "name": "Swedish Krona"}, - {"id": 2785, "symbol": "CHF", "name": "Swiss Franc"}, - {"id": 2809, "symbol": "THB", "name": "Thai Baht"}, - {"id": 3569, "symbol": "TTD", "name": "Trinidad and Tobago Dollar"}, - {"id": 3568, "symbol": "TND", "name": "Tunisian Dinar"}, - {"id": 2810, "symbol": "TRY", "name": "Turkish Lira"}, - {"id": 3570, "symbol": "UGX", "name": "Ugandan Shilling"}, - {"id": 2824, "symbol": "UAH", "name": "Ukrainian Hryvnia"}, - {"id": 2813, "symbol": "AED", "name": "United Arab Emirates Dirham"}, - {"id": 3571, "symbol": "UYU", "name": "Uruguayan Peso"}, - {"id": 3572, "symbol": "UZS", "name": "Uzbekistan Som"}, - {"id": 2823, "symbol": "VND", "name": "Vietnamese Dong"}, - ] - metals = [ - {"id": 3575, "symbol": "XAU", "name": "Gold Troy Ounce"}, - {"id": 3574, "symbol": "XAG", "name": "Silver Troy Ounce"}, - {"id": 3577, "symbol": "XPT", "name": "Platinum Ounce"}, - {"id": 3576, "symbol": "XPD", "name": "Palladium Ounce"}, - ] - # fmt: on - - return fiat + metals + crypto - - def _get_json_data(self, url, params={}): - try: - response = self.log_curl(requests.get(url, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - parsed = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if type(parsed) is not dict or "data" not in parsed: - raise exceptions.ResponseParsingError("Unexpected content.") - - elif len(parsed["data"]) == 0: - raise exceptions.ResponseParsingError("Empty data section.") - - return parsed["data"] + fiat_url = "https://web-api.coinmarketcap.com/v1/fiat/map?include_metals=true" + fiat_res = self.log_curl(requests.get(fiat_url)) + fiat = json.loads(fiat_res.content) + crypto_url = ( + "https://web-api.coinmarketcap.com/v1/cryptocurrency/map?sort=cmc_rank" + ) + crypto_res = self.log_curl(requests.get(crypto_url)) + crypto = json.loads(crypto_res.content) + return crypto["data"] + fiat["data"] diff --git a/src/pricehist/sources/exchangeratehost.py b/src/pricehist/sources/exchangeratehost.py deleted file mode 100644 index 76c412a..0000000 --- a/src/pricehist/sources/exchangeratehost.py +++ /dev/null @@ -1,122 +0,0 @@ -import dataclasses -import json -from decimal import Decimal - -import requests - -from pricehist import exceptions -from pricehist.price import Price - -from .basesource import BaseSource - - -class ExchangeRateHost(BaseSource): - def id(self): - return "exchangeratehost" - - def name(self): - return "exchangerate.host Exchange rates API" - - def description(self): - return ( - "Exchange rates API is a simple and lightweight free service for " - "current and historical foreign exchange rates & crypto exchange " - "rates." - ) - - def source_url(self): - return "https://exchangerate.host/" - - def start(self): - return "1999-01-01" - - def types(self): - return ["close"] - - def notes(self): - return "" - - def symbols(self): - url = "https://api.coindesk.com/v1/bpi/supported-currencies.json" - - try: - response = self.log_curl(requests.get(url)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - relevant = [i for i in data if i["currency"] not in ["BTC", "XBT"]] - results = [ - (f"BTC/{i['currency']}", f"Bitcoin against {i['country']}") - for i in sorted(relevant, key=lambda i: i["currency"]) - ] - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - if not results: - raise exceptions.ResponseParsingError("Expected data not found") - else: - return results - - def fetch(self, series): - if series.base != "BTC" or series.quote in ["BTC", "XBT"]: - # BTC is the only valid base. - # BTC as the quote will return BTC/USD, which we don't want. - # XBT as the quote will fail with HTTP status 500. - raise exceptions.InvalidPair(series.base, series.quote, self) - - data = self._data(series) - - prices = [] - for (d, v) in data.get("bpi", {}).items(): - prices.append(Price(d, Decimal(str(v)))) - - return dataclasses.replace(series, prices=prices) - - def _data(self, series): - url = "https://api.coindesk.com/v1/bpi/historical/close.json" - params = { - "currency": series.quote, - "start": series.start, - "end": series.end, - } - - try: - response = self.log_curl(requests.get(url, params=params)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - code = response.status_code - text = response.text - if code == 404 and "currency was not found" in text: - raise exceptions.InvalidPair(series.base, series.quote, self) - elif code == 404 and "only covers data from" in text: - raise exceptions.BadResponse(text) - elif code == 404 and "end date is before" in text and series.end < series.start: - raise exceptions.BadResponse("End date is before start date.") - elif code == 404 and "end date is before" in text: - raise exceptions.BadResponse("The start date must be in the past.") - elif code == 500 and "No results returned from database" in text: - raise exceptions.BadResponse( - "No results returned from database. This can happen when data " - "for a valid quote currency (e.g. CUP) doesn't go all the way " - "back to the start date, and potentially for other reasons." - ) - else: - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - result = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError(str(e)) from e - - return result diff --git a/src/pricehist/sources/yahoo.py b/src/pricehist/sources/yahoo.py index 25d92fe..c0b4d15 100644 --- a/src/pricehist/sources/yahoo.py +++ b/src/pricehist/sources/yahoo.py @@ -1,12 +1,13 @@ +import csv import dataclasses import json import logging -from datetime import datetime, timezone +from datetime import datetime from decimal import Decimal import requests -from pricehist import __version__, exceptions +from pricehist import __version__ from pricehist.price import Price from .basesource import BaseSource @@ -29,10 +30,7 @@ class Yahoo(BaseSource): return "https://finance.yahoo.com/" def start(self): - # The "Download historical data in Yahoo Finance" page says - # "Historical prices usually don't go back earlier than 1970", but - # several do. Examples going back to 1962-01-02 include ED and IBM. - return "1962-01-02" + return "1970-01-01" def types(self): return ["adjclose", "open", "high", "low", "close", "mid"] @@ -57,7 +55,7 @@ class Yahoo(BaseSource): return ( "Find the symbol of interest on https://finance.yahoo.com/ and use " "that as the PAIR in your pricehist command. Prices for each symbol " - "are quoted in its native currency." + "are given in its native currency." ) def symbols(self): @@ -65,109 +63,63 @@ class Yahoo(BaseSource): return [] def fetch(self, series): - if series.quote: - raise exceptions.InvalidPair( - series.base, series.quote, self, "Don't specify the quote currency." - ) + # TODO fail if quote isn't empty - yahoo symbols don't have a slash + spark, history = self._data(series) - data = self._data(series) - quote = data["chart"]["result"][0]["meta"]["currency"] - offset = data["chart"]["result"][0]["meta"]["gmtoffset"] - - timestamps = data["chart"]["result"][0]["timestamp"] - adjclose_data = data["chart"]["result"][0]["indicators"]["adjclose"][0] - rest_data = data["chart"]["result"][0]["indicators"]["quote"][0] - amounts = {**adjclose_data, **rest_data} + output_quote = spark["spark"]["result"][0]["response"][0]["meta"]["currency"] prices = [ - Price(date, amount) - for i in range(len(timestamps)) - if (date := self._ts_to_date(timestamps[i] + offset)) <= series.end - if (amount := self._amount(amounts, series.type, i)) is not None + Price(row["date"], amount) + for row in history + if (amount := self._amount(row, series.type)) ] - return dataclasses.replace(series, quote=quote, prices=prices) + return dataclasses.replace(series, quote=output_quote, prices=prices) - def _ts_to_date(self, ts) -> str: - return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() - - def _amount(self, amounts, type, i): - if type == "mid" and amounts["high"] != "null" and amounts["low"] != "null": - return sum([Decimal(amounts["high"][i]), Decimal(amounts["low"][i])]) / 2 - elif amounts[type] != "null" and amounts[type][i] is not None: - return Decimal(amounts[type][i]) + def _amount(self, row, type): + if type != "mid" and row[type] != "null": + return Decimal(row[type]) + elif type == "mid" and row["high"] != "null" and row["low"] != "null": + return sum([Decimal(row["high"]), Decimal(row["low"])]) / 2 else: return None - def _data(self, series) -> dict: - base_url = "https://query1.finance.yahoo.com/v8/finance/chart" + def _data(self, series) -> (dict, csv.DictReader): + base_url = "https://query1.finance.yahoo.com/v7/finance" headers = {"User-Agent": f"pricehist/{__version__}"} - url = f"{base_url}/{series.base}" - start_ts = int( - datetime.strptime(series.start, "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - .timestamp() + spark_url = f"{base_url}/spark" + spark_params = { + "symbols": series.base, + "range": "1d", + "interval": "1d", + "indicators": "close", + "includeTimestamps": "false", + "includePrePost": "false", + } + spark_response = self.log_curl( + requests.get(spark_url, params=spark_params, headers=headers) ) - end_ts = int( - datetime.strptime(series.end, "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - .timestamp() - ) + ( - 24 * 60 * 60 - ) # some symbols require padding on the end timestamp + spark = json.loads(spark_response.content) - params = { - "symbol": series.base, + start_ts = int(datetime.strptime(series.start, "%Y-%m-%d").timestamp()) + end_ts = int(datetime.strptime(series.end, "%Y-%m-%d").timestamp()) + ( + 24 * 60 * 60 + ) # round up to include the last day + + history_url = f"{base_url}/download/{series.base}" + history_params = { "period1": start_ts, "period2": end_ts, "interval": "1d", - "events": "capitalGain%7Cdiv%7Csplit", + "events": "history", "includeAdjustedClose": "true", - "formatted": "true", - "userYfid": "true", - "lang": "en-US", - "region": "US", } + history_response = self.log_curl( + requests.get(history_url, params=history_params, headers=headers) + ) + history_lines = history_response.content.decode("utf-8").splitlines() + history_lines[0] = history_lines[0].lower().replace(" ", "") + history = csv.DictReader(history_lines, delimiter=",") - try: - response = self.log_curl(requests.get(url, params=params, headers=headers)) - except Exception as e: - raise exceptions.RequestError(str(e)) from e - - code = response.status_code - text = response.text - - if code == 404 and "No data found, symbol may be delisted" in text: - raise exceptions.InvalidPair( - series.base, series.quote, self, "Symbol not found." - ) - elif code == 400 and "Data doesn't exist" in text: - raise exceptions.BadResponse( - "No data for the given interval. Try requesting a larger interval." - ) - elif code == 404 and "Timestamp data missing" in text: - raise exceptions.BadResponse( - "Data missing. The given interval may be for a gap in the data " - "such as a weekend or holiday. Try requesting a larger interval." - ) - - try: - response.raise_for_status() - except Exception as e: - raise exceptions.BadResponse(str(e)) from e - - try: - data = json.loads(response.content) - except Exception as e: - raise exceptions.ResponseParsingError( - "The data couldn't be parsed. " - ) from e - - if "timestamp" not in data["chart"]["result"][0]: - raise exceptions.BadResponse( - "No data for the given interval. " - "There may be a problem with the symbol or the interval." - ) - - return data + return (spark, history) diff --git a/tests/live.sh b/tests/live.sh deleted file mode 100755 index 157cdad..0000000 --- a/tests/live.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/bin/bash - -# These are basic happy path tests that run pricehist from the command line and -# confirm that the results come out as expected. They help ensure that the main -# endpoints for each source are still working. - -# Run this from the project root. - -export ALPHAVANTAGE_API_KEY="TEST_KEY_$RANDOM" -cmd_prefix="poetry run" - -passed=0 -failed=0 -skipped=0 - -run_test(){ - name=$1 - cmd=$2 - expected=$3 - echo "TEST: $name" - echo " Action: $cmd" - echo -n " Result: " - full_cmd="$cmd_prefix $cmd" - actual=$($full_cmd 2>&1) - if [[ "$actual" == "$expected" ]]; then - passed=$((passed+1)) - echo "passed, output as expected" - else - failed=$((failed+1)) - echo "failed, output differs as follows..." - echo - diff <(echo "$expected") <(echo "$actual") - fi - echo -} - -skip_test(){ - name=$1 - cmd=$2 - echo "TEST: $name" - echo " Action: $cmd" - echo " Result: SKIPPED!" - skipped=$((skipped+1)) - echo -} - -report(){ - total=$((passed+failed)) - if [[ "$skipped" -eq "0" ]]; then - skipped_str="none" - else - skipped_str="$skipped" - fi - if [[ "$failed" -eq "0" ]]; then - echo "SUMMARY: $passed tests passed, none failed, $skipped_str skipped" - else - echo "SUMMARY: $failed/$total tests failed, $skipped_str skipped" - exit 1 - fi -} - -name="Alpha Vantage stocks" -cmd="pricehist fetch alphavantage TSLA -s 2021-01-04 -e 2021-01-08" -read -r -d '' expected < 0 - - assert isinstance(src.name(), str) - assert len(src.name()) > 0 - - assert isinstance(src.description(), str) - assert len(src.description()) > 0 - - assert isinstance(src.source_url(), str) - assert src.source_url().startswith("http") - - assert datetime.strptime(src.start(), "%Y-%m-%d") - - assert isinstance(src.types(), list) - assert len(src.types()) > 0 - assert isinstance(src.types()[0], str) - assert len(src.types()[0]) > 0 - - assert isinstance(src.notes(), str) - - -def test_symbols_stock_message(src, physical_list_ok, digital_list_ok, caplog): - with caplog.at_level(logging.INFO): - src.symbols() - assert any(["Stock symbols can be discovered" in r.message for r in caplog.records]) - - -def test_symbols(src, physical_list_ok, digital_list_ok): - syms = src.symbols() - assert ("BTC", "Digital: Bitcoin") in syms - assert ("AUD", "Physical: Australian Dollar") in syms - assert len(syms) > 2 - - -def test_symbols_digital_network_issue(src, requests_mock): - requests_mock.add( - responses.GET, - digital_list_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.symbols() - assert "Network issue" in str(e.value) - - -def test_symbols_digital_bad_status(src, requests_mock): - requests_mock.add(responses.GET, digital_list_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.symbols() - assert "Server Error" in str(e.value) - - -def test_symbols_digital_no_data(src, requests_mock): - requests_mock.add(responses.GET, digital_list_url, body="NOT CSV", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "Symbols data missing." in str(e.value) - - -def test_symbols_digital_bad_data(src, requests_mock): - requests_mock.add(responses.GET, digital_list_url, body="A,B,C\na,b,c", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "too many values" in str(e.value) - - -def test_symbols_physical_network_issue(src, digital_list_ok, requests_mock): - requests_mock.add( - responses.GET, - physical_list_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.symbols() - assert "Network issue" in str(e.value) - - -def test_symbols_physical_bad_status(src, digital_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_list_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.symbols() - assert "Server Error" in str(e.value) - - -def test_symbols_physical_no_data(src, digital_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_list_url, body="", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "Symbols data missing." in str(e.value) - - -def test_search(src, search_ok): - results = src.search("IBM") - req = search_ok.calls[0].request - assert req.params["function"] == "SYMBOL_SEARCH" - assert req.params["keywords"] == "IBM" - assert len(req.params["apikey"]) > 0 - assert len(results) == 10 - for expected in [ - ("IBM", "International Business Machines Corp, Equity, United States, USD"), - ("IBMJ", "iShares iBonds Dec 2021 Term Muni Bond ETF, ETF, United States, USD"), - ("IBMK", "iShares iBonds Dec 2022 Term Muni Bond ETF, ETF, United States, USD"), - ("IBM.DEX", "International Business Machines Corporation, Equity, XETRA, EUR"), - ]: - assert expected in results - - -def test_search_network_issue(src, requests_mock): - requests_mock.add( - responses.GET, - search_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.search("IBM") - assert "Network issue" in str(e.value) - - -def test_search_bad_status(src, requests_mock): - requests_mock.add(responses.GET, search_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.search("IBM") - assert "Server Error" in str(e.value) - - -def test_search_bad_data(src, requests_mock): - requests_mock.add(responses.GET, search_url, body="NOT JSON", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.search("IBM") - assert "while parsing data" in str(e.value) - - -def test_search_bad_json(src, requests_mock): - requests_mock.add(responses.GET, search_url, body="{}", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.search("IBM") - assert "Unexpected content." in str(e.value) - - -def test_search_bad_json_tricky(src, requests_mock): - requests_mock.add( - responses.GET, search_url, body='{"bestMatches": [{}]}', status=200 - ) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.search("IBM") - assert "Unexpected content." in str(e.value) - - -def test_search_rate_limit(src, type, requests_mock): - requests_mock.add(responses.GET, search_url, body=rate_limit_json) - with pytest.raises(exceptions.RateLimit) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "rate limit" in str(e.value) - - -def test_fetch_stock_known(src, type, search_ok, ibm_ok): - series = src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - search_req = search_ok.calls[0].request - stock_req = ibm_ok.calls[1].request - assert search_req.params["function"] == "SYMBOL_SEARCH" - assert search_req.params["keywords"] == "IBM" - assert stock_req.params["function"] == "TIME_SERIES_DAILY" - assert stock_req.params["symbol"] == "IBM" - assert stock_req.params["outputsize"] == "full" - assert (series.base, series.quote) == ("IBM", "USD") - assert len(series.prices) == 5 - assert series.prices[0] == Price("2021-01-04", Decimal("123.94")) - assert series.prices[-1] == Price("2021-01-08", Decimal("128.53")) - - -def test_fetch_stock_compact_if_recent(src, type, search_ok, ibm_ok): - today = datetime.now().date() - start = (today - timedelta(days=30)).isoformat() - end = today.isoformat() - src.fetch(Series("IBM", "", type, start, end)) - stock_req = ibm_ok.calls[1].request - assert stock_req.params["outputsize"] == "compact" - - -def test_fetch_stock_requests_logged(src, type, search_ok, ibm_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - logged_requests = 0 - for r in caplog.records: - if r.levelname == "DEBUG" and "curl " in r.message: - logged_requests += 1 - assert logged_requests == 2 - - -def test_fetch_stock_types_all_available(src, search_ok, ibm_ok): - cls = src.fetch(Series("IBM", "", "close", "2021-01-04", "2021-01-08")) - opn = src.fetch(Series("IBM", "", "open", "2021-01-04", "2021-01-08")) - hgh = src.fetch(Series("IBM", "", "high", "2021-01-04", "2021-01-08")) - low = src.fetch(Series("IBM", "", "low", "2021-01-04", "2021-01-08")) - mid = src.fetch(Series("IBM", "", "mid", "2021-01-04", "2021-01-08")) - assert cls.prices[0].amount == Decimal("123.94") - assert opn.prices[0].amount == Decimal("125.85") - assert hgh.prices[0].amount == Decimal("125.9174") - assert low.prices[0].amount == Decimal("123.04") - assert mid.prices[0].amount == Decimal("124.4787") - - -def test_fetch_stock_types_adj_available(src, search_ok, ibm_adj_ok): - adj = src.fetch(Series("IBM", "", "adjclose", "2021-01-04", "2021-01-08")) - assert adj.prices[0].amount == Decimal("120.943645029") - - -def test_fetch_stock_type_mid_is_mean_of_low_and_high(src, search_ok, ibm_ok): - hgh = src.fetch(Series("IBM", "", "high", "2021-01-04", "2021-01-08")).prices - low = src.fetch(Series("IBM", "", "low", "2021-01-04", "2021-01-08")).prices - mid = src.fetch(Series("IBM", "", "mid", "2021-01-04", "2021-01-08")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 5) - ] - ) - - -def test_fetch_stock_bad_sym(src, type, search_not_found, requests_mock): - requests_mock.add( - responses.GET, - stock_url, - status=200, - body="""{ - "Error Message": "Invalid API call. Please retry or..." - }""", - ) - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("NOTASTOCK", "", type, "2021-01-04", "2021-01-08")) - assert "Unknown stock symbol" in str(e.value) - - -def test_fetch_stock_quote_found_prices_error(src, type, search_ok, requests_mock): - requests_mock.add( - responses.GET, - stock_url, - status=200, - body="""{ - "Error Message": "Invalid API call. Please retry or..." - }""", - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "bad response" in str(e.value) - - -def test_fetch_stock_network_issue(src, type, search_ok, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, stock_url, body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "Network issue" in str(e.value) - - -def test_fetch_stock_bad_status(src, type, search_ok, requests_mock): - requests_mock.add(responses.GET, stock_url, status=500, body="Some other reason") - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_stock_parsing_error(src, type, search_ok, requests_mock): - requests_mock.add(responses.GET, stock_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "while parsing data" in str(e.value) - - -def test_fetch_stock_unexpected_json(src, type, search_ok, requests_mock): - requests_mock.add(responses.GET, stock_url, body='{"notdata": []}') - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "Unexpected content" in str(e.value) - - -def test_fetch_stock_rate_limit(src, type, search_ok, requests_mock): - requests_mock.add(responses.GET, stock_url, body=rate_limit_json) - with pytest.raises(exceptions.RateLimit) as e: - src.fetch(Series("IBM", "", type, "2021-01-04", "2021-01-08")) - assert "rate limit" in str(e.value) - - -def test_fetch_stock_premium(src, search_ok, requests_mock): - requests_mock.add(responses.GET, adj_stock_url, body=premium_json) - with pytest.raises(exceptions.CredentialsError) as e: - src.fetch(Series("IBM", "", "adjclose", "2021-01-04", "2021-01-08")) - assert "denied access to a premium endpoint" in str(e.value) - - -def test_fetch_physical_known(src, type, physical_list_ok, euraud_ok): - series = src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - req = euraud_ok.calls[1].request - assert req.params["function"] == "FX_DAILY" - assert req.params["from_symbol"] == "EUR" - assert req.params["to_symbol"] == "AUD" - assert req.params["outputsize"] == "full" - assert (series.base, series.quote) == ("EUR", "AUD") - assert len(series.prices) == 5 - assert series.prices[0] == Price("2021-01-04", Decimal("1.59718")) - assert series.prices[-1] == Price("2021-01-08", Decimal("1.57350")) - - -def test_fetch_physical_compact_if_recent(src, type, physical_list_ok, euraud_ok): - today = datetime.now().date() - start = (today - timedelta(days=30)).isoformat() - end = today.isoformat() - src.fetch(Series("EUR", "AUD", type, start, end)) - req = euraud_ok.calls[1].request - assert req.params["outputsize"] == "compact" - - -def test_fetch_physical_requests_logged(src, type, physical_list_ok, euraud_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - logged_requests = 0 - for r in caplog.records: - if r.levelname == "DEBUG" and "curl " in r.message: - logged_requests += 1 - assert logged_requests == 2 - - -def test_fetch_physical_types_but_adjclose_available(src, physical_list_ok, euraud_ok): - cls = src.fetch(Series("EUR", "AUD", "close", "2021-01-04", "2021-01-08")) - opn = src.fetch(Series("EUR", "AUD", "open", "2021-01-04", "2021-01-08")) - hgh = src.fetch(Series("EUR", "AUD", "high", "2021-01-04", "2021-01-08")) - low = src.fetch(Series("EUR", "AUD", "low", "2021-01-04", "2021-01-08")) - mid = src.fetch(Series("EUR", "AUD", "mid", "2021-01-04", "2021-01-08")) - assert cls.prices[0].amount == Decimal("1.59718") - assert opn.prices[0].amount == Decimal("1.58741") - assert hgh.prices[0].amount == Decimal("1.60296") - assert low.prices[0].amount == Decimal("1.58550") - assert mid.prices[0].amount == Decimal("1.59423") - - -def test_fetch_physical_adjclose_not_available(src): - with pytest.raises(exceptions.InvalidType) as e: - src.fetch(Series("EUR", "AUD", "adjclose", "2021-01-04", "2021-01-08")) - assert "Invalid price type 'adjclose' for pair 'EUR/AUD'." in str(e) - - -def test_fetch_physical_type_mid_is_mean_of_low_and_high( - src, physical_list_ok, euraud_ok -): - hgh = src.fetch(Series("EUR", "AUD", "high", "2021-01-04", "2021-01-08")).prices - low = src.fetch(Series("EUR", "AUD", "low", "2021-01-04", "2021-01-08")).prices - mid = src.fetch(Series("EUR", "AUD", "mid", "2021-01-04", "2021-01-08")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 5) - ] - ) - - -def test_fetch_physical_bad_sym(src, type, physical_list_ok, digital_list_ok): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("NOTPHYSICAL", "AUD", type, "2021-01-04", "2021-01-08")) - assert "base must be a known physical or digital currency" in str(e.value) - - -def test_fetch_physical_network_issue(src, type, physical_list_ok, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, physical_url, body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Network issue" in str(e.value) - - -def test_fetch_physical_bad_status(src, type, physical_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_url, status=500, body="Some other reason") - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_physical_parsing_error(src, type, physical_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "while parsing data" in str(e.value) - - -def test_fetch_physical_unexpected_json(src, type, physical_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_url, body='{"notdata": []}') - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Unexpected content" in str(e.value) - - -def test_fetch_physical_rate_limit(src, type, physical_list_ok, requests_mock): - requests_mock.add(responses.GET, physical_url, body=rate_limit_json) - with pytest.raises(exceptions.RateLimit) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "rate limit" in str(e.value) - - -def test_fetch_digital_known(src, type, physical_list_ok, digital_list_ok, btcaud_ok): - series = src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - req = btcaud_ok.calls[2].request - assert req.params["function"] == "DIGITAL_CURRENCY_DAILY" - assert req.params["symbol"] == "BTC" - assert req.params["market"] == "AUD" - assert (series.base, series.quote) == ("BTC", "AUD") - assert len(series.prices) == 5 - assert series.prices[0] == Price("2021-01-04", Decimal("43406.76014740")) - assert series.prices[-1] == Price("2021-01-08", Decimal("55068.43820140")) - - -def test_fetch_digital_requests_logged( - src, type, physical_list_ok, digital_list_ok, btcaud_ok, caplog -): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - logged_requests = 0 - for r in caplog.records: - if r.levelname == "DEBUG" and "curl " in r.message: - logged_requests += 1 - assert logged_requests == 3 - - -def test_fetch_digital_types_but_adjclose_available( - src, physical_list_ok, digital_list_ok, btcaud_ok -): - cls = src.fetch(Series("BTC", "AUD", "close", "2021-01-04", "2021-01-08")) - opn = src.fetch(Series("BTC", "AUD", "open", "2021-01-04", "2021-01-08")) - hgh = src.fetch(Series("BTC", "AUD", "high", "2021-01-04", "2021-01-08")) - low = src.fetch(Series("BTC", "AUD", "low", "2021-01-04", "2021-01-08")) - mid = src.fetch(Series("BTC", "AUD", "mid", "2021-01-04", "2021-01-08")) - assert cls.prices[0].amount == Decimal("43406.76014740") - assert opn.prices[0].amount == Decimal("44779.08784700") - assert hgh.prices[0].amount == Decimal("45593.18400000") - assert low.prices[0].amount == Decimal("38170.72220000") - assert mid.prices[0].amount == Decimal("41881.95310000") - - -def test_fetch_digital_adjclose_not_available(src): - with pytest.raises(exceptions.InvalidType) as e: - src.fetch(Series("BTC", "AUD", "adjclose", "2021-01-04", "2021-01-08")) - assert "Invalid price type 'adjclose' for pair 'BTC/AUD'." in str(e.value) - - -def test_fetch_digital_type_mid_is_mean_of_low_and_high( - src, physical_list_ok, digital_list_ok, btcaud_ok -): - hgh = src.fetch(Series("BTC", "AUD", "high", "2021-01-04", "2021-01-08")).prices - low = src.fetch(Series("BTC", "AUD", "low", "2021-01-04", "2021-01-08")).prices - mid = src.fetch(Series("BTC", "AUD", "mid", "2021-01-04", "2021-01-08")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 5) - ] - ) - - -def test_fetch_digital_bad_sym(src, type, physical_list_ok, digital_list_ok): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("NOTDIGITAL", "AUD", type, "2021-01-04", "2021-01-08")) - assert "base must be a known physical or digital currency" in str(e.value) - - -def test_fetch_digital_network_issue( - src, type, physical_list_ok, digital_list_ok, requests_mock -): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, digital_url, body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Network issue" in str(e.value) - - -def test_fetch_digital_bad_status( - src, type, physical_list_ok, digital_list_ok, requests_mock -): - requests_mock.add(responses.GET, digital_url, status=500, body="Some other reason") - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_digital_parsing_error( - src, type, physical_list_ok, digital_list_ok, requests_mock -): - requests_mock.add(responses.GET, digital_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - assert "while parsing data" in str(e.value) - - -def test_fetch_digital_unexpected_json( - src, type, physical_list_ok, digital_list_ok, requests_mock -): - requests_mock.add(responses.GET, digital_url, body='{"notdata": []}') - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - assert "Unexpected content" in str(e.value) - - -def test_fetch_digital_rate_limit( - src, type, physical_list_ok, digital_list_ok, requests_mock -): - requests_mock.add(responses.GET, digital_url, body=rate_limit_json) - with pytest.raises(exceptions.RateLimit) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-04", "2021-01-08")) - assert "rate limit" in str(e.value) - - -def test_fetch_bad_pair_quote_non_physical(src, type, physical_list_ok): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("EUR", "BTC", type, "2021-01-04", "2021-01-08")) - assert "quote must be a physical currency" in str(e.value) - - -def test_fetch_api_key_defaults_to_generic( - src, type, physical_list_ok, euraud_ok, monkeypatch -): - monkeypatch.delenv(api_key_name) - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - req = euraud_ok.calls[-1].request - assert req.params["apikey"] == f"pricehist_{__version__}" - - -def test_fetch_api_key_invalid(src, type, physical_list_ok, requests_mock): - body = ( - '{ "Error Message": "the parameter apikey is invalid or missing. Please ' - "claim your free API key on (https://www.alphavantage.co/support/#api-key). " - 'It should take less than 20 seconds." }' - ) - requests_mock.add(responses.GET, physical_url, body=body) - with pytest.raises(exceptions.CredentialsError) as e: - src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) - assert "unavailable or invalid" in str(e.value) diff --git a/tests/pricehist/sources/test_alphavantage/btc-aud-partial.json b/tests/pricehist/sources/test_alphavantage/btc-aud-partial.json deleted file mode 100644 index 737658f..0000000 --- a/tests/pricehist/sources/test_alphavantage/btc-aud-partial.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "Meta Data": { - "1. Information": "Daily Prices and Volumes for Digital Currency", - "2. Digital Currency Code": "BTC", - "3. Digital Currency Name": "Bitcoin", - "4. Market Code": "AUD", - "5. Market Name": "Australian Dollar", - "6. Last Refreshed": "2021-07-28 00:00:00", - "7. Time Zone": "UTC" - }, - "Time Series (Digital Currency Daily)": { - "2021-01-09": { - "1. open": "55074.06950240", - "2. high": "56150.17720000", - "3. low": "52540.71680000", - "4. close": "54397.30924680", - "5. volume": "75785.97967500" - }, - "2021-01-08": { - "1. open": "53507.50941120", - "2. high": "56923.63300000", - "3. low": "49528.31000000", - "4. close": "55068.43820140", - "5. volume": "139789.95749900" - }, - "2021-01-07": { - "1. open": "49893.81535840", - "2. high": "54772.88310000", - "3. low": "49256.92200000", - "4. close": "53507.23802320", - "5. volume": "132825.70043700" - }, - "2021-01-06": { - "1. open": "46067.47523820", - "2. high": "50124.29161740", - "3. low": "45169.81872000", - "4. close": "49893.81535840", - "5. volume": "127139.20131000" - }, - "2021-01-05": { - "1. open": "43408.17136500", - "2. high": "46624.45840000", - "3. low": "40572.50600000", - "4. close": "46067.47523820", - "5. volume": "116049.99703800" - }, - "2021-01-04": { - "1. open": "44779.08784700", - "2. high": "45593.18400000", - "3. low": "38170.72220000", - "4. close": "43406.76014740", - "5. volume": "140899.88569000" - }, - "2021-01-03": { - "1. open": "43661.51206300", - "2. high": "47191.80858340", - "3. low": "43371.85965060", - "4. close": "44779.08784700", - "5. volume": "120957.56675000" - } - } -} diff --git a/tests/pricehist/sources/test_alphavantage/digital-partial.csv b/tests/pricehist/sources/test_alphavantage/digital-partial.csv deleted file mode 100644 index dd72cce..0000000 --- a/tests/pricehist/sources/test_alphavantage/digital-partial.csv +++ /dev/null @@ -1,3 +0,0 @@ -currency code,currency name -BTC,Bitcoin -ETH,Ethereum diff --git a/tests/pricehist/sources/test_alphavantage/eur-aud-partial.json b/tests/pricehist/sources/test_alphavantage/eur-aud-partial.json deleted file mode 100644 index 36bfa85..0000000 --- a/tests/pricehist/sources/test_alphavantage/eur-aud-partial.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "Meta Data": { - "1. Information": "Forex Daily Prices (open, high, low, close)", - "2. From Symbol": "EUR", - "3. To Symbol": "AUD", - "4. Output Size": "Full size", - "5. Last Refreshed": "2021-07-27 11:35:00", - "6. Time Zone": "UTC" - }, - "Time Series FX (Daily)": { - "2021-01-11": { - "1. open": "1.57496", - "2. high": "1.58318", - "3. low": "1.57290", - "4. close": "1.57823" - }, - "2021-01-08": { - "1. open": "1.57879", - "2. high": "1.58140", - "3. low": "1.57177", - "4. close": "1.57350" - }, - "2021-01-07": { - "1. open": "1.57901", - "2. high": "1.58650", - "3. low": "1.57757", - "4. close": "1.57893" - }, - "2021-01-06": { - "1. open": "1.58390", - "2. high": "1.58800", - "3. low": "1.57640", - "4. close": "1.57932" - }, - "2021-01-05": { - "1. open": "1.59698", - "2. high": "1.59886", - "3. low": "1.58100", - "4. close": "1.58389" - }, - "2021-01-04": { - "1. open": "1.58741", - "2. high": "1.60296", - "3. low": "1.58550", - "4. close": "1.59718" - }, - "2021-01-01": { - "1. open": "1.58730", - "2. high": "1.58730", - "3. low": "1.58504", - "4. close": "1.58668" - }, - "2020-12-31": { - "1. open": "1.59946", - "2. high": "1.60138", - "3. low": "1.58230", - "4. close": "1.58730" - } - } -} diff --git a/tests/pricehist/sources/test_alphavantage/ibm-partial-adj.json b/tests/pricehist/sources/test_alphavantage/ibm-partial-adj.json deleted file mode 100644 index 7329475..0000000 --- a/tests/pricehist/sources/test_alphavantage/ibm-partial-adj.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "Meta Data": { - "1. Information": "Daily Time Series with Splits and Dividend Events", - "2. Symbol": "IBM", - "3. Last Refreshed": "2021-07-20", - "4. Output Size": "Full size", - "5. Time Zone": "US/Eastern" - }, - "Time Series (Daily)": { - "2021-01-11": { - "1. open": "127.95", - "2. high": "129.675", - "3. low": "127.66", - "4. close": "128.58", - "5. adjusted close": "125.471469081", - "6. volume": "5602466", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2021-01-08": { - "1. open": "128.57", - "2. high": "129.32", - "3. low": "126.98", - "4. close": "128.53", - "5. adjusted close": "125.422677873", - "6. volume": "4676487", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2021-01-07": { - "1. open": "130.04", - "2. high": "130.46", - "3. low": "128.26", - "4. close": "128.99", - "5. adjusted close": "125.871556982", - "6. volume": "4507382", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2021-01-06": { - "1. open": "126.9", - "2. high": "131.88", - "3. low": "126.72", - "4. close": "129.29", - "5. adjusted close": "126.164304226", - "6. volume": "7956740", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2021-01-05": { - "1. open": "125.01", - "2. high": "126.68", - "3. low": "124.61", - "4. close": "126.14", - "5. adjusted close": "123.090458157", - "6. volume": "6114619", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2021-01-04": { - "1. open": "125.85", - "2. high": "125.9174", - "3. low": "123.04", - "4. close": "123.94", - "5. adjusted close": "120.943645029", - "6. volume": "5179161", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - }, - "2020-12-31": { - "1. open": "124.22", - "2. high": "126.03", - "3. low": "123.99", - "4. close": "125.88", - "5. adjusted close": "122.836743878", - "6. volume": "3574696", - "7. dividend amount": "0.0000", - "8. split coefficient": "1.0" - } - } -} diff --git a/tests/pricehist/sources/test_alphavantage/ibm-partial.json b/tests/pricehist/sources/test_alphavantage/ibm-partial.json deleted file mode 100644 index 8a1ce0b..0000000 --- a/tests/pricehist/sources/test_alphavantage/ibm-partial.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "Meta Data": { - "1. Information": "Daily Time Series with Splits and Dividend Events", - "2. Symbol": "IBM", - "3. Last Refreshed": "2021-07-20", - "4. Output Size": "Full size", - "5. Time Zone": "US/Eastern" - }, - "Time Series (Daily)": { - "2021-01-11": { - "1. open": "127.95", - "2. high": "129.675", - "3. low": "127.66", - "4. close": "128.58" - }, - "2021-01-08": { - "1. open": "128.57", - "2. high": "129.32", - "3. low": "126.98", - "4. close": "128.53" - }, - "2021-01-07": { - "1. open": "130.04", - "2. high": "130.46", - "3. low": "128.26", - "4. close": "128.99" - }, - "2021-01-06": { - "1. open": "126.9", - "2. high": "131.88", - "3. low": "126.72", - "4. close": "129.29" - }, - "2021-01-05": { - "1. open": "125.01", - "2. high": "126.68", - "3. low": "124.61", - "4. close": "126.14" - }, - "2021-01-04": { - "1. open": "125.85", - "2. high": "125.9174", - "3. low": "123.04", - "4. close": "123.94" - }, - "2020-12-31": { - "1. open": "124.22", - "2. high": "126.03", - "3. low": "123.99", - "4. close": "125.88" - } - } -} diff --git a/tests/pricehist/sources/test_alphavantage/physical-partial.csv b/tests/pricehist/sources/test_alphavantage/physical-partial.csv deleted file mode 100644 index 57460d3..0000000 --- a/tests/pricehist/sources/test_alphavantage/physical-partial.csv +++ /dev/null @@ -1,4 +0,0 @@ -currency code,currency name -AUD,Australian Dollar -EUR,Euro -USD,United States Dollar diff --git a/tests/pricehist/sources/test_alphavantage/search-ibm.json b/tests/pricehist/sources/test_alphavantage/search-ibm.json deleted file mode 100644 index 97e34b3..0000000 --- a/tests/pricehist/sources/test_alphavantage/search-ibm.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "bestMatches": [ - { - "1. symbol": "IBM", - "2. name": "International Business Machines Corp", - "3. type": "Equity", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "1.0000" - }, - { - "1. symbol": "IBMJ", - "2. name": "iShares iBonds Dec 2021 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBMK", - "2. name": "iShares iBonds Dec 2022 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBML", - "2. name": "iShares iBonds Dec 2023 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBMM", - "2. name": "iShares iBonds Dec 2024 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBMN", - "2. name": "iShares iBonds Dec 2025 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBMO", - "2. name": "iShares iBonds Dec 2026 Term Muni Bond ETF", - "3. type": "ETF", - "4. region": "United States", - "5. marketOpen": "09:30", - "6. marketClose": "16:00", - "7. timezone": "UTC-04", - "8. currency": "USD", - "9. matchScore": "0.8571" - }, - { - "1. symbol": "IBM.FRK", - "2. name": "International Business Machines Corporation", - "3. type": "Equity", - "4. region": "Frankfurt", - "5. marketOpen": "08:00", - "6. marketClose": "20:00", - "7. timezone": "UTC+02", - "8. currency": "EUR", - "9. matchScore": "0.7500" - }, - { - "1. symbol": "IBM.LON", - "2. name": "International Business Machines Corporation", - "3. type": "Equity", - "4. region": "United Kingdom", - "5. marketOpen": "08:00", - "6. marketClose": "16:30", - "7. timezone": "UTC+01", - "8. currency": "USD", - "9. matchScore": "0.7500" - }, - { - "1. symbol": "IBM.DEX", - "2. name": "International Business Machines Corporation", - "3. type": "Equity", - "4. region": "XETRA", - "5. marketOpen": "08:00", - "6. marketClose": "20:00", - "7. timezone": "UTC+02", - "8. currency": "EUR", - "9. matchScore": "0.6667" - } - ] -} diff --git a/tests/pricehist/sources/test_bankofcanada.py b/tests/pricehist/sources/test_bankofcanada.py deleted file mode 100644 index fde654f..0000000 --- a/tests/pricehist/sources/test_bankofcanada.py +++ /dev/null @@ -1,246 +0,0 @@ -import logging -import os -from datetime import datetime -from decimal import Decimal -from pathlib import Path - -import pytest -import requests -import responses - -from pricehist import exceptions -from pricehist.price import Price -from pricehist.series import Series -from pricehist.sources.bankofcanada import BankOfCanada - - -@pytest.fixture -def src(): - return BankOfCanada() - - -@pytest.fixture -def type(src): - return src.types()[0] - - -@pytest.fixture -def requests_mock(): - with responses.RequestsMock() as mock: - yield mock - - -@pytest.fixture -def series_list_url(): - return "https://www.bankofcanada.ca/valet/lists/series/json" - - -def fetch_url(series_name): - return f"https://www.bankofcanada.ca/valet/observations/{series_name}/json" - - -@pytest.fixture -def series_list_json(): - dir = Path(os.path.splitext(__file__)[0]) - return (dir / "series-partial.json").read_text() - - -@pytest.fixture -def series_list_response_ok(requests_mock, series_list_url, series_list_json): - requests_mock.add(responses.GET, series_list_url, body=series_list_json, status=200) - yield requests_mock - - -@pytest.fixture -def recent_response_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "recent.json").read_text() - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), body=json, status=200) - yield requests_mock - - -@pytest.fixture -def all_response_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "all-partial.json").read_text() - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), body=json, status=200) - yield requests_mock - - -def test_normalizesymbol(src): - assert src.normalizesymbol("cad") == "CAD" - assert src.normalizesymbol("usd") == "USD" - - -def test_metadata(src): - assert isinstance(src.id(), str) - assert len(src.id()) > 0 - - assert isinstance(src.name(), str) - assert len(src.name()) > 0 - - assert isinstance(src.description(), str) - assert len(src.description()) > 0 - - assert isinstance(src.source_url(), str) - assert src.source_url().startswith("http") - - assert datetime.strptime(src.start(), "%Y-%m-%d") - - assert isinstance(src.types(), list) - assert len(src.types()) > 0 - assert isinstance(src.types()[0], str) - assert len(src.types()[0]) > 0 - - assert isinstance(src.notes(), str) - - -def test_symbols(src, series_list_response_ok): - syms = src.symbols() - assert ("CAD/USD", "Canadian dollar to US dollar daily exchange rate") in syms - assert len(syms) > 3 - - -def test_symbols_requests_logged(src, series_list_response_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.symbols() - assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] - ) - - -def test_symbols_not_found(src, requests_mock, series_list_url): - requests_mock.add(responses.GET, series_list_url, body='{"series":{}}', status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "data not found" in str(e.value) - - -def test_symbols_network_issue(src, requests_mock, series_list_url): - requests_mock.add( - responses.GET, - series_list_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.symbols() - assert "Network issue" in str(e.value) - - -def test_symbols_bad_status(src, requests_mock, series_list_url): - requests_mock.add(responses.GET, series_list_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.symbols() - assert "Server Error" in str(e.value) - - -def test_symbols_parsing_error(src, requests_mock, series_list_url): - requests_mock.add(responses.GET, series_list_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "while parsing data" in str(e.value) - - -def test_fetch_known_pair(src, type, recent_response_ok): - series = src.fetch(Series("CAD", "USD", type, "2021-01-01", "2021-01-07")) - req = recent_response_ok.calls[0].request - assert req.params["order_dir"] == "asc" - assert req.params["start_date"] == "2021-01-01" - assert req.params["end_date"] == "2021-01-07" - assert series.prices[0] == Price("2021-01-04", Decimal("0.7843")) - assert series.prices[-1] == Price("2021-01-07", Decimal("0.7870")) - assert len(series.prices) == 4 - - -def test_fetch_requests_logged(src, type, recent_response_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("CAD", "USD", type, "2021-01-01", "2021-01-07")) - assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] - ) - - -def test_fetch_long_hist_from_start(src, type, all_response_ok): - series = src.fetch(Series("CAD", "USD", type, src.start(), "2021-01-07")) - assert series.prices[0] == Price("2017-01-03", Decimal("0.7443")) - assert series.prices[-1] == Price("2021-01-07", Decimal("0.7870")) - assert len(series.prices) > 13 - - -def test_fetch_from_before_start(src, type, requests_mock): - body = """{ "observations": [] }""" - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), status=200, body=body) - series = src.fetch(Series("CAD", "USD", type, "2000-01-01", "2017-01-01")) - assert len(series.prices) == 0 - - -def test_fetch_to_future(src, type, all_response_ok): - series = src.fetch(Series("CAD", "USD", type, "2021-01-01", "2100-01-01")) - assert len(series.prices) > 0 - - -def test_wrong_dates_order(src, type, requests_mock): - body = """{ "message": "The End date must be greater than the Start date." }""" - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), status=400, body=body) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("CAD", "USD", type, "2021-01-07", "2021-01-01")) - assert "End date must be greater" in str(e.value) - - -def test_fetch_in_future(src, type, requests_mock): - body = """{ "observations": [] }""" - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), status=200, body=body) - series = src.fetch(Series("CAD", "USD", type, "2030-01-01", "2030-01-07")) - assert len(series.prices) == 0 - - -def test_fetch_empty(src, type, requests_mock): - requests_mock.add( - responses.GET, fetch_url("FXCADUSD"), body="""{"observations":{}}""" - ) - series = src.fetch(Series("CAD", "USD", type, "2021-01-03", "2021-01-03")) - assert len(series.prices) == 0 - - -def test_fetch_no_quote(src, type): - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("CAD", "", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_unknown_pair(src, type, requests_mock): - requests_mock.add( - responses.GET, - fetch_url("FXCADAFN"), - status=404, - body="""{ - "message": "Series FXCADAFN not found.", - "docs": "https://www.bankofcanada.ca/valet/docs" - }""", - ) - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("CAD", "AFN", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_network_issue(src, type, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("CAD", "USD", type, "2021-01-01", "2021-01-07")) - assert "Network issue" in str(e.value) - - -def test_fetch_bad_status(src, type, requests_mock): - requests_mock.add( - responses.GET, - fetch_url("FXCADUSD"), - status=500, - body="""{"message": "Some other reason"}""", - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("CAD", "USD", type, "2021-01-01", "2021-01-07")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_parsing_error(src, type, requests_mock): - requests_mock.add(responses.GET, fetch_url("FXCADUSD"), body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("CAD", "USD", type, "2021-01-01", "2021-01-07")) - assert "while parsing data" in str(e.value) diff --git a/tests/pricehist/sources/test_bankofcanada/all-partial.json b/tests/pricehist/sources/test_bankofcanada/all-partial.json deleted file mode 100644 index 3604707..0000000 --- a/tests/pricehist/sources/test_bankofcanada/all-partial.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "terms": { - "url": "https://www.bankofcanada.ca/terms/" - }, - "seriesDetail": { - "FXCADUSD": { - "label": "CAD/USD", - "description": "Canadian dollar to US dollar daily exchange rate", - "dimension": { - "key": "d", - "name": "date" - } - } - }, - "observations": [ - { - "d": "2017-01-03", - "FXCADUSD": { - "v": "0.7443" - } - }, - { - "d": "2017-01-04", - "FXCADUSD": { - "v": "0.7510" - } - }, - { - "d": "2017-01-05", - "FXCADUSD": { - "v": "0.7551" - } - }, - { - "d": "2017-01-06", - "FXCADUSD": { - "v": "0.7568" - } - }, - { - "d": "2017-01-09", - "FXCADUSD": { - "v": "0.7553" - } - }, - { - "d": "2017-01-10", - "FXCADUSD": { - "v": "0.7568" - } - }, - { - "d": "2017-01-11", - "FXCADUSD": { - "v": "0.7547" - } - }, - { - "d": "2020-12-29", - "FXCADUSD": { - "v": "0.7809" - } - }, - { - "d": "2020-12-30", - "FXCADUSD": { - "v": "0.7831" - } - }, - { - "d": "2020-12-31", - "FXCADUSD": { - "v": "0.7854" - } - }, - { - "d": "2021-01-04", - "FXCADUSD": { - "v": "0.7843" - } - }, - { - "d": "2021-01-05", - "FXCADUSD": { - "v": "0.7870" - } - }, - { - "d": "2021-01-06", - "FXCADUSD": { - "v": "0.7883" - } - }, - { - "d": "2021-01-07", - "FXCADUSD": { - "v": "0.7870" - } - } - ] -} diff --git a/tests/pricehist/sources/test_bankofcanada/recent.json b/tests/pricehist/sources/test_bankofcanada/recent.json deleted file mode 100644 index 46ec248..0000000 --- a/tests/pricehist/sources/test_bankofcanada/recent.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "terms": { - "url": "https://www.bankofcanada.ca/terms/" - }, - "seriesDetail": { - "FXCADUSD": { - "label": "CAD/USD", - "description": "Canadian dollar to US dollar daily exchange rate", - "dimension": { - "key": "d", - "name": "date" - } - } - }, - "observations": [ - { - "d": "2021-01-04", - "FXCADUSD": { - "v": "0.7843" - } - }, - { - "d": "2021-01-05", - "FXCADUSD": { - "v": "0.7870" - } - }, - { - "d": "2021-01-06", - "FXCADUSD": { - "v": "0.7883" - } - }, - { - "d": "2021-01-07", - "FXCADUSD": { - "v": "0.7870" - } - } - ] -} diff --git a/tests/pricehist/sources/test_bankofcanada/series-partial.json b/tests/pricehist/sources/test_bankofcanada/series-partial.json deleted file mode 100644 index 68bfb4c..0000000 --- a/tests/pricehist/sources/test_bankofcanada/series-partial.json +++ /dev/null @@ -1,272 +0,0 @@ -{ - "terms": { - "url": "https://www.bankofcanada.ca/terms/" - }, - "series": { - "FXAUDCAD": { - "label": "AUD/CAD", - "description": "Australian dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXAUDCAD" - }, - "FXBRLCAD": { - "label": "BRL/CAD", - "description": "Brazilian real to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXBRLCAD" - }, - "FXCNYCAD": { - "label": "CNY/CAD", - "description": "Chinese renminbi to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCNYCAD" - }, - "FXEURCAD": { - "label": "EUR/CAD", - "description": "European euro to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXEURCAD" - }, - "FXHKDCAD": { - "label": "HKD/CAD", - "description": "Hong Kong dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXHKDCAD" - }, - "FXINRCAD": { - "label": "INR/CAD", - "description": "Indian rupee to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXINRCAD" - }, - "FXIDRCAD": { - "label": "IDR/CAD", - "description": "Indonesian rupiah to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXIDRCAD" - }, - "FXJPYCAD": { - "label": "JPY/CAD", - "description": "Japanese yen to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXJPYCAD" - }, - "FXMYRCAD": { - "label": "MYR/CAD", - "description": "Malaysian ringgit to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXMYRCAD" - }, - "FXMXNCAD": { - "label": "MXN/CAD", - "description": "Mexican peso to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXMXNCAD" - }, - "FXNZDCAD": { - "label": "NZD/CAD", - "description": "New Zealand dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXNZDCAD" - }, - "FXNOKCAD": { - "label": "NOK/CAD", - "description": "Norwegian krone to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXNOKCAD" - }, - "FXPENCAD": { - "label": "PEN/CAD", - "description": "Peruvian new sol to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXPENCAD" - }, - "FXRUBCAD": { - "label": "RUB/CAD", - "description": "Russian ruble to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXRUBCAD" - }, - "FXSARCAD": { - "label": "SAR/CAD", - "description": "Saudi riyal to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXSARCAD" - }, - "FXSGDCAD": { - "label": "SGD/CAD", - "description": "Singapore dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXSGDCAD" - }, - "FXZARCAD": { - "label": "ZAR/CAD", - "description": "South African rand to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXZARCAD" - }, - "FXKRWCAD": { - "label": "KRW/CAD", - "description": "South Korean won to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXKRWCAD" - }, - "FXSEKCAD": { - "label": "SEK/CAD", - "description": "Swedish krona to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXSEKCAD" - }, - "FXCHFCAD": { - "label": "CHF/CAD", - "description": "Swiss franc to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCHFCAD" - }, - "FXTWDCAD": { - "label": "TWD/CAD", - "description": "Taiwanese dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXTWDCAD" - }, - "FXTHBCAD": { - "label": "THB/CAD", - "description": "Thai baht to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXTHBCAD" - }, - "FXTRYCAD": { - "label": "TRY/CAD", - "description": "Turkish lira to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXTRYCAD" - }, - "FXGBPCAD": { - "label": "GBP/CAD", - "description": "UK pound sterling to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXGBPCAD" - }, - "FXUSDCAD": { - "label": "USD/CAD", - "description": "US dollar to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXUSDCAD" - }, - "FXVNDCAD": { - "label": "VND/CAD", - "description": "Vietnamese dong to Canadian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXVNDCAD" - }, - "FXCADAUD": { - "label": "CAD/AUD", - "description": "Canadian dollar to Australian dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADAUD" - }, - "FXCADBRL": { - "label": "CAD/BRL", - "description": "Canadian dollar to Brazilian real daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADBRL" - }, - "FXCADCNY": { - "label": "CAD/CNY", - "description": "Canadian dollar to Chinese renminbi daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADCNY" - }, - "FXCADEUR": { - "label": "CAD/EUR", - "description": "Canadian dollar to European euro daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADEUR" - }, - "FXCADHKD": { - "label": "CAD/HKD", - "description": "Canadian dollar to Hong Kong dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADHKD" - }, - "FXCADINR": { - "label": "CAD/INR", - "description": "Canadian dollar to Indian rupee daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADINR" - }, - "FXCADIDR": { - "label": "CAD/IDR", - "description": "Canadian dollar to Indonesian rupiah daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADIDR" - }, - "FXCADJPY": { - "label": "CAD/JPY", - "description": "Canadian dollar to Japanese yen daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADJPY" - }, - "FXCADMYR": { - "label": "CAD/MYR", - "description": "Canadian dollar to Malaysian ringgit daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADMYR" - }, - "FXCADMXN": { - "label": "CAD/MXN", - "description": "Canadian dollar to Mexican peso daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADMXN" - }, - "FXCADNZD": { - "label": "CAD/NZD", - "description": "Canadian dollar to New Zealand dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADNZD" - }, - "FXCADNOK": { - "label": "CAD/NOK", - "description": "Canadian dollar to Norwegian krone daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADNOK" - }, - "FXCADPEN": { - "label": "CAD/PEN", - "description": "Canadian dollar to Peruvian new sol daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADPEN" - }, - "FXCADRUB": { - "label": "CAD/RUB", - "description": "Canadian dollar to Russian ruble daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADRUB" - }, - "FXCADSAR": { - "label": "CAD/SAR", - "description": "Canadian dollar to Saudi riyal daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADSAR" - }, - "FXCADSGD": { - "label": "CAD/SGD", - "description": "Canadian dollar to Singapore dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADSGD" - }, - "FXCADZAR": { - "label": "CAD/ZAR", - "description": "Canadian dollar to South African rand daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADZAR" - }, - "FXCADKRW": { - "label": "CAD/KRW", - "description": "Canadian dollar to South Korean won daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADKRW" - }, - "FXCADSEK": { - "label": "CAD/SEK", - "description": "Canadian dollar to Swedish krona daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADSEK" - }, - "FXCADCHF": { - "label": "CAD/CHF", - "description": "Canadian dollar to Swiss franc daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADCHF" - }, - "FXCADTWD": { - "label": "CAD/TWD", - "description": "Canadian dollar to Taiwanese dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADTWD" - }, - "FXCADTHB": { - "label": "CAD/THB", - "description": "Canadian dollar to Thai baht daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADTHB" - }, - "FXCADTRY": { - "label": "CAD/TRY", - "description": "Canadian dollar to Turkish lira daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADTRY" - }, - "FXCADGBP": { - "label": "CAD/GBP", - "description": "Canadian dollar to UK pound sterling daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADGBP" - }, - "FXCADUSD": { - "label": "CAD/USD", - "description": "Canadian dollar to US dollar daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADUSD" - }, - "FXCADVND": { - "label": "CAD/VND", - "description": "Canadian dollar to Vietnamese dong daily exchange rate", - "link": "https://www.bankofcanada.ca/valet/series/FXCADVND" - }, - "INDINF_GRACE_Q": { - "label": "Foreign demand for Canadian non-commodity exports (GRACE) (2007=100)", - "description": "Foreign demand for Canadian non-commodity exports (GRACE) (2007=100)", - "link": "https://www.bankofcanada.ca/valet/series/INDINF_GRACE_Q" - } - } -} diff --git a/tests/pricehist/sources/test_basesource.py b/tests/pricehist/sources/test_basesource.py deleted file mode 100644 index 478d5c2..0000000 --- a/tests/pricehist/sources/test_basesource.py +++ /dev/null @@ -1,183 +0,0 @@ -import logging -from typing import List, Tuple - -import pytest - -from pricehist.series import Series -from pricehist.sources.basesource import BaseSource - - -class TestSource(BaseSource): - def id(self) -> str: - return "" - - def name(self) -> str: - return "" - - def description(self) -> str: - return "" - - def source_url(self) -> str: - return "" - - def start(self) -> str: - return "" - - def types(self) -> List[str]: - return [] - - def notes(self) -> str: - return "" - - def symbols(self) -> List[Tuple[str, str]]: - return [] - - def fetch(self, series: Series) -> Series: - pass - - -@pytest.fixture -def src(): - return TestSource() - - -def test_normalizesymbol_default_uppercase(src): - assert src.normalizesymbol("eur") == "EUR" - - -def test_format_symbols_one(src, mocker): - src.symbols = mocker.MagicMock(return_value=[("A", "Description")]) - assert src.format_symbols() == "A Description\n" - - -def test_format_symbols_many(src, mocker): - src.symbols = mocker.MagicMock( - return_value=[ - ("A", "Description"), - ("BB", "Description longer"), - ("CCC", "Description longer again"), - ("DDDD", f"Description {'very '*15}long"), - ] - ) - assert src.format_symbols() == ( - "A Description\n" - "BB Description longer\n" - "CCC Description longer again\n" - "DDDD Description very very very very very very very very " - "very very very very very very very long\n" - ) - - -def test_format_search(src, mocker): - src.search = mocker.MagicMock( - return_value=[ - ("A", "Description"), - ("BB", "Description longer"), - ("CCC", "Description longer again"), - ("DDDD", f"Description {'very '*15}long"), - ] - ) - assert src.format_search("some query") == ( - "A Description\n" - "BB Description longer\n" - "CCC Description longer again\n" - "DDDD Description very very very very very very very very " - "very very very very very very very long\n" - ) - - -def test_format_search_not_possible(src, mocker, caplog): - src.search = mocker.MagicMock(return_value=None) - with caplog.at_level(logging.INFO): - with pytest.raises(SystemExit) as e: - src.format_search("some query") - assert e.value.code == 1 - r = caplog.records[0] - assert r.levelname == "ERROR" - assert "Symbol search is not possible for" in r.message - - -def test_format_search_no_results(src, mocker, caplog): - src.search = mocker.MagicMock(return_value=[]) - with caplog.at_level(logging.INFO): - results = src.format_search("some query") - r = caplog.records[0] - assert r.levelname == "INFO" - assert "No results found" in r.message - assert results == "" - - -def test_format_info_skips_renderes_all_fields(src, mocker): - src.id = mocker.MagicMock(return_value="sourceid") - src.name = mocker.MagicMock(return_value="Source Name") - src.description = mocker.MagicMock(return_value="Source description.") - src.source_url = mocker.MagicMock(return_value="https://example.com/") - src.start = mocker.MagicMock(return_value="2021-01-01") - src.types = mocker.MagicMock(return_value=["open", "close"]) - src.notes = mocker.MagicMock(return_value="Notes for user.") - output = src.format_info() - assert output == ( - "ID : sourceid\n" - "Name : Source Name\n" - "Description : Source description.\n" - "URL : https://example.com/\n" - "Start : 2021-01-01\n" - "Types : open, close\n" - "Notes : Notes for user." - ) - - -def test_format_info_skips_empty_fields(src, mocker): - src.notes = mocker.MagicMock(return_value="") - output = src.format_info() - assert "Notes" not in output - - -def test_format_info_wraps_long_values_with_indent(src, mocker): - notes = ( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " - "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim " - "ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " - "aliquip ex ea commodo consequat." - ) - src.notes = mocker.MagicMock(return_value=notes) - output = src.format_info(total_width=60) - assert output == ( - "Notes : Lorem ipsum dolor sit amet, consectetur\n" - " adipiscing elit, sed do eiusmod tempor\n" - " incididunt ut labore et dolore magna aliqua.\n" - " Ut enim ad minim veniam, quis nostrud\n" - " exercitation ullamco laboris nisi ut aliquip\n" - " ex ea commodo consequat." - ) - - -def test_format_info_newline_handling(src, mocker): - notes = ( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " - "eiusmod tempor incididunt ut labore.\n" - "Ut enim ad minim veniam.\n" - "\n" - "Quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea " - "commodo consequat." - ) - src.notes = mocker.MagicMock(return_value=notes) - output = src.format_info(total_width=60) - assert output == ( - "Notes : Lorem ipsum dolor sit amet, consectetur\n" - " adipiscing elit, sed do eiusmod tempor\n" - " incididunt ut labore.\n" - " Ut enim ad minim veniam.\n" - "\n" - " Quis nostrud exercitation ullamco laboris nisi\n" - " ut aliquip ex ea commodo consequat." - ) - - -def test_format_info_does_not_wrap_source_url(src, mocker): - url = "https://www.example.com/longlonglonglonglonglonglonglong/" - src.source_url = mocker.MagicMock(return_value=url) - output = src.format_info(total_width=60) - assert output == ( - "URL : https://www.example.com/longlonglonglonglonglonglonglong/" - ) diff --git a/tests/pricehist/sources/test_coinbasepro.py b/tests/pricehist/sources/test_coinbasepro.py deleted file mode 100644 index 38b0ac4..0000000 --- a/tests/pricehist/sources/test_coinbasepro.py +++ /dev/null @@ -1,334 +0,0 @@ -import logging -import os -import re -from datetime import datetime -from decimal import Decimal -from pathlib import Path - -import pytest -import requests -import responses - -from pricehist import exceptions -from pricehist.price import Price -from pricehist.series import Series -from pricehist.sources.coinbasepro import CoinbasePro - - -@pytest.fixture -def src(): - return CoinbasePro() - - -@pytest.fixture -def type(src): - return src.types()[0] - - -@pytest.fixture -def requests_mock(): - with responses.RequestsMock() as mock: - yield mock - - -@pytest.fixture -def products_url(): - return "https://api.pro.coinbase.com/products" - - -@pytest.fixture -def currencies_url(): - return "https://api.pro.coinbase.com/currencies" - - -def product_url(base, quote): - return f"https://api.pro.coinbase.com/products/{base}-{quote}/candles" - - -@pytest.fixture -def products_json(): - return (Path(os.path.splitext(__file__)[0]) / "products-partial.json").read_text() - - -@pytest.fixture -def currencies_json(): - return (Path(os.path.splitext(__file__)[0]) / "currencies-partial.json").read_text() - - -@pytest.fixture -def products_response_ok(requests_mock, products_url, products_json): - requests_mock.add(responses.GET, products_url, body=products_json, status=200) - yield requests_mock - - -@pytest.fixture -def currencies_response_ok(requests_mock, currencies_url, currencies_json): - requests_mock.add(responses.GET, currencies_url, body=currencies_json, status=200) - yield requests_mock - - -@pytest.fixture -def recent_response_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "recent.json").read_text() - requests_mock.add(responses.GET, product_url("BTC", "EUR"), body=json, status=200) - yield requests_mock - - -@pytest.fixture -def multi_response_ok(requests_mock): - url1 = re.compile( - r"https://api\.pro\.coinbase\.com/products/BTC-EUR/candles\?start=2020-01-01.*" - ) - url2 = re.compile( - r"https://api\.pro\.coinbase\.com/products/BTC-EUR/candles\?start=2020-10-17.*" - ) - json1 = ( - Path(os.path.splitext(__file__)[0]) / "2020-01-01--2020-10-16.json" - ).read_text() - json2 = ( - Path(os.path.splitext(__file__)[0]) / "2020-10-17--2021-01-07.json" - ).read_text() - requests_mock.add(responses.GET, url1, body=json1, status=200) - requests_mock.add(responses.GET, url2, body=json2, status=200) - yield requests_mock - - -@pytest.fixture -def response_empty(requests_mock): - requests_mock.add( - responses.GET, - product_url("BTC", "EUR"), - status=200, - body="[]", - ) - - -def test_normalizesymbol(src): - assert src.normalizesymbol("btc") == "BTC" - assert src.normalizesymbol("usd") == "USD" - - -def test_metadata(src): - assert isinstance(src.id(), str) - assert len(src.id()) > 0 - - assert isinstance(src.name(), str) - assert len(src.name()) > 0 - - assert isinstance(src.description(), str) - assert len(src.description()) > 0 - - assert isinstance(src.source_url(), str) - assert src.source_url().startswith("http") - - assert datetime.strptime(src.start(), "%Y-%m-%d") - - assert isinstance(src.types(), list) - assert len(src.types()) > 0 - assert isinstance(src.types()[0], str) - assert len(src.types()[0]) > 0 - - assert isinstance(src.notes(), str) - - -def test_symbols(src, products_response_ok, currencies_response_ok): - syms = src.symbols() - assert ("BTC/EUR", "Bitcoin against Euro") in syms - assert len(syms) > 2 - - -def test_symbols_requests_logged( - src, products_response_ok, currencies_response_ok, caplog -): - with caplog.at_level(logging.DEBUG): - src.symbols() - matching = filter( - lambda r: "DEBUG" == r.levelname and "curl " in r.message, - caplog.records, - ) - assert len(list(matching)) == 2 - - -def test_symbols_not_found(src, requests_mock, products_url, currencies_response_ok): - requests_mock.add(responses.GET, products_url, body="[]", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "data not found" in str(e.value) - - -def test_symbols_network_issue( - src, requests_mock, products_response_ok, currencies_url -): - requests_mock.add( - responses.GET, - currencies_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.symbols() - assert "Network issue" in str(e.value) - - -def test_symbols_bad_status(src, requests_mock, products_url, currencies_response_ok): - requests_mock.add(responses.GET, products_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.symbols() - assert "Server Error" in str(e.value) - - -def test_symbols_parsing_error( - src, requests_mock, products_response_ok, currencies_url -): - requests_mock.add(responses.GET, currencies_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "while parsing data" in str(e.value) - - -def test_fetch_known_pair(src, type, recent_response_ok): - series = src.fetch(Series("BTC", "EUR", type, "2021-01-01", "2021-01-07")) - req = recent_response_ok.calls[0].request - assert req.params["granularity"] == "86400" - assert req.params["start"] == "2021-01-01" - assert req.params["end"] == "2021-01-07" - assert series.prices[0] == Price("2021-01-01", Decimal("23881.35")) - assert series.prices[-1] == Price("2021-01-07", Decimal("31208.49")) - assert len(series.prices) == 7 - - -def test_fetch_types_all_available(src, recent_response_ok): - mid = src.fetch(Series("BTC", "EUR", "mid", "2021-01-01", "2021-01-07")) - opn = src.fetch(Series("BTC", "EUR", "open", "2021-01-01", "2021-01-07")) - hgh = src.fetch(Series("BTC", "EUR", "high", "2021-01-01", "2021-01-07")) - low = src.fetch(Series("BTC", "EUR", "low", "2021-01-01", "2021-01-07")) - cls = src.fetch(Series("BTC", "EUR", "close", "2021-01-01", "2021-01-07")) - assert mid.prices[0].amount == Decimal("23881.35") - assert opn.prices[0].amount == Decimal("23706.73") - assert hgh.prices[0].amount == Decimal("24250") - assert low.prices[0].amount == Decimal("23512.7") - assert cls.prices[0].amount == Decimal("24070.97") - - -def test_fetch_type_mid_is_mean_of_low_and_high(src, recent_response_ok): - mid = src.fetch(Series("BTC", "EUR", "mid", "2021-01-01", "2021-01-07")).prices - low = src.fetch(Series("BTC", "EUR", "low", "2021-01-01", "2021-01-07")).prices - hgh = src.fetch(Series("BTC", "EUR", "high", "2021-01-01", "2021-01-07")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 7) - ] - ) - - -def test_fetch_requests_logged(src, type, recent_response_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("BTC", "EUR", type, "2021-01-01", "2021-01-07")) - assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] - ) - - -def test_fetch_long_hist_multi_segment(src, type, multi_response_ok): - series = src.fetch(Series("BTC", "EUR", type, "2020-01-01", "2021-01-07")) - assert series.prices[0] == Price("2020-01-01", Decimal("6430.175")) - assert series.prices[-1] == Price("2021-01-07", Decimal("31208.49")) - assert len(series.prices) > 3 - - -def test_fetch_from_before_start(src, type, requests_mock): - body = '{"message":"End is too old"}' - requests_mock.add(responses.GET, product_url("BTC", "EUR"), status=400, body=body) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "EUR", type, "1960-01-01", "1960-01-07")) - assert "too early" in str(e.value) - - -def test_fetch_in_future(src, type, response_empty): - series = src.fetch(Series("BTC", "EUR", type, "2100-01-01", "2100-01-07")) - assert len(series.prices) == 0 - - -def test_fetch_wrong_dates_order_alledged(src, type, requests_mock): - # Is actually prevented in argument parsing and inside the source. - body = '{"message":"start must be before end"}' - requests_mock.add(responses.GET, product_url("BTC", "EUR"), status=400, body=body) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-07", "2021-01-01")) - assert "end can't preceed" in str(e.value) - - -def test_fetch_too_many_data_points_alledged(src, type, requests_mock): - # Should only happen if limit is reduced or calculated segments lengthened - body = "aggregations requested exceeds" - requests_mock.add(responses.GET, product_url("BTC", "EUR"), status=400, body=body) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-07", "2021-01-01")) - assert "Too many data points" in str(e.value) - - -def test_fetch_rate_limit(src, type, requests_mock): - body = "Too many requests" - requests_mock.add(responses.GET, product_url("BTC", "EUR"), status=429, body=body) - with pytest.raises(exceptions.RateLimit) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-07", "2021-01-01")) - assert "rate limit has been exceeded" in str(e.value) - - -def test_fetch_empty(src, type, response_empty): - series = src.fetch(Series("BTC", "EUR", type, "2000-01-01", "2000-01-07")) - assert len(series.prices) == 0 - - -def test_fetch_unknown_base(src, type, requests_mock): - body = '{"message":"NotFound"}' - requests_mock.add( - responses.GET, product_url("UNKNOWN", "EUR"), status=404, body=body - ) - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("UNKNOWN", "EUR", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_unknown_quote(src, type, requests_mock): - body = '{"message":"NotFound"}' - requests_mock.add(responses.GET, product_url("BTC", "XZY"), status=404, body=body) - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("BTC", "XZY", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_no_quote(src, type, requests_mock): - body = '{"message":"NotFound"}' - requests_mock.add(responses.GET, product_url("BTC", ""), status=404, body=body) - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("BTC", "", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_unknown_pair(src, type, requests_mock): - body = '{"message":"NotFound"}' - requests_mock.add(responses.GET, product_url("ABC", "XZY"), status=404, body=body) - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("ABC", "XZY", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_network_issue(src, type, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, product_url("BTC", "EUR"), body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-01", "2021-01-07")) - assert "Network issue" in str(e.value) - - -def test_fetch_bad_status(src, type, requests_mock): - requests_mock.add( - responses.GET, product_url("BTC", "EUR"), status=500, body="Some other reason" - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-01", "2021-01-07")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_parsing_error(src, type, requests_mock): - requests_mock.add(responses.GET, product_url("BTC", "EUR"), body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("BTC", "EUR", type, "2021-01-01", "2021-01-07")) - assert "while parsing data" in str(e.value) diff --git a/tests/pricehist/sources/test_coinbasepro/2020-01-01--2020-10-16.json b/tests/pricehist/sources/test_coinbasepro/2020-01-01--2020-10-16.json deleted file mode 100644 index 0d6a2d4..0000000 --- a/tests/pricehist/sources/test_coinbasepro/2020-01-01--2020-10-16.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - [ - 1602806400, - 9588, - 9860, - 9828.84, - 9672.41, - 1068.08144123 - ], - [ - 1577836800, - 6388.91, - 6471.44, - 6400.02, - 6410.22, - 491.94797816 - ] -] diff --git a/tests/pricehist/sources/test_coinbasepro/2020-10-17--2021-01-07.json b/tests/pricehist/sources/test_coinbasepro/2020-10-17--2021-01-07.json deleted file mode 100644 index 6641080..0000000 --- a/tests/pricehist/sources/test_coinbasepro/2020-10-17--2021-01-07.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - [ - 1609977600, - 29516.98, - 32900, - 29818.73, - 32120.19, - 5957.46980324 - ], - [ - 1602892800, - 9630.1, - 9742.61, - 9675.29, - 9706.33, - 385.03505036 - ] -] diff --git a/tests/pricehist/sources/test_coinbasepro/currencies-partial.json b/tests/pricehist/sources/test_coinbasepro/currencies-partial.json deleted file mode 100644 index cb73b65..0000000 --- a/tests/pricehist/sources/test_coinbasepro/currencies-partial.json +++ /dev/null @@ -1,141 +0,0 @@ -[ - { - "id": "BTC", - "name": "Bitcoin", - "min_size": "0.00000001", - "status": "online", - "message": "", - "max_precision": "0.00000001", - "convertible_to": [], - "details": { - "type": "crypto", - "symbol": "₿", - "network_confirmations": 3, - "sort_order": 20, - "crypto_address_link": "https://live.blockcypher.com/btc/address/{{address}}", - "crypto_transaction_link": "https://live.blockcypher.com/btc/tx/{{txId}}", - "push_payment_methods": [ - "crypto" - ], - "group_types": [ - "btc", - "crypto" - ], - "display_name": "", - "processing_time_seconds": 0, - "min_withdrawal_amount": 0.0001, - "max_withdrawal_amount": 2400 - } - }, - { - "id": "DOGE", - "name": "Dogecoin", - "min_size": "1", - "status": "online", - "message": "", - "max_precision": "0.1", - "convertible_to": [], - "details": { - "type": "crypto", - "symbol": "", - "network_confirmations": 60, - "sort_order": 29, - "crypto_address_link": "https://dogechain.info/address/{{address}}", - "crypto_transaction_link": "", - "push_payment_methods": [ - "crypto" - ], - "group_types": [], - "display_name": "", - "processing_time_seconds": 0, - "min_withdrawal_amount": 1, - "max_withdrawal_amount": 17391300 - } - }, - { - "id": "ETH", - "name": "Ether", - "min_size": "0.00000001", - "status": "online", - "message": "", - "max_precision": "0.00000001", - "convertible_to": [], - "details": { - "type": "crypto", - "symbol": "Ξ", - "network_confirmations": 35, - "sort_order": 25, - "crypto_address_link": "https://etherscan.io/address/{{address}}", - "crypto_transaction_link": "https://etherscan.io/tx/0x{{txId}}", - "push_payment_methods": [ - "crypto" - ], - "group_types": [ - "eth", - "crypto" - ], - "display_name": "", - "processing_time_seconds": 0, - "min_withdrawal_amount": 0.001, - "max_withdrawal_amount": 7450 - } - }, - { - "id": "EUR", - "name": "Euro", - "min_size": "0.01", - "status": "online", - "message": "", - "max_precision": "0.01", - "convertible_to": [], - "details": { - "type": "fiat", - "symbol": "€", - "network_confirmations": 0, - "sort_order": 2, - "crypto_address_link": "", - "crypto_transaction_link": "", - "push_payment_methods": [ - "sepa_bank_account" - ], - "group_types": [ - "fiat", - "eur" - ], - "display_name": "", - "processing_time_seconds": 0, - "min_withdrawal_amount": 0, - "max_withdrawal_amount": 0 - } - }, - { - "id": "GBP", - "name": "British Pound", - "min_size": "0.01", - "status": "online", - "message": "", - "max_precision": "0.01", - "convertible_to": [], - "details": { - "type": "fiat", - "symbol": "£", - "network_confirmations": 0, - "sort_order": 3, - "crypto_address_link": "", - "crypto_transaction_link": "", - "push_payment_methods": [ - "uk_bank_account", - "swift_lhv", - "swift" - ], - "group_types": [ - "fiat", - "gbp" - ], - "display_name": "", - "processing_time_seconds": 0, - "min_withdrawal_amount": 0, - "max_withdrawal_amount": 0 - } - } -] diff --git a/tests/pricehist/sources/test_coinbasepro/products-partial.json b/tests/pricehist/sources/test_coinbasepro/products-partial.json deleted file mode 100644 index b241803..0000000 --- a/tests/pricehist/sources/test_coinbasepro/products-partial.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "id": "BTC-EUR", - "base_currency": "BTC", - "quote_currency": "EUR", - "base_min_size": "0.0001", - "base_max_size": "200", - "quote_increment": "0.01", - "base_increment": "0.00000001", - "display_name": "BTC/EUR", - "min_market_funds": "10", - "max_market_funds": "600000", - "margin_enabled": false, - "fx_stablecoin": false, - "post_only": false, - "limit_only": false, - "cancel_only": false, - "trading_disabled": false, - "status": "online", - "status_message": "" - }, - { - "id": "ETH-GBP", - "base_currency": "ETH", - "quote_currency": "GBP", - "base_min_size": "0.001", - "base_max_size": "1400", - "quote_increment": "0.01", - "base_increment": "0.00000001", - "display_name": "ETH/GBP", - "min_market_funds": "10", - "max_market_funds": "1000000", - "margin_enabled": false, - "fx_stablecoin": false, - "post_only": false, - "limit_only": false, - "cancel_only": false, - "trading_disabled": false, - "status": "online", - "status_message": "" - }, - { - "id": "DOGE-EUR", - "base_currency": "DOGE", - "quote_currency": "EUR", - "base_min_size": "1", - "base_max_size": "690000", - "quote_increment": "0.0001", - "base_increment": "0.1", - "display_name": "DOGE/EUR", - "min_market_funds": "5.0", - "max_market_funds": "100000", - "margin_enabled": false, - "fx_stablecoin": false, - "post_only": false, - "limit_only": false, - "cancel_only": false, - "trading_disabled": false, - "status": "online", - "status_message": "" - } -] diff --git a/tests/pricehist/sources/test_coinbasepro/recent.json b/tests/pricehist/sources/test_coinbasepro/recent.json deleted file mode 100644 index fab4821..0000000 --- a/tests/pricehist/sources/test_coinbasepro/recent.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - [ - 1609977600, - 29516.98, - 32900, - 29818.73, - 32120.19, - 5957.46980324 - ], - [ - 1609891200, - 27105.01, - 29949, - 27655.04, - 29838.52, - 4227.05067035 - ], - [ - 1609804800, - 24413.62, - 27989, - 26104.4, - 27654.01, - 4036.27720179 - ], - [ - 1609718400, - 22055, - 26199, - 25624.7, - 26115.94, - 6304.41029978 - ], - [ - 1609632000, - 24500, - 27195.46, - 25916.75, - 25644.41, - 4975.13927959 - ], - [ - 1609545600, - 22000, - 27000, - 24071.26, - 25907.35, - 7291.88538639 - ], - [ - 1609459200, - 23512.7, - 24250, - 23706.73, - 24070.97, - 1830.04655405 - ] -] diff --git a/tests/pricehist/sources/test_coindesk.py b/tests/pricehist/sources/test_coindesk.py index c53f34a..98d3d6f 100644 --- a/tests/pricehist/sources/test_coindesk.py +++ b/tests/pricehist/sources/test_coindesk.py @@ -35,11 +35,6 @@ def currencies_url(): return "https://api.coindesk.com/v1/bpi/supported-currencies.json" -@pytest.fixture -def fetch_url(): - return "https://api.coindesk.com/v1/bpi/historical/close.json" - - @pytest.fixture def currencies_json(): dir = Path(os.path.splitext(__file__)[0]) @@ -48,29 +43,54 @@ def currencies_json(): @pytest.fixture def currencies_response_ok(requests_mock, currencies_url, currencies_json): - requests_mock.add(responses.GET, currencies_url, body=currencies_json, status=200) - yield requests_mock - - -@pytest.fixture -def recent_response_ok(requests_mock, fetch_url): - json = (Path(os.path.splitext(__file__)[0]) / "recent.json").read_text() - requests_mock.add(responses.GET, fetch_url, body=json, status=200) - yield requests_mock - - -@pytest.fixture -def all_response_ok(requests_mock, fetch_url): - json = (Path(os.path.splitext(__file__)[0]) / "all-partial.json").read_text() - requests_mock.add(responses.GET, fetch_url, body=json, status=200) - yield requests_mock - - -@pytest.fixture -def not_found_response(requests_mock, fetch_url): requests_mock.add( responses.GET, - fetch_url, + currencies_url, + body=currencies_json, + status=200, + ) + yield requests_mock + + +@pytest.fixture +def recent_json(): + dir = Path(os.path.splitext(__file__)[0]) + return (dir / "recent.json").read_text() + + +@pytest.fixture +def recent_response_ok(requests_mock, recent_json): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + body=recent_json, + status=200, + ) + yield requests_mock + + +@pytest.fixture +def all_json(): + dir = Path(os.path.splitext(__file__)[0]) + return (dir / "all-partial.json").read_text() + + +@pytest.fixture +def all_response_ok(requests_mock, all_json): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + body=all_json, + status=200, + ) + yield requests_mock + + +@pytest.fixture +def not_found_response(requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", status=404, body="Sorry, that currency was not found", ) @@ -114,12 +134,17 @@ def test_symbols_requests_logged(src, currencies_response_ok, caplog): with caplog.at_level(logging.DEBUG): src.symbols() assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] + ["DEBUG" == r.levelname and " curl " in r.message for r in caplog.records] ) def test_symbols_not_found(src, requests_mock, currencies_url): - requests_mock.add(responses.GET, currencies_url, body="[]", status=200) + requests_mock.add( + responses.GET, + currencies_url, + body="[]", + status=200, + ) with pytest.raises(exceptions.ResponseParsingError) as e: src.symbols() assert "data not found" in str(e.value) @@ -137,14 +162,22 @@ def test_symbols_network_issue(src, requests_mock, currencies_url): def test_symbols_bad_status(src, requests_mock, currencies_url): - requests_mock.add(responses.GET, currencies_url, status=500) + requests_mock.add( + responses.GET, + currencies_url, + status=500, + ) with pytest.raises(exceptions.BadResponse) as e: src.symbols() assert "Server Error" in str(e.value) def test_symbols_parsing_error(src, requests_mock, currencies_url): - requests_mock.add(responses.GET, currencies_url, body="NOT JSON") + requests_mock.add( + responses.GET, + currencies_url, + body="NOT JSON", + ) with pytest.raises(exceptions.ResponseParsingError) as e: src.symbols() assert "while parsing data" in str(e.value) @@ -165,7 +198,7 @@ def test_fetch_requests_logged(src, type, recent_response_ok, caplog): with caplog.at_level(logging.DEBUG): src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] + ["DEBUG" == r.levelname and " curl " in r.message for r in caplog.records] ) @@ -176,9 +209,13 @@ def test_fetch_long_hist_from_start(src, type, all_response_ok): assert len(series.prices) > 13 -def test_fetch_from_before_start(src, type, requests_mock, fetch_url): - body = "Sorry, the CoinDesk BPI only covers data from 2010-07-17 onwards." - requests_mock.add(responses.GET, fetch_url, status=404, body=body) +def test_fetch_from_before_start(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + status=404, + body="Sorry, the CoinDesk BPI only covers data from 2010-07-17 onwards.", + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("BTC", "AUD", type, "2010-01-01", "2010-07-24")) assert "only covers data from" in str(e.value) @@ -189,31 +226,47 @@ def test_fetch_to_future(src, type, all_response_ok): assert len(series.prices) > 0 -def test_wrong_dates_order(src, type, requests_mock, fetch_url): - body = "Sorry, but your specified end date is before your start date." - requests_mock.add(responses.GET, fetch_url, status=404, body=body) +def test_wrong_dates_order(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + status=404, + body="Sorry, but your specified end date is before your start date.", + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("BTC", "AUD", type, "2021-01-07", "2021-01-01")) assert "End date is before start date." in str(e.value) -def test_fetch_in_future(src, type, requests_mock, fetch_url): - body = "Sorry, but your specified end date is before your start date." - requests_mock.add(responses.GET, fetch_url, status=404, body=body) +def test_fetch_in_future(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + status=404, + body="Sorry, but your specified end date is before your start date.", + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("BTC", "AUD", type, "2030-01-01", "2030-01-07")) assert "start date must be in the past" in str(e.value) -def test_fetch_empty(src, type, requests_mock, fetch_url): - requests_mock.add(responses.GET, fetch_url, body="{}") +def test_fetch_empty(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + body="{}", + ) series = src.fetch(Series("BTC", "AUD", type, "2010-07-17", "2010-07-17")) assert len(series.prices) == 0 -def test_fetch_known_pair_no_data(src, type, requests_mock, fetch_url): - body = "No results returned from database" - requests_mock.add(responses.GET, fetch_url, status=500, body=body) +def test_fetch_known_pair_no_data(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + status=500, + body="No results returned from database", + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("BTC", "CUP", type, "2010-07-17", "2010-07-23")) assert "No results returned from database" in str(e.value) @@ -239,23 +292,35 @@ def test_fetch_unknown_pair(src, type): src.fetch(Series("ABC", "XZY", type, "2021-01-01", "2021-01-07")) -def test_fetch_network_issue(src, type, requests_mock, fetch_url): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, fetch_url, body=body) +def test_fetch_network_issue(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + body=requests.exceptions.ConnectionError("Network issue"), + ) with pytest.raises(exceptions.RequestError) as e: src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) assert "Network issue" in str(e.value) -def test_fetch_bad_status(src, type, requests_mock, fetch_url): - requests_mock.add(responses.GET, fetch_url, status=500, body="Some other reason") +def test_fetch_bad_status(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + status=500, + body="Some other reason", + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) assert "Internal Server Error" in str(e.value) -def test_fetch_parsing_error(src, type, requests_mock, fetch_url): - requests_mock.add(responses.GET, fetch_url, body="NOT JSON") +def test_fetch_parsing_error(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://api.coindesk.com/v1/bpi/historical/close.json", + body="NOT JSON", + ) with pytest.raises(exceptions.ResponseParsingError) as e: src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) assert "while parsing data" in str(e.value) diff --git a/tests/pricehist/sources/test_coinmarketcap.py b/tests/pricehist/sources/test_coinmarketcap.py deleted file mode 100644 index b7bd721..0000000 --- a/tests/pricehist/sources/test_coinmarketcap.py +++ /dev/null @@ -1,310 +0,0 @@ -import logging -import os -from datetime import datetime, timezone -from decimal import Decimal -from pathlib import Path - -import pytest -import requests -import responses - -from pricehist import exceptions -from pricehist.price import Price -from pricehist.series import Series -from pricehist.sources.coinmarketcap import CoinMarketCap - - -def timestamp(date): - return int( - datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - ) - - -@pytest.fixture -def src(): - return CoinMarketCap() - - -@pytest.fixture -def type(src): - return src.types()[0] - - -@pytest.fixture -def requests_mock(): - with responses.RequestsMock() as mock: - yield mock - - -crypto_url = ( - "https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map?sort=cmc_rank" -) -fetch_url = "https://api.coinmarketcap.com/data-api/v3.1/cryptocurrency/historical" - - -@pytest.fixture -def crypto_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "crypto-partial.json").read_text() - requests_mock.add(responses.GET, crypto_url, body=json, status=200) - yield requests_mock - - -@pytest.fixture -def recent_id_id_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "recent-id1-id2782.json").read_text() - requests_mock.add(responses.GET, fetch_url, body=json, status=200) - yield requests_mock - - -def test_normalizesymbol(src): - assert src.normalizesymbol("btc") == "BTC" - assert src.normalizesymbol("id=1") == "ID=1" - - -def test_metadata(src): - assert isinstance(src.id(), str) - assert len(src.id()) > 0 - - assert isinstance(src.name(), str) - assert len(src.name()) > 0 - - assert isinstance(src.description(), str) - assert len(src.description()) > 0 - - assert isinstance(src.source_url(), str) - assert src.source_url().startswith("http") - - assert datetime.strptime(src.start(), "%Y-%m-%d") - - assert isinstance(src.types(), list) - assert len(src.types()) > 0 - assert isinstance(src.types()[0], str) - assert len(src.types()[0]) > 0 - - assert isinstance(src.notes(), str) - - -def test_symbols(src, crypto_ok): - syms = src.symbols() - assert ("id=1", "BTC Bitcoin") in syms - assert ("id=2782", "AUD Australian Dollar") in syms - assert len(syms) > 2 - - -def test_symbols_request_logged(src, crypto_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.symbols() - logged_requests = 0 - for r in caplog.records: - if r.levelname == "DEBUG" and "curl " in r.message: - logged_requests += 1 - assert logged_requests == 1 - - -def test_symbols_crypto_not_found(src, requests_mock): - requests_mock.add(responses.GET, crypto_url, body="{}", status=200) - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "Unexpected content" in str(e.value) - - -def test_symbols_crypto_network_issue(src, requests_mock): - requests_mock.add( - responses.GET, - crypto_url, - body=requests.exceptions.ConnectionError("Network issue"), - ) - with pytest.raises(exceptions.RequestError) as e: - src.symbols() - assert "Network issue" in str(e.value) - - -def test_symbols_crypto_bad_status(src, requests_mock): - requests_mock.add(responses.GET, crypto_url, status=500) - with pytest.raises(exceptions.BadResponse) as e: - src.symbols() - assert "Server Error" in str(e.value) - - -def test_symbols_crypto_parsing_error(src, requests_mock): - requests_mock.add(responses.GET, crypto_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "while parsing data" in str(e.value) - - -def test_symbols_no_data(src, type, requests_mock): - requests_mock.add(responses.GET, crypto_url, body='{"data": []}') - with pytest.raises(exceptions.ResponseParsingError) as e: - src.symbols() - assert "Empty data section" in str(e.value) - - -def test_fetch_known_pair_id_id(src, type, recent_id_id_ok, crypto_ok): - series = src.fetch(Series("ID=1", "ID=2782", type, "2021-01-01", "2021-01-07")) - req = recent_id_id_ok.calls[0].request - assert req.params["id"] == "1" - assert req.params["convertId"] == "2782" - assert (series.base, series.quote) == ("BTC", "AUD") - assert len(series.prices) == 7 - - -def test_fetch_known_pair_id_sym(src, type, recent_id_id_ok, crypto_ok): - series = src.fetch(Series("ID=1", "AUD", type, "2021-01-01", "2021-01-07")) - req = recent_id_id_ok.calls[1].request - assert req.params["id"] == "1" - assert req.params["convertId"] == "2782" - assert (series.base, series.quote) == ("BTC", "AUD") - assert len(series.prices) == 7 - - -def test_fetch_known_pair_sym_id(src, type, recent_id_id_ok, crypto_ok): - series = src.fetch(Series("BTC", "ID=2782", type, "2021-01-01", "2021-01-07")) - req = recent_id_id_ok.calls[1].request - assert req.params["id"] == "1" - assert req.params["convertId"] == "2782" - assert (series.base, series.quote) == ("BTC", "AUD") - assert len(series.prices) == 7 - - -def test_fetch_known_pair_sym_sym(src, type, recent_id_id_ok, crypto_ok): - series = src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - req = recent_id_id_ok.calls[1].request - assert req.params["id"] == "1" - assert req.params["convertId"] == "2782" - assert len(series.prices) == 7 - - -def test_fetch_requests_and_receives_correct_times( - src, type, recent_id_id_ok, crypto_ok -): - series = src.fetch(Series("ID=1", "ID=2782", type, "2021-01-01", "2021-01-07")) - req = recent_id_id_ok.calls[0].request - assert req.params["timeStart"] == str(timestamp("2020-12-31")) # back one period - assert req.params["timeEnd"] == str(timestamp("2021-01-07")) - assert series.prices[0] == Price("2021-01-01", Decimal("37914.35060237985")) - assert series.prices[-1] == Price("2021-01-07", Decimal("49369.66288590665")) - - -def test_fetch_requests_logged(src, type, crypto_ok, recent_id_id_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] - ) - - -def test_fetch_types_all_available(src, crypto_ok, recent_id_id_ok): - mid = src.fetch(Series("BTC", "AUD", "mid", "2021-01-01", "2021-01-07")) - opn = src.fetch(Series("BTC", "AUD", "open", "2021-01-01", "2021-01-07")) - hgh = src.fetch(Series("BTC", "AUD", "high", "2021-01-01", "2021-01-07")) - low = src.fetch(Series("BTC", "AUD", "low", "2021-01-01", "2021-01-07")) - cls = src.fetch(Series("BTC", "AUD", "close", "2021-01-01", "2021-01-07")) - assert mid.prices[0].amount == Decimal("37914.35060237985") - assert opn.prices[0].amount == Decimal("37658.1146368474") - assert hgh.prices[0].amount == Decimal("38417.9137031205") - assert low.prices[0].amount == Decimal("37410.7875016392") - assert cls.prices[0].amount == Decimal("38181.9913330076") - - -def test_fetch_type_mid_is_mean_of_low_and_high(src, crypto_ok, recent_id_id_ok): - mid = src.fetch(Series("BTC", "AUD", "mid", "2021-01-01", "2021-01-07")).prices - low = src.fetch(Series("BTC", "AUD", "low", "2021-01-01", "2021-01-07")).prices - hgh = src.fetch(Series("BTC", "AUD", "high", "2021-01-01", "2021-01-07")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 7) - ] - ) - - -def test_fetch_empty(src, type, crypto_ok, requests_mock): - requests_mock.add( - responses.GET, - fetch_url, - body="""{ - "data": { - "id": 1, - "name": "Bitcoin", - "symbol": "BTC", - "timeEnd": "1228348799", - "quotes": [] - }, - "status": { - "timestamp": "2024-08-03T09:31:52.719Z", - "error_code": "0", - "error_message": "SUCCESS", - "elapsed": "14", - "credit_count": 0 - } - }""", - ) - series = src.fetch(Series("BTC", "AUD", type, "2010-01-01", "2010-01-07")) - assert len(series.prices) == 0 - - -def test_fetch_bad_base_sym(src, type, crypto_ok): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("NOTABASE", "USD", type, "2021-01-01", "2021-01-07")) - assert "Invalid symbol 'NOTABASE'" in str(e.value) - - -def test_fetch_bad_quote_sym(src, type, crypto_ok): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("BTC", "NOTAQUOTE", type, "2021-01-01", "2021-01-07")) - assert "Invalid symbol 'NOTAQUOTE'" in str(e.value) - - -def test_fetch_bad_response(src, type, crypto_ok, requests_mock): - requests_mock.add( - responses.GET, - fetch_url, - status=200, - body="""{ - "status": { - "timestamp": "2024-08-03T09:42:43.699Z", - "error_code": "500", - "error_message": "The system is busy, please try again later!", - "elapsed": "0", - "credit_count": 0 - } - }""", - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("ID=987654321", "USD", type, "2021-01-01", "2021-01-07")) - assert "general error" in str(e.value) - - -def test_fetch_no_quote(src, type): - with pytest.raises(exceptions.InvalidPair): - src.fetch(Series("BTC", "", type, "2021-01-01", "2021-01-07")) - - -def test_fetch_network_issue(src, type, crypto_ok, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, fetch_url, body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - assert "Network issue" in str(e.value) - - -def test_fetch_bad_status(src, type, crypto_ok, requests_mock): - requests_mock.add(responses.GET, fetch_url, status=500, body="Some other reason") - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_parsing_error(src, type, crypto_ok, requests_mock): - requests_mock.add(responses.GET, fetch_url, body="NOT JSON") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - assert "while parsing data" in str(e.value) - - -def test_fetch_unexpected_json(src, type, crypto_ok, requests_mock): - requests_mock.add(responses.GET, fetch_url, body='{"notdata": []}') - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("BTC", "AUD", type, "2021-01-01", "2021-01-07")) - assert "Unexpected content" in str(e.value) diff --git a/tests/pricehist/sources/test_coinmarketcap/crypto-partial.json b/tests/pricehist/sources/test_coinmarketcap/crypto-partial.json deleted file mode 100644 index c1a27b5..0000000 --- a/tests/pricehist/sources/test_coinmarketcap/crypto-partial.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "status": { - "timestamp": "2021-07-16T10:08:28.938Z", - "error_code": 0, - "error_message": null, - "elapsed": 18, - "credit_count": 0, - "notice": null - }, - "data": [ - { - "id": 1, - "name": "Bitcoin", - "symbol": "BTC", - "slug": "bitcoin", - "rank": 1, - "is_active": 1, - "first_historical_data": "2013-04-28T18:47:21.000Z", - "last_historical_data": "2021-07-16T09:59:03.000Z", - "platform": null - }, - { - "id": 1027, - "name": "Ethereum", - "symbol": "ETH", - "slug": "ethereum", - "rank": 2, - "is_active": 1, - "first_historical_data": "2015-08-07T14:49:30.000Z", - "last_historical_data": "2021-07-16T09:59:04.000Z", - "platform": null - } - ] -} diff --git a/tests/pricehist/sources/test_coinmarketcap/recent-id1-id2782.json b/tests/pricehist/sources/test_coinmarketcap/recent-id1-id2782.json deleted file mode 100644 index e05e7bf..0000000 --- a/tests/pricehist/sources/test_coinmarketcap/recent-id1-id2782.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "data": { - "id": 1, - "name": "Bitcoin", - "symbol": "BTC", - "timeEnd": "1575503999", - "quotes": [ - { - "timeOpen": "2021-01-01T00:00:00.000Z", - "timeClose": "2021-01-01T23:59:59.999Z", - "timeHigh": "2021-01-01T12:38:43.000Z", - "timeLow": "2021-01-01T00:16:43.000Z", - "quote": { - "name": "2782", - "open": 37658.1146368474, - "high": 38417.9137031205, - "low": 37410.7875016392, - "close": 38181.9913330076, - "volume": 52901492931.8344367080, - "marketCap": 709159975413.2388897949, - "timestamp": "2021-01-01T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-02T00:00:00.000Z", - "timeClose": "2021-01-02T23:59:59.999Z", - "timeHigh": "2021-01-02T19:49:42.000Z", - "timeLow": "2021-01-02T00:31:44.000Z", - "quote": { - "name": "2782", - "open": 38184.9861160068, - "high": 43096.6811974230, - "low": 37814.1718709653, - "close": 41760.6292307951, - "volume": 88214867181.9830439141, - "marketCap": 776278147177.8037261338, - "timestamp": "2021-01-02T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-03T00:00:00.000Z", - "timeClose": "2021-01-03T23:59:59.999Z", - "timeHigh": "2021-01-03T07:47:38.000Z", - "timeLow": "2021-01-03T00:20:45.000Z", - "quote": { - "name": "2782", - "open": 41763.4101511766, - "high": 44985.9324758502, - "low": 41663.2043506016, - "close": 42534.0538859236, - "volume": 102253005977.1115650988, - "marketCap": 792140565709.1701340036, - "timestamp": "2021-01-03T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-04T00:00:00.000Z", - "timeClose": "2021-01-04T23:59:59.999Z", - "timeHigh": "2021-01-04T04:07:42.000Z", - "timeLow": "2021-01-04T10:19:42.000Z", - "quote": { - "name": "2782", - "open": 42548.6134964877, - "high": 43347.7527651400, - "low": 37111.8678479690, - "close": 41707.4890765162, - "volume": 105251252720.3013091567, - "marketCap": 770785910830.3801120744, - "timestamp": "2021-01-04T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-05T00:00:00.000Z", - "timeClose": "2021-01-05T23:59:59.999Z", - "timeHigh": "2021-01-05T22:44:35.000Z", - "timeLow": "2021-01-05T06:16:41.000Z", - "quote": { - "name": "2782", - "open": 41693.0732180764, - "high": 44406.6531914952, - "low": 39220.9654861842, - "close": 43777.4560620835, - "volume": 88071174132.6445648582, - "marketCap": 824003338903.4613958343, - "timestamp": "2021-01-05T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-06T00:00:00.000Z", - "timeClose": "2021-01-06T23:59:59.999Z", - "timeHigh": "2021-01-06T23:57:36.000Z", - "timeLow": "2021-01-06T00:25:38.000Z", - "quote": { - "name": "2782", - "open": 43798.3790529373, - "high": 47185.7303335186, - "low": 43152.6028176424, - "close": 47114.9330444897, - "volume": 96948095813.7503737302, - "marketCap": 881631993096.0701475336, - "timestamp": "2021-01-06T23:59:59.999Z" - } - }, - { - "timeOpen": "2021-01-07T00:00:00.000Z", - "timeClose": "2021-01-07T23:59:59.999Z", - "timeHigh": "2021-01-07T18:17:42.000Z", - "timeLow": "2021-01-07T08:25:51.000Z", - "quote": { - "name": "2782", - "open": 47128.0213932810, - "high": 51832.6746004172, - "low": 46906.6511713961, - "close": 50660.9643451606, - "volume": 108451040396.2660095877, - "marketCap": 936655898949.2177196744, - "timestamp": "2021-01-07T23:59:59.999Z" - } - } - ] - }, - "status": { - "timestamp": "2024-08-02T18:23:21.586Z", - "error_code": "0", - "error_message": "SUCCESS", - "elapsed": "212", - "credit_count": 0 - } -} diff --git a/tests/pricehist/sources/test_ecb.py b/tests/pricehist/sources/test_ecb.py index f081342..c68db11 100644 --- a/tests/pricehist/sources/test_ecb.py +++ b/tests/pricehist/sources/test_ecb.py @@ -24,22 +24,18 @@ def type(src): return src.types()[0] -@pytest.fixture -def url(): - return "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml" - - -@pytest.fixture -def url_90d(): - return "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml" - - @pytest.fixture def xml(): dir = Path(os.path.splitext(__file__)[0]) return (dir / "eurofxref-hist-partial.xml").read_text() +@pytest.fixture +def empty_xml(): + dir = Path(os.path.splitext(__file__)[0]) + return (dir / "eurofxref-hist-empty.xml").read_text() + + @pytest.fixture def requests_mock(): with responses.RequestsMock() as mock: @@ -47,23 +43,35 @@ def requests_mock(): @pytest.fixture -def response_ok(requests_mock, url, xml): - requests_mock.add(responses.GET, url, body=xml, status=200) +def response_ok(requests_mock, xml): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", + body=xml, + status=200, + ) yield requests_mock @pytest.fixture -def response_ok_90d(requests_mock, url_90d, xml): - requests_mock.add(responses.GET, url_90d, body=xml, status=200) +def response_ok_90d(requests_mock, xml): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml", + body=xml, + status=200, + ) yield requests_mock @pytest.fixture -def response_empty_xml(requests_mock, url): - empty_xml = ( - Path(os.path.splitext(__file__)[0]) / "eurofxref-hist-empty.xml" - ).read_text() - requests_mock.add(responses.GET, url, body=empty_xml, status=200) +def response_empty_xml(requests_mock, empty_xml): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", + body=empty_xml, + status=200, + ) yield requests_mock @@ -105,7 +113,7 @@ def test_symbols_requests_logged_for(src, response_ok, caplog): with caplog.at_level(logging.DEBUG): src.symbols() assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] + ["DEBUG" == r.levelname and " curl " in r.message for r in caplog.records] ) @@ -134,7 +142,7 @@ def test_fetch_requests_logged(src, response_ok, caplog): with caplog.at_level(logging.DEBUG): src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) assert any( - ["DEBUG" == r.levelname and "curl " in r.message for r in caplog.records] + ["DEBUG" == r.levelname and " curl " in r.message for r in caplog.records] ) @@ -190,23 +198,34 @@ def test_fetch_unknown_pair(src, type): src.fetch(Series("ABC", "XZY", type, "2021-01-04", "2021-01-08")) -def test_fetch_network_issue(src, type, requests_mock, url): - err = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, url, body=err) +def test_fetch_network_issue(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", + body=requests.exceptions.ConnectionError("Network issue"), + ) with pytest.raises(exceptions.RequestError) as e: src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) assert "Network issue" in str(e.value) -def test_fetch_bad_status(src, type, requests_mock, url): - requests_mock.add(responses.GET, url, status=500) +def test_fetch_bad_status(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", + status=500, + ) with pytest.raises(exceptions.BadResponse) as e: src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) assert "Server Error" in str(e.value) -def test_fetch_parsing_error(src, type, requests_mock, url): - requests_mock.add(responses.GET, url, body="NOT XML") +def test_fetch_parsing_error(src, type, requests_mock): + requests_mock.add( + responses.GET, + "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml", + body="NOT XML", + ) with pytest.raises(exceptions.ResponseParsingError) as e: src.fetch(Series("EUR", "AUD", type, "2021-01-04", "2021-01-08")) assert "while parsing data" in str(e.value) diff --git a/tests/pricehist/sources/test_yahoo.py b/tests/pricehist/sources/test_yahoo.py deleted file mode 100644 index d490d86..0000000 --- a/tests/pricehist/sources/test_yahoo.py +++ /dev/null @@ -1,258 +0,0 @@ -import logging -import os -from datetime import datetime, timezone -from decimal import Decimal -from pathlib import Path - -import pytest -import requests -import responses - -from pricehist import exceptions -from pricehist.price import Price -from pricehist.series import Series -from pricehist.sources.yahoo import Yahoo - - -def timestamp(date): - return int( - datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - ) - - -@pytest.fixture -def src(): - return Yahoo() - - -@pytest.fixture -def type(src): - return src.types()[0] - - -@pytest.fixture -def requests_mock(): - with responses.RequestsMock() as mock: - yield mock - - -def url(base): - return f"https://query1.finance.yahoo.com/v8/finance/chart/{base}" - - -@pytest.fixture -def recent_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "tsla-recent.json").read_text() - requests_mock.add(responses.GET, url("TSLA"), body=json, status=200) - yield requests_mock - - -@pytest.fixture -def long_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "ibm-long-partial.json").read_text() - requests_mock.add(responses.GET, url("IBM"), body=json, status=200) - yield requests_mock - - -@pytest.fixture -def with_null_ok(requests_mock): - json = (Path(os.path.splitext(__file__)[0]) / "inrx-with-null.json").read_text() - requests_mock.add(responses.GET, url("INR=X"), body=json, status=200) - yield requests_mock - - -def test_normalizesymbol(src): - assert src.normalizesymbol("tsla") == "TSLA" - - -def test_metadata(src): - assert isinstance(src.id(), str) - assert len(src.id()) > 0 - - assert isinstance(src.name(), str) - assert len(src.name()) > 0 - - assert isinstance(src.description(), str) - assert len(src.description()) > 0 - - assert isinstance(src.source_url(), str) - assert src.source_url().startswith("http") - - assert datetime.strptime(src.start(), "%Y-%m-%d") - - assert isinstance(src.types(), list) - assert len(src.types()) > 0 - assert isinstance(src.types()[0], str) - assert len(src.types()[0]) > 0 - - assert isinstance(src.notes(), str) - - -def test_symbols(src, caplog): - with caplog.at_level(logging.INFO): - symbols = src.symbols() - assert symbols == [] - assert any(["Find the symbol of interest on" in r.message for r in caplog.records]) - - -def test_fetch_known(src, type, recent_ok): - series = src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - req = recent_ok.calls[0].request - assert req.params["events"] == "capitalGain%7Cdiv%7Csplit" - assert req.params["includeAdjustedClose"] == "true" - assert (series.base, series.quote) == ("TSLA", "USD") - assert len(series.prices) == 5 - - -def test_fetch_requests_and_receives_correct_times(src, type, recent_ok): - series = src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - req = recent_ok.calls[0].request - assert req.params["period1"] == str(timestamp("2021-01-04")) - assert req.params["period2"] == str(timestamp("2021-01-09")) # rounded up one - assert req.params["interval"] == "1d" - assert series.prices[0] == Price("2021-01-04", Decimal("243.2566680908203125")) - assert series.prices[-1] == Price("2021-01-08", Decimal("293.339996337890625")) - - -def test_fetch_ignores_any_extra_row(src, type, recent_ok): - series = src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-07")) - assert series.prices[0] == Price("2021-01-04", Decimal("243.2566680908203125")) - assert series.prices[-1] == Price("2021-01-07", Decimal("272.013336181640625")) - - -def test_fetch_requests_logged(src, type, recent_ok, caplog): - with caplog.at_level(logging.DEBUG): - src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - logged_requests = 0 - for r in caplog.records: - if r.levelname == "DEBUG" and "curl " in r.message: - logged_requests += 1 - assert logged_requests == 1 - - -def test_fetch_types_all_available(src, recent_ok): - adj = src.fetch(Series("TSLA", "", "adjclose", "2021-01-04", "2021-01-08")) - opn = src.fetch(Series("TSLA", "", "open", "2021-01-04", "2021-01-08")) - hgh = src.fetch(Series("TSLA", "", "high", "2021-01-04", "2021-01-08")) - low = src.fetch(Series("TSLA", "", "low", "2021-01-04", "2021-01-08")) - cls = src.fetch(Series("TSLA", "", "close", "2021-01-04", "2021-01-08")) - mid = src.fetch(Series("TSLA", "", "mid", "2021-01-04", "2021-01-08")) - assert adj.prices[0].amount == Decimal("243.2566680908203125") - assert opn.prices[0].amount == Decimal("239.82000732421875") - assert hgh.prices[0].amount == Decimal("248.163330078125") - assert low.prices[0].amount == Decimal("239.0633392333984375") - assert cls.prices[0].amount == Decimal("243.2566680908203125") - assert mid.prices[0].amount == Decimal("243.61333465576171875") - - -def test_fetch_type_mid_is_mean_of_low_and_high(src, recent_ok): - mid = src.fetch(Series("TSLA", "", "mid", "2021-01-04", "2021-01-08")).prices - hgh = src.fetch(Series("TSLA", "", "high", "2021-01-04", "2021-01-08")).prices - low = src.fetch(Series("TSLA", "", "low", "2021-01-04", "2021-01-08")).prices - assert all( - [ - mid[i].amount == (sum([low[i].amount, hgh[i].amount]) / 2) - for i in range(0, 5) - ] - ) - - -def test_fetch_from_before_start(src, type, long_ok): - series = src.fetch(Series("IBM", "", type, "1900-01-01", "2021-01-08")) - assert series.prices[0] == Price("1962-01-02", Decimal("1.5133211612701416015625")) - assert series.prices[-1] == Price("2021-01-08", Decimal("103.2923736572265625")) - assert len(series.prices) > 9 - - -def test_fetch_skips_dates_with_nulls(src, type, with_null_ok): - series = src.fetch(Series("INR=X", "", type, "2017-07-10", "2017-07-12")) - assert series.prices[0] == Price("2017-07-10", Decimal("64.61170196533203125")) - assert series.prices[1] == Price("2017-07-12", Decimal("64.52559661865234375")) - assert len(series.prices) == 2 - - -def test_fetch_to_future(src, type, recent_ok): - series = src.fetch(Series("TSLA", "", type, "2021-01-04", "2100-01-08")) - assert len(series.prices) > 0 - - -def test_fetch_no_data_in_past(src, type, requests_mock): - requests_mock.add( - responses.GET, - url("TSLA"), - status=400, - body=( - "400 Bad Request: Data doesn't exist for " - "startDate = 1262304000, endDate = 1262995200" - ), - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("TSLA", "", type, "2010-01-04", "2010-01-08")) - assert "No data for the given interval" in str(e.value) - - -def test_fetch_no_data_in_future(src, type, requests_mock): - requests_mock.add( - responses.GET, - url("TSLA"), - status=400, - body=( - "400 Bad Request: Data doesn't exist for " - "startDate = 1893715200, endDate = 1894147200" - ), - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("TSLA", "", type, "2030-01-04", "2030-01-08")) - assert "No data for the given interval" in str(e.value) - - -def test_fetch_no_data_on_weekend(src, type, requests_mock): - requests_mock.add( - responses.GET, - url("TSLA"), - status=404, - body="404 Not Found: Timestamp data missing.", - ) - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("TSLA", "", type, "2021-01-09", "2021-01-10")) - assert "may be for a gap in the data" in str(e.value) - - -def test_fetch_bad_sym(src, type, requests_mock): - requests_mock.add( - responses.GET, - url("NOTABASE"), - status=404, - body="404 Not Found: No data found, symbol may be delisted", - ) - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("NOTABASE", "", type, "2021-01-04", "2021-01-08")) - assert "Symbol not found" in str(e.value) - - -def test_fetch_giving_quote(src, type): - with pytest.raises(exceptions.InvalidPair) as e: - src.fetch(Series("TSLA", "USD", type, "2021-01-04", "2021-01-08")) - assert "quote currency" in str(e.value) - - -def test_fetch_network_issue(src, type, requests_mock): - body = requests.exceptions.ConnectionError("Network issue") - requests_mock.add(responses.GET, url("TSLA"), body=body) - with pytest.raises(exceptions.RequestError) as e: - src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - assert "Network issue" in str(e.value) - - -def test_fetch_bad_status(src, type, requests_mock): - requests_mock.add(responses.GET, url("TSLA"), status=500, body="Some other reason") - with pytest.raises(exceptions.BadResponse) as e: - src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - assert "Internal Server Error" in str(e.value) - - -def test_fetch_parsing_error(src, type, requests_mock): - requests_mock.add(responses.GET, url("TSLA"), body="") - with pytest.raises(exceptions.ResponseParsingError) as e: - src.fetch(Series("TSLA", "", type, "2021-01-04", "2021-01-08")) - assert "error occurred while parsing data from the source" in str(e.value) diff --git a/tests/pricehist/sources/test_yahoo/ibm-long-partial.json b/tests/pricehist/sources/test_yahoo/ibm-long-partial.json deleted file mode 100644 index df98efa..0000000 --- a/tests/pricehist/sources/test_yahoo/ibm-long-partial.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "chart": { - "result": [ - { - "meta": { - "currency": "USD", - "symbol": "IBM", - "exchangeName": "NYQ", - "fullExchangeName": "NYSE", - "instrumentType": "EQUITY", - "firstTradeDate": -252322200, - "regularMarketTime": 1726257602, - "hasPrePostMarketData": true, - "gmtoffset": -14400, - "timezone": "EDT", - "exchangeTimezoneName": "America/New_York", - "regularMarketPrice": 214.79, - "fiftyTwoWeekHigh": 216.08, - "fiftyTwoWeekLow": 212.13, - "regularMarketDayHigh": 216.08, - "regularMarketDayLow": 212.13, - "regularMarketVolume": 4553547, - "longName": "International Business Machines Corporation", - "shortName": "International Business Machines", - "chartPreviousClose": 7.291, - "priceHint": 2, - "currentTradingPeriod": { - "pre": { - "timezone": "EDT", - "end": 1726234200, - "start": 1726214400, - "gmtoffset": -14400 - }, - "regular": { - "timezone": "EDT", - "end": 1726257600, - "start": 1726234200, - "gmtoffset": -14400 - }, - "post": { - "timezone": "EDT", - "end": 1726272000, - "start": 1726257600, - "gmtoffset": -14400 - } - }, - "dataGranularity": "1d", - "range": "", - "validRanges": [ - "1d", - "5d", - "1mo", - "3mo", - "6mo", - "1y", - "2y", - "5y", - "10y", - "ytd", - "max" - ] - }, - "timestamp": [ - -252322200, - -252235800, - -252149400, - -252063000, - -251803800, - 1609770600, - 1609857000, - 1609943400, - 1610029800, - 1610116200 - ], - "events": { - "dividends": { - "-249298200": { - "amount": 0.000956, - "date": -249298200 - }, - "-241439400": { - "amount": 0.000956, - "date": -241439400 - }, - "-233577000": { - "amount": 0.000956, - "date": -233577000 - }, - "-225797400": { - "amount": 0.000956, - "date": -225797400 - }, - "-217848600": { - "amount": 0.001275, - "date": -217848600 - }, - "1573137000": { - "amount": 1.548757, - "date": 1573137000 - }, - "1581085800": { - "amount": 1.548757, - "date": 1581085800 - }, - "1588858200": { - "amount": 1.558317, - "date": 1588858200 - }, - "1596807000": { - "amount": 1.558317, - "date": 1596807000 - }, - "1604932200": { - "amount": 1.558317, - "date": 1604932200 - } - }, - "splits": { - "-177417000": { - "date": -177417000, - "numerator": 5.0, - "denominator": 4.0, - "splitRatio": "5:4" - }, - "-114345000": { - "date": -114345000, - "numerator": 3.0, - "denominator": 2.0, - "splitRatio": "3:2" - }, - "-53343000": { - "date": -53343000, - "numerator": 2.0, - "denominator": 1.0, - "splitRatio": "2:1" - }, - "107530200": { - "date": 107530200, - "numerator": 5.0, - "denominator": 4.0, - "splitRatio": "5:4" - }, - "297091800": { - "date": 297091800, - "numerator": 4.0, - "denominator": 1.0, - "splitRatio": "4:1" - }, - "864826200": { - "date": 864826200, - "numerator": 2.0, - "denominator": 1.0, - "splitRatio": "2:1" - }, - "927811800": { - "date": 927811800, - "numerator": 2.0, - "denominator": 1.0, - "splitRatio": "2:1" - } - } - }, - "indicators": { - "quote": [ - { - "close": [ - 7.2912678718566895, - 7.3550028800964355, - 7.281707763671875, - 7.138305187225342, - 7.00446081161499, - 118.48948669433594, - 120.59273529052734, - 123.60420989990234, - 123.31739807128906, - 122.87763214111328 - ], - "low": [ - 7.2912678718566895, - 7.2912678718566895, - 7.2785210609436035, - 7.125557899475098, - 6.9471001625061035, - 117.62906646728516, - 119.13002014160156, - 121.14722442626953, - 122.61949920654297, - 121.39579010009766 - ], - "open": [ - 7.374124050140381, - 7.2912678718566895, - 7.3550028800964355, - 7.272148132324219, - 7.131930828094482, - 120.31549072265625, - 119.5124282836914, - 121.3193130493164, - 124.32122039794922, - 122.9158706665039 - ], - "high": [ - 7.374124050140381, - 7.3550028800964355, - 7.3550028800964355, - 7.272148132324219, - 7.131930828094482, - 120.38240814208984, - 121.1089859008789, - 126.08030700683594, - 124.7227554321289, - 123.63288879394531 - ], - "volume": [ - 407940, - 305955, - 274575, - 384405, - 572685, - 5417443, - 6395872, - 8322708, - 4714740, - 4891305 - ] - } - ], - "adjclose": [ - { - "adjclose": [ - 1.5133211612701416, - 1.5265485048294067, - 1.5113375186920166, - 1.4815733432769775, - 1.4537923336029053, - 99.60364532470703, - 101.37164306640625, - 103.90313720703125, - 103.66202545166016, - 103.29237365722656 - ] - } - ] - } - } - ], - "error": null - } -} diff --git a/tests/pricehist/sources/test_yahoo/inrx-with-null.json b/tests/pricehist/sources/test_yahoo/inrx-with-null.json deleted file mode 100644 index 5ae762e..0000000 --- a/tests/pricehist/sources/test_yahoo/inrx-with-null.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "chart": { - "result": [ - { - "meta": { - "currency": "INR", - "symbol": "INR=X", - "exchangeName": "CCY", - "fullExchangeName": "CCY", - "instrumentType": "CURRENCY", - "firstTradeDate": 1070236800, - "regularMarketTime": 1726284616, - "hasPrePostMarketData": false, - "gmtoffset": 3600, - "timezone": "BST", - "exchangeTimezoneName": "Europe/London", - "regularMarketPrice": 83.89, - "fiftyTwoWeekHigh": 83.89, - "fiftyTwoWeekLow": 83.89, - "regularMarketDayHigh": 83.89, - "regularMarketDayLow": 83.89, - "regularMarketVolume": 0, - "longName": "USD/INR", - "shortName": "USD/INR", - "chartPreviousClose": 64.6117, - "priceHint": 4, - "currentTradingPeriod": { - "pre": { - "timezone": "BST", - "start": 1726182000, - "end": 1726182000, - "gmtoffset": 3600 - }, - "regular": { - "timezone": "BST", - "start": 1726182000, - "end": 1726268340, - "gmtoffset": 3600 - }, - "post": { - "timezone": "BST", - "start": 1726268340, - "end": 1726268340, - "gmtoffset": 3600 - } - }, - "dataGranularity": "1d", - "range": "", - "validRanges": [ - "1d", - "5d", - "1mo", - "3mo", - "6mo", - "1y", - "2y", - "5y", - "10y", - "ytd", - "max" - ] - }, - "timestamp": [ - 1499641200, - 1499727600, - 1499814000, - 1499900400 - ], - "indicators": { - "quote": [ - { - "open": [ - 64.6155014038086, - null, - 64.55549621582031, - 64.46800231933594 - ], - "volume": [ - 0, - null, - 0, - 0 - ], - "low": [ - 64.41000366210938, - null, - 64.3499984741211, - 64.33999633789062 - ], - "close": [ - 64.61170196533203, - null, - 64.52559661865234, - 64.36499786376953 - ], - "high": [ - 64.6155014038086, - null, - 64.56999969482422, - 64.48419952392578 - ] - } - ], - "adjclose": [ - { - "adjclose": [ - 64.61170196533203, - null, - 64.52559661865234, - 64.36499786376953 - ] - } - ] - } - } - ], - "error": null - } -} diff --git a/tests/pricehist/sources/test_yahoo/tsla-recent.json b/tests/pricehist/sources/test_yahoo/tsla-recent.json deleted file mode 100644 index 3f35daa..0000000 --- a/tests/pricehist/sources/test_yahoo/tsla-recent.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "chart": { - "result": [ - { - "meta": { - "currency": "USD", - "symbol": "TSLA", - "exchangeName": "NMS", - "fullExchangeName": "NasdaqGS", - "instrumentType": "EQUITY", - "firstTradeDate": 1277818200, - "regularMarketTime": 1726257600, - "hasPrePostMarketData": true, - "gmtoffset": -14400, - "timezone": "EDT", - "exchangeTimezoneName": "America/New_York", - "regularMarketPrice": 230.29, - "fiftyTwoWeekHigh": 232.664, - "fiftyTwoWeekLow": 226.32, - "regularMarketDayHigh": 232.664, - "regularMarketDayLow": 226.32, - "regularMarketVolume": 59096538, - "longName": "Tesla, Inc.", - "shortName": "Tesla, Inc.", - "chartPreviousClose": 235.223, - "priceHint": 2, - "currentTradingPeriod": { - "pre": { - "timezone": "EDT", - "start": 1726214400, - "end": 1726234200, - "gmtoffset": -14400 - }, - "regular": { - "timezone": "EDT", - "start": 1726234200, - "end": 1726257600, - "gmtoffset": -14400 - }, - "post": { - "timezone": "EDT", - "start": 1726257600, - "end": 1726272000, - "gmtoffset": -14400 - } - }, - "dataGranularity": "1d", - "range": "", - "validRanges": [ - "1d", - "5d", - "1mo", - "3mo", - "6mo", - "1y", - "2y", - "5y", - "10y", - "ytd", - "max" - ] - }, - "timestamp": [ - 1609770600, - 1609857000, - 1609943400, - 1610029800, - 1610116200 - ], - "indicators": { - "quote": [ - { - "open": [ - 239.82000732421875, - 241.22000122070312, - 252.8300018310547, - 259.2099914550781, - 285.3333435058594 - ], - "close": [ - 243.2566680908203, - 245.0366668701172, - 251.9933319091797, - 272.0133361816406, - 293.3399963378906 - ], - "high": [ - 248.163330078125, - 246.94667053222656, - 258.0, - 272.3299865722656, - 294.8299865722656 - ], - "low": [ - 239.06333923339844, - 239.73333740234375, - 249.6999969482422, - 258.3999938964844, - 279.46331787109375 - ], - "volume": [ - 145914600, - 96735600, - 134100000, - 154496700, - 225166500 - ] - } - ], - "adjclose": [ - { - "adjclose": [ - 243.2566680908203, - 245.0366668701172, - 251.9933319091797, - 272.0133361816406, - 293.3399963378906 - ] - } - ] - } - } - ], - "error": null - } -} diff --git a/tests/pricehist/test_beanprice.py b/tests/pricehist/test_beanprice.py deleted file mode 100644 index 9793752..0000000 --- a/tests/pricehist/test_beanprice.py +++ /dev/null @@ -1,141 +0,0 @@ -import importlib -from datetime import date, datetime, timedelta, timezone -from decimal import Decimal - -import pytest - -from pricehist import beanprice, exceptions, sources -from pricehist.price import Price -from pricehist.series import Series - - -@pytest.fixture -def series(): - series = Series( - "BTC", - "USD", - "high", - "2021-01-01", - "2021-01-03", - prices=[ - Price("2021-01-01", Decimal("1.1")), - Price("2021-01-02", Decimal("1.2")), - Price("2021-01-03", Decimal("1.3")), - ], - ) - return series - - -@pytest.fixture -def pricehist_source(mocker, series): - mock = mocker.MagicMock() - mock.types = mocker.MagicMock(return_value=["close", "high", "low"]) - mock.fetch = mocker.MagicMock(return_value=series) - return mock - - -@pytest.fixture -def source(pricehist_source): - return beanprice.source(pricehist_source)() - - -@pytest.fixture -def ltz(): - return datetime.now(timezone.utc).astimezone().tzinfo - - -def test_get_prices_series(pricehist_source, source, ltz): - ticker = "BTC:USD:high" - begin = datetime(2021, 1, 1, tzinfo=ltz) - end = datetime(2021, 1, 3, tzinfo=ltz) - result = source.get_prices_series(ticker, begin, end) - - pricehist_source.fetch.assert_called_once_with( - Series("BTC", "USD", "high", "2021-01-01", "2021-01-03") - ) - - assert result == [ - beanprice.SourcePrice(Decimal("1.1"), datetime(2021, 1, 1, tzinfo=ltz), "USD"), - beanprice.SourcePrice(Decimal("1.2"), datetime(2021, 1, 2, tzinfo=ltz), "USD"), - beanprice.SourcePrice(Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD"), - ] - - -def test_get_prices_series_exception(pricehist_source, source, ltz, mocker): - pricehist_source.fetch = mocker.MagicMock( - side_effect=exceptions.RequestError("Message") - ) - ticker = "_5eDJI::low" - begin = datetime(2021, 1, 1, tzinfo=ltz) - end = datetime(2021, 1, 3, tzinfo=ltz) - result = source.get_prices_series(ticker, begin, end) - assert result is None - - -def test_get_prices_series_special_chars(pricehist_source, source, ltz): - ticker = "_5eDJI::low" - begin = datetime(2021, 1, 1, tzinfo=ltz) - end = datetime(2021, 1, 3, tzinfo=ltz) - source.get_prices_series(ticker, begin, end) - pricehist_source.fetch.assert_called_once_with( - Series("^DJI", "", "low", "2021-01-01", "2021-01-03") - ) - - -def test_get_prices_series_price_type(pricehist_source, source, ltz): - ticker = "TSLA" - begin = datetime(2021, 1, 1, tzinfo=ltz) - end = datetime(2021, 1, 3, tzinfo=ltz) - source.get_prices_series(ticker, begin, end) - pricehist_source.fetch.assert_called_once_with( - Series("TSLA", "", "close", "2021-01-01", "2021-01-03") - ) - - -def test_get_historical_price(pricehist_source, source, ltz): - ticker = "BTC:USD:high" - time = datetime(2021, 1, 3, tzinfo=ltz) - result = source.get_historical_price(ticker, time) - pricehist_source.fetch.assert_called_once_with( - Series("BTC", "USD", "high", "2021-01-03", "2021-01-03") - ) - assert result == beanprice.SourcePrice( - Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD" - ) - - -def test_get_historical_price_none_available(pricehist_source, source, ltz, mocker): - pricehist_source.fetch = mocker.MagicMock( - return_value=Series("BTC", "USD", "high", "2021-01-03", "2021-01-03", prices=[]) - ) - ticker = "BTC:USD:high" - time = datetime(2021, 1, 3, tzinfo=ltz) - result = source.get_historical_price(ticker, time) - assert result is None - - -def test_get_latest_price(pricehist_source, source, ltz): - ticker = "BTC:USD:high" - start = datetime.combine((date.today() - timedelta(days=7)), datetime.min.time()) - today = datetime.combine(date.today(), datetime.min.time()) - result = source.get_latest_price(ticker) - pricehist_source.fetch.assert_called_once_with( - Series("BTC", "USD", "high", start.date().isoformat(), today.date().isoformat()) - ) - assert result == beanprice.SourcePrice( - Decimal("1.3"), datetime(2021, 1, 3, tzinfo=ltz), "USD" - ) - - -def test_get_latest_price_none_available(pricehist_source, source, ltz, mocker): - pricehist_source.fetch = mocker.MagicMock( - return_value=Series("BTC", "USD", "high", "2021-01-01", "2021-01-03", prices=[]) - ) - ticker = "BTC:USD:high" - result = source.get_latest_price(ticker) - assert result is None - - -def test_all_sources_available_for_beanprice(): - for identifier in sources.by_id.keys(): - importlib.import_module(f"pricehist.beanprice.{identifier}").Source() diff --git a/tests/pricehist/test_cli.py b/tests/pricehist/test_cli.py deleted file mode 100644 index 0c4d8ef..0000000 --- a/tests/pricehist/test_cli.py +++ /dev/null @@ -1,166 +0,0 @@ -import argparse - -import pytest - -from pricehist import __version__, cli, sources - - -def w(string): - return string.split(" ") - - -def test_valid_pair(): - assert cli.valid_pair("BTC/AUD") == ("BTC", "AUD") - assert cli.valid_pair("BTC/AUD/ignored") == ("BTC", "AUD") - assert cli.valid_pair("SYM") == ("SYM", "") - assert cli.valid_pair("SYM/") == ("SYM", "") - with pytest.raises(argparse.ArgumentTypeError): - cli.valid_pair("/SYM") - with pytest.raises(argparse.ArgumentTypeError): - cli.valid_pair("") - - -def test_valid_date(): - assert cli.valid_date("today") == cli.today() - assert cli.valid_date("2021-12-30") == "2021-12-30" - with pytest.raises(argparse.ArgumentTypeError) as e: - cli.valid_date("2021-12-40") - assert "Not a valid" in str(e.value) - - -def test_valid_date_before(): - assert cli.valid_date_before("2021-12-30") == "2021-12-29" - with pytest.raises(argparse.ArgumentTypeError) as e: - cli.valid_date_before("2021-12-40") - assert "Not a valid" in str(e.value) - - -def test_valid_date_after(): - assert cli.valid_date_after("2021-12-30") == "2021-12-31" - with pytest.raises(argparse.ArgumentTypeError) as e: - cli.valid_date_after("2021-12-40") - assert "Not a valid" in str(e.value) - - -def test_valid_char(): - assert cli.valid_char(",") == "," - with pytest.raises(argparse.ArgumentTypeError): - cli.valid_char("") - with pytest.raises(argparse.ArgumentTypeError): - cli.valid_char("12") - - -def test_cli_no_args_shows_usage(capfd): - cli.cli(w("pricehist")) - out, err = capfd.readouterr() - assert "usage: pricehist" in out - assert "optional arguments:" in out or "options:" in out - assert "commands:" in out - - -def test_cli_help_shows_usage_and_exits(capfd): - with pytest.raises(SystemExit) as e: - cli.cli(w("pricehist -h")) - assert e.value.code == 0 - out, err = capfd.readouterr() - assert "usage: pricehist" in out - assert "optional arguments:" in out or "options:" in out - assert "commands:" in out - - -def test_cli_verbose(capfd, mocker): - cli.cli(w("pricehist --verbose")) - out, err = capfd.readouterr() - assert "Ended pricehist run at" in err - - -def test_cli_version(capfd): - cli.cli(w("pricehist --version")) - out, err = capfd.readouterr() - assert f"pricehist {__version__}\n" == out - - -def test_cli_sources(capfd): - cli.cli(w("pricehist sources")) - out, err = capfd.readouterr() - for source_id in sources.by_id.keys(): - assert source_id in out - - -def test_cli_source(capfd): - expected = sources.by_id["ecb"].format_info() + "\n" - cli.cli(w("pricehist source ecb")) - out, err = capfd.readouterr() - assert out == expected - - -def test_cli_source_symbols(capfd, mocker): - sources.by_id["ecb"].symbols = mocker.MagicMock( - return_value=[("EUR/AUD", "Euro against Australian Dollar")] - ) - cli.cli(w("pricehist source ecb --symbols")) - out, err = capfd.readouterr() - assert out == "EUR/AUD Euro against Australian Dollar\n" - - -def test_cli_source_search(capfd, mocker): - sources.by_id["alphavantage"].search = mocker.MagicMock( - return_value=[("TSLA", "Tesla Inc, Equity, United States, USD")] - ) - cli.cli(w("pricehist source alphavantage --search TSLA")) - out, err = capfd.readouterr() - assert out == "TSLA Tesla Inc, Equity, United States, USD\n" - - -def test_cli_source_fetch(capfd, mocker): - formatted_result = "P 2021-01-01 00:00:00 BTC 24139.4648 EUR\n" - cli.fetch = mocker.MagicMock(return_value=formatted_result) - argv = w("pricehist fetch coindesk BTC/EUR -s 2021-01-01 -e 2021-01-01 -o ledger") - cli.cli(argv) - out, err = capfd.readouterr() - assert out == formatted_result - - -def test_cli_source_fetch_invalid_start(capfd, mocker): - argv = w("pricehist fetch coindesk BTC/EUR -s 2021-01-01 -e 2020-12-01") - with pytest.raises(SystemExit) as e: - cli.cli(argv) - assert e.value.code != 0 - out, err = capfd.readouterr() - assert "end date '2020-12-01' preceeds the start date" in err - - -def test_cli_source_fetch_invalid_type(capfd, mocker): - argv = w("pricehist fetch coindesk BTC/EUR -t notype") - with pytest.raises(SystemExit) as e: - cli.cli(argv) - assert e.value.code != 0 - out, err = capfd.readouterr() - assert "price type 'notype' is not recognized" in err - - -def test_cli_source_fetch_sets_source_defaults(mocker): - cli.fetch = mocker.MagicMock(return_value="") - cli.cli(w("pricehist fetch coindesk BTC/EUR")) - captured_series = cli.fetch.call_args.args[0] - assert captured_series.start == sources.by_id["coindesk"].start() - assert captured_series.type == sources.by_id["coindesk"].types()[0] - - -def test_cli_source_fetch_normalizes_symbols(mocker): - cli.fetch = mocker.MagicMock(return_value="") - cli.cli(w("pricehist fetch coindesk btc/eur")) - captured_series = cli.fetch.call_args.args[0] - assert captured_series.base == "BTC" - assert captured_series.quote == "EUR" - - -def test_cli_source_fetch_handles_brokenpipeerror(caplog, mocker): - cli.fetch = mocker.MagicMock(side_effect=BrokenPipeError()) - cli.cli(w("pricehist fetch coindesk BTC/EUR --verbose")) - assert any( - [ - "DEBUG" == r.levelname and "output pipe was closed early" in r.message - for r in caplog.records - ] - ) diff --git a/tests/pricehist/test_exceptions.py b/tests/pricehist/test_exceptions.py deleted file mode 100644 index 525cbad..0000000 --- a/tests/pricehist/test_exceptions.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -import pytest - -from pricehist import exceptions - - -def test_handler_logs_debug_information(caplog): - with caplog.at_level(logging.DEBUG): - try: - with exceptions.handler(): - raise exceptions.RequestError("Some message") - except SystemExit: - pass - - assert caplog.records[0].levelname == "DEBUG" - assert "exception encountered" in caplog.records[0].message - assert caplog.records[0].exc_info - - -def test_handler_exits_nonzero(caplog): - with pytest.raises(SystemExit) as e: - with exceptions.handler(): - raise exceptions.RequestError("Some message") - - assert e.value.code == 1 - - -def test_handler_logs_critical_information(caplog): - with caplog.at_level(logging.CRITICAL): - try: - with exceptions.handler(): - raise exceptions.RequestError("Some message") - except SystemExit: - pass - - assert any( - [ - "CRITICAL" == r.levelname and "Some message" in r.message - for r in caplog.records - ] - ) diff --git a/tests/pricehist/test_fetch.py b/tests/pricehist/test_fetch.py deleted file mode 100644 index 30cda76..0000000 --- a/tests/pricehist/test_fetch.py +++ /dev/null @@ -1,199 +0,0 @@ -import logging -from datetime import date, timedelta -from decimal import Decimal - -import pytest - -from pricehist import exceptions -from pricehist.fetch import fetch -from pricehist.format import Format -from pricehist.price import Price -from pricehist.series import Series -from pricehist.sources.basesource import BaseSource - - -@pytest.fixture -def res_series(mocker): - series = mocker.MagicMock() - series.start = "2021-01-01" - series.end = "2021-01-03" - return series - - -@pytest.fixture -def source(res_series, mocker): - source = mocker.MagicMock(BaseSource) - source.start = mocker.MagicMock(return_value="2021-01-01") - source.fetch = mocker.MagicMock(return_value=res_series) - return source - - -@pytest.fixture -def output(mocker): - output = mocker.MagicMock() - output.format = mocker.MagicMock(return_value="") - return output - - -@pytest.fixture -def fmt(): - return Format() - - -def test_fetch_warns_if_start_before_source_start(source, output, fmt, mocker, caplog): - req_series = Series("BTC", "EUR", "close", "2020-12-31", "2021-01-03") - source.start = mocker.MagicMock(return_value="2021-01-01") - with caplog.at_level(logging.INFO): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - assert any( - [ - "WARNING" == r.levelname and "start date 2020-12-31 preceeds" in r.message - for r in caplog.records - ] - ) - - -def test_fetch_returns_formatted_output(source, res_series, output, fmt, mocker): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - output.format = mocker.MagicMock(return_value="rendered output") - - result = fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - - output.format.assert_called_once_with(res_series, source, fmt=fmt) - assert result == "rendered output" - - -def test_fetch_inverts_if_requested(source, res_series, output, fmt, mocker): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - inv_series = mocker.MagicMock() - res_series.invert = mocker.MagicMock(return_value=inv_series) - - fetch(req_series, source, output, invert=True, quantize=None, fmt=fmt) - - res_series.invert.assert_called_once_with() - output.format.assert_called_once_with(inv_series, source, fmt=fmt) - - -def test_fetch_quantizes_if_requested(source, res_series, output, fmt, mocker): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - qnt_series = mocker.MagicMock() - res_series.quantize = mocker.MagicMock(return_value=qnt_series) - - fetch(req_series, source, output, invert=False, quantize=2, fmt=fmt) - - res_series.quantize.assert_called_once_with(2) - output.format.assert_called_once_with(qnt_series, source, fmt=fmt) - - -def test_fetch_warns_if_no_data(source, res_series, output, fmt, mocker, caplog): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - res_series.prices = mocker.MagicMock(return_value=[]) - with caplog.at_level(logging.INFO): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - assert any( - [ - "WARNING" == r.levelname and "No data found" in r.message - for r in caplog.records - ] - ) - - -def test_fetch_warns_if_missing_data_at_start(source, res_series, output, fmt, caplog): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - res_series.prices = [ - Price("2021-01-02", Decimal("1.2")), - Price("2021-01-03", Decimal("1.3")), - ] - with caplog.at_level(logging.INFO): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - r = caplog.records[0] - assert r.levelname == "WARNING" - assert r.message == ( - "Available data covers the interval [2021-01-02--2021-01-03], " - "which starts 1 day later than requested." - ) - - -def test_fetch_warns_if_missing_data_at_end(source, res_series, output, fmt, caplog): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - res_series.prices = [Price("2021-01-01", Decimal("1.1"))] - with caplog.at_level(logging.INFO): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - r = caplog.records[0] - assert r.levelname == "WARNING" - assert r.message == ( - "Available data covers the interval [2021-01-01--2021-01-01], " - "which ends 2 days earlier than requested." - ) - - -def test_fetch_warns_if_missing_data_at_both_ends( - source, res_series, output, fmt, caplog -): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - res_series.prices = [Price("2021-01-02", Decimal("1.2"))] - with caplog.at_level(logging.INFO): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - r = caplog.records[0] - assert r.levelname == "WARNING" - assert r.message == ( - "Available data covers the interval [2021-01-02--2021-01-02], " - "which starts 1 day later and ends 1 day earlier than requested." - ) - - -def test_fetch_debug_not_warning_message_if_only_today_missing( - source, res_series, output, fmt, caplog -): - start = (date.today() - timedelta(days=2)).isoformat() - yesterday = (date.today() - timedelta(days=1)).isoformat() - today = date.today().isoformat() - req_series = Series("BTC", "EUR", "close", start, today) - res_series.start = start - res_series.end = today - res_series.prices = [Price(start, Decimal("1.1")), Price(yesterday, Decimal("1.2"))] - with caplog.at_level(logging.DEBUG): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - r = caplog.records[0] - assert r.levelname == "DEBUG" - assert r.message == ( - f"Available data covers the interval [{start}--{yesterday}], " - "which ends 1 day earlier than requested." - ) - - -def test_fetch_debug_not_warning_message_if_as_requested( - source, res_series, output, fmt, caplog -): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - res_series.prices = [ - Price("2021-01-01", Decimal("1.1")), - Price("2021-01-02", Decimal("1.2")), - Price("2021-01-03", Decimal("1.3")), - ] - with caplog.at_level(logging.DEBUG): - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - r = caplog.records[0] - assert r.levelname == "DEBUG" - assert r.message == ( - "Available data covers the interval [2021-01-01--2021-01-03], as requested." - ) - - -def test_fetch_handles_source_exceptions(source, output, fmt, mocker, caplog): - req_series = Series("BTC", "EUR", "close", "2021-01-01", "2021-01-03") - - def side_effect(_): - raise exceptions.RequestError("something strange") - - source.fetch = mocker.MagicMock(side_effect=side_effect) - - with caplog.at_level(logging.INFO): - with pytest.raises(SystemExit) as e: - fetch(req_series, source, output, invert=False, quantize=None, fmt=fmt) - - r = caplog.records[0] - assert r.levelname == "CRITICAL" - assert "something strange" in r.message - - assert e.value.code == 1 diff --git a/tests/pricehist/test_format.py b/tests/pricehist/test_format.py deleted file mode 100644 index e816850..0000000 --- a/tests/pricehist/test_format.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections import namedtuple -from decimal import Decimal - -from pricehist.format import Format - - -def test_fromargs(): - arg_values = { - "formatquote": None, - "formattime": "23:59:59", - "formatdecimal": None, - "formatthousands": None, - "formatsymbol": None, - "formatdatesep": None, - "formatcsvdelim": None, - "formatbase": None, - "formatjsonnums": None, - } - args = namedtuple("args", arg_values.keys())(**arg_values) - fmt = Format.fromargs(args) - assert fmt.time == "23:59:59" - assert fmt.symbol == "rightspace" - - -def test_format_date(): - assert Format().format_date("2021-01-01") == "2021-01-01" - assert Format(datesep="/").format_date("2021-01-01") == "2021/01/01" - - -def test_format_quote_amount(): - assert ( - Format(decimal=",").format_quote_amount("USD", Decimal("1234.5678")) - == "1234,5678 USD" - ) - assert ( - Format(symbol="rightspace").format_quote_amount("USD", Decimal("1234.5678")) - == "1234.5678 USD" - ) - assert ( - Format(symbol="right").format_quote_amount("€", Decimal("1234.5678")) - == "1234.5678€" - ) - assert ( - Format(symbol="leftspace").format_quote_amount("£", Decimal("1234.5678")) - == "£ 1234.5678" - ) - assert ( - Format(symbol="left").format_quote_amount("$", Decimal("1234.5678")) - == "$1234.5678" - ) - - -def test_format_num(): - assert Format().format_num(Decimal("1234.5678")) == "1234.5678" - assert ( - Format(decimal=",", thousands=".").format_num(Decimal("1234.5678")) - == "1.234,5678" - ) diff --git a/tests/pricehist/test_isocurrencies.py b/tests/pricehist/test_isocurrencies.py deleted file mode 100644 index 5e79c97..0000000 --- a/tests/pricehist/test_isocurrencies.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime - -from pricehist import isocurrencies - - -def test_current(): - currency = isocurrencies.by_code()["EUR"] - assert currency.code == "EUR" - assert currency.number == 978 - assert currency.minor_units == 2 - assert currency.name == "Euro" - assert "GERMANY" in currency.countries - assert "FRANCE" in currency.countries - assert not currency.is_fund - assert not currency.historical - assert not currency.withdrawal_date - - -def test_historical(): - currency = isocurrencies.by_code()["DEM"] - assert currency.code == "DEM" - assert currency.number == 276 - assert currency.minor_units is None - assert currency.name == "Deutsche Mark" - assert "GERMANY" in currency.countries - assert not currency.is_fund - assert currency.historical - assert currency.withdrawal_date == "2002-03" - - -def test_data_dates(): - assert datetime.strptime(isocurrencies.current_data_date(), "%Y-%m-%d") - assert datetime.strptime(isocurrencies.historical_data_date(), "%Y-%m-%d") diff --git a/tests/pricehist/test_logger.py b/tests/pricehist/test_logger.py deleted file mode 100644 index c431d45..0000000 --- a/tests/pricehist/test_logger.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import sys - -from pricehist import logger - - -class Record: - pass - - -def test_formatter_no_prefix_for_info(): - record = Record() - record.levelno = logging.INFO - record.levelname = "INFO" - record.msg = "A message %s" - record.args = "for you" - record.exc_info = None - record.exc_text = "" - - s = logger.Formatter().format(record) - - assert s == "A message for you" - - -def test_formatter_prefix_for_other_levels(): - record = Record() - record.levelno = logging.WARNING - record.levelname = "WARNING" - record.msg = "A warning %s" - record.args = "for you" - record.exc_info = None - record.exc_text = "" - - s = logger.Formatter().format(record) - - assert s == "WARNING A warning for you" - - -def test_formatter_formats_given_exception(): - - try: - raise Exception("Something happened") - except Exception: - exc_info = sys.exc_info() - - record = Record() - record.levelno = logging.DEBUG - record.levelname = "DEBUG" - record.msg = "An exception %s:" - record.args = "for you" - record.exc_info = exc_info - record.exc_text = "" - - s = logger.Formatter().format(record) - lines = s.splitlines() - - assert "DEBUG An exception for you:" in lines - assert "DEBUG Traceback (most recent call last):" in lines - assert any('DEBUG File "' in line for line in lines) - assert "DEBUG Exception: Something happened" in lines - - -def test_init_sets_dest_formatter_and_level(capfd): - logger.init() - logging.info("Test message") - out, err = capfd.readouterr() - assert "Test message" not in out - assert "Test message" in err.splitlines() - assert logging.root.level == logging.INFO - - -def test_show_debug(): - logger.show_debug() - assert logging.root.level == logging.DEBUG diff --git a/tests/pricehist/test_series.py b/tests/pricehist/test_series.py deleted file mode 100644 index 2cee45c..0000000 --- a/tests/pricehist/test_series.py +++ /dev/null @@ -1,95 +0,0 @@ -from dataclasses import replace -from decimal import Decimal - -import pytest - -from pricehist.price import Price -from pricehist.series import Series - - -@pytest.fixture -def series(): - return Series( - "BASE", - "QUOTE", - "type", - "2021-01-01", - "2021-06-30", - [ - Price("2021-01-01", Decimal("1.0123456789")), - Price("2021-01-02", Decimal("2.01234567890123456789")), - Price("2021-01-03", Decimal("3.012345678901234567890123456789")), - ], - ) - - -def test_invert(series): - result = series.invert() - assert (series.base, series.quote) == ("BASE", "QUOTE") - assert (result.base, result.quote) == ("QUOTE", "BASE") - - -def test_rename_base(series): - result = series.rename_base("NEWBASE") - assert series.base == "BASE" - assert result.base == "NEWBASE" - - -def test_rename_quote(series): - result = series.rename_quote("NEWQUOTE") - assert series.quote == "QUOTE" - assert result.quote == "NEWQUOTE" - - -def test_quantize_rounds_half_even(series): - subject = replace( - series, - prices=[ - Price("2021-01-01", Decimal("1.14")), - Price("2021-01-02", Decimal("2.25")), - Price("2021-01-03", Decimal("3.35")), - Price("2021-01-04", Decimal("4.46")), - ], - ) - amounts = [p.amount for p in subject.quantize(1).prices] - assert amounts == [ - Decimal("1.1"), - Decimal("2.2"), - Decimal("3.4"), - Decimal("4.5"), - ] - - -def test_quantize_does_not_extend(series): - subject = replace( - series, - prices=[ - Price("2021-01-01", Decimal("1.14")), - Price("2021-01-02", Decimal("2.25")), - Price("2021-01-03", Decimal("3.35")), - Price("2021-01-04", Decimal("4.46")), - ], - ) - amounts = [p.amount for p in subject.quantize(3).prices] - assert amounts == [ - Decimal("1.14"), - Decimal("2.25"), - Decimal("3.35"), - Decimal("4.46"), - ] - - -def test_quantize_does_not_go_beyond_context_max_prec(series): - subject = replace( - series, - prices=[ - Price("2021-01-01", Decimal("1.012345678901234567890123456789")), - ], - ) - assert subject.prices[0].amount == Decimal("1.012345678901234567890123456789") - result0 = subject.quantize(26) - result1 = subject.quantize(27) - result2 = subject.quantize(35) - assert result0.prices[0].amount == Decimal("1.01234567890123456789012346") - assert result1.prices[0].amount == Decimal("1.012345678901234567890123457") - assert result2.prices[0].amount == Decimal("1.012345678901234567890123457") diff --git a/tests/pricehist/test_sources.py b/tests/pricehist/test_sources.py deleted file mode 100644 index 977cb79..0000000 --- a/tests/pricehist/test_sources.py +++ /dev/null @@ -1,16 +0,0 @@ -import re - -from pricehist import sources - - -def test_formatted_includes_ecb(): - lines = sources.formatted().splitlines() - assert any(re.match(r"ecb +European Central Bank", line) for line in lines) - - -def test_formatted_names_aligned(): - lines = sources.formatted().splitlines() - offsets = [len(re.match(r"(\w+ +)[^ ]", line)[1]) for line in lines] - first = offsets[0] - assert first > 1 - assert all(offset == first for offset in offsets) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 15bf882..0000000 --- a/tox.ini +++ /dev/null @@ -1,9 +0,0 @@ -[tox] -isolated_build = True -envlist = py38,py39 - -[testenv] -deps = poetry -commands = - poetry install - poetry run make test