From c262bfca3073af384bd469cab4bc40c57a93ea7f Mon Sep 17 00:00:00 2001 From: Lorenzo Copelli Date: Wed, 8 Apr 2026 18:14:58 +0200 Subject: [PATCH 1/5] Project import. (#1) * First commit (project import). * Added GitHub workflows. --- .coveragerc | 3 + .github/workflows/codecov.yml | 37 +++ .github/workflows/mkdocs.yml | 39 +++ .gitignore | 7 + LICENSE | 287 ++++++++++++++++++++ README.md | 60 ++++- docs/guide.md | 211 +++++++++++++++ media/logo.png | Bin 0 -> 53875 bytes mkdocs.yml | 13 + pyproject.toml | 46 ++++ requirements.txt | Bin 0 -> 1474 bytes src/pystiller/__init__.py | 7 + src/pystiller/_core/__init__.py | 1 + src/pystiller/_core/_authentication.py | 42 +++ src/pystiller/_core/_datarama.py | 154 +++++++++++ src/pystiller/_core/_projects.py | 52 ++++ src/pystiller/_utils/__init__.py | 1 + src/pystiller/_utils/_checks.py | 60 +++++ src/pystiller/_utils/_data.py | 195 ++++++++++++++ src/pystiller/_utils/_env.py | 32 +++ src/pystiller/_utils/_requests.py | 217 +++++++++++++++ src/pystiller/client.py | 242 +++++++++++++++++ tests/test__authentication.py | 77 ++++++ tests/test__checks.py | 53 ++++ tests/test__data.py | 130 +++++++++ tests/test__datarama.py | 240 +++++++++++++++++ tests/test__env.py | 28 ++ tests/test__projects.py | 84 ++++++ tests/test__requests.py | 356 +++++++++++++++++++++++++ tests/test_client.py | 150 +++++++++++ 30 files changed, 2822 insertions(+), 2 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/workflows/codecov.yml create mode 100644 .github/workflows/mkdocs.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 docs/guide.md create mode 100644 media/logo.png create mode 100644 mkdocs.yml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/pystiller/__init__.py create mode 100644 src/pystiller/_core/__init__.py create mode 100644 src/pystiller/_core/_authentication.py create mode 100644 src/pystiller/_core/_datarama.py create mode 100644 src/pystiller/_core/_projects.py create mode 100644 src/pystiller/_utils/__init__.py create mode 100644 src/pystiller/_utils/_checks.py create mode 100644 src/pystiller/_utils/_data.py create mode 100644 src/pystiller/_utils/_env.py create mode 100644 src/pystiller/_utils/_requests.py create mode 100644 src/pystiller/client.py create mode 100644 tests/test__authentication.py create mode 100644 tests/test__checks.py create mode 100644 tests/test__data.py create mode 100644 tests/test__datarama.py create mode 100644 tests/test__env.py create mode 100644 tests/test__projects.py create mode 100644 tests/test__requests.py create mode 100644 tests/test_client.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..a7cba45 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + tests/* diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..204f6df --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,37 @@ +name: Run tests and upload coverage report to Codecov + +on: + push: + branches: [dev, main] + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + pip install codecov + pip install -e . + + - name: Run tests with coverage + env: + SKIP_ONLINE_TESTS: "true" + run: | + coverage run -m pytest + coverage xml + + - name: Upload score to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml new file mode 100644 index 0000000..b40ad50 --- /dev/null +++ b/.github/workflows/mkdocs.yml @@ -0,0 +1,39 @@ +name: Build and deploy mkdocs site to GitHub Pages + +on: + push: + branches: [main] + +jobs: + mkdocs: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install docs dependencies + run: | + pip install mkdocs + + - name: Generate index.md from README.md + run: | + cp README.md docs/index.md + sed -i 's|docs/guide.md|guide/|g' docs/index.md + mkdir docs/media + cp media/logo.png docs/media/logo.png + + - name: Build mkdocs site + run: mkdocs build + + - name: Deploy site to gh-pages branch + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31910b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ +*.egg-info/ +.env +__pycache__/ +.coverage +dist/ +coverage.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c29ce2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. \ No newline at end of file diff --git a/README.md b/README.md index 8334ec1..d15b2a2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ -# pystiller -A wrap around the DistillerSR APIs. +# pystiller + +[![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![codecov](https://codecov.io/gh/openefsa/pystiller/branch/main/graph/badge.svg?token=VL7426RVCI)](https://codecov.io/gh/openefsa/pystiller) + +## Overview + +The **pystiller** package provides a pool of functions to query **DistillerSR** +through its APIs. It features authentication and utilities to retrieve data +from DistillerSR projects and reports. + +The package is intended for researchers, analysts, and practitioners who +require convenient programmatic access to DistillerSR data. + +## Installation + +### From PyPi + +``` +pip install pystiller +``` + +### Development version + +To install the latest development version: + +``` +pip install git+https://github.com/openefsa/pystiller.git +``` + +## Requirements + +An active internet connection is required, as the package communicates with +DistillerSR online services to fetch and process data. + +## Usage + +Once installed, load the package as usual: + +```python +from pystiller import * +``` + +Basic usage examples and full documentation are available in the package +[guide](docs/guide.md). + +## Authors and maintainers + +- **Lorenzo Copelli** (author, [ORCID](https://orcid.org/0009-0002-4305-065X)). +- **Fulvio Barizzone** (author, [ORCID](https://orcid.org/0009-0006-3035-520X)). +- **Dayana Stephanie Buzle** (author, [ORCID](https://orcid.org/0009-0003-2990-7431)). +- **Rafael Vieira** (author, [ORCID](https://orcid.org/0009-0009-0289-5438)). +- **Luca Belmonte** (author, maintainer, [ORCID](https://orcid.org/0000-0002-7977-9170)). + +## Links + +- **Homepage**: [GitHub](https://github.com/openefsa/pystiller). +- **Bug Tracker**: [Issues on GitHub](https://github.com/openefsa/pystiller/issues). +- **DistillerSR API Documentation**: [https://apidocs.evidencepartners.com/](https://apidocs.evidencepartners.com/). diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..e0523bb --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,211 @@ +# Introduction to pystiller + +## Overview + +The **pystiller** package provides a pool of functions to query **DistillerSR** +through its APIs. It features authentication and utilities to retrieve data +from DistillerSR projects and reports. + +The package is intended for researchers, analysts, and practitioners who +require convenient programmatic access to DistillerSR data. + +## Installation + +### From PyPi + +``` +pip install pystiller +``` + +### Development version + +To install the latest development version: + +``` +pip install git+https://github.com/openefsa/pystiller.git +``` + +## Requirements + +An active internet connection is required, as the package communicates with +DistillerSR online services to fetch and process data. + +## Working with API keys and environment variables + +The *pystiller* package requires your personal API key provided by DistillerSR. +You can provide your API key in one of two ways: + +1. By setting it in the `.env` file.\ +2. By including it manually in the authentication request. + +### Setting the API key via `.env` + +A `.env` file is used to define environment variables that Python can load at +runtime. This approach is particularly convenient for sensitive information +like API keys, as it allows you to use them in any Python script or function +without hardcoding them. + +Place the `.env` file in the root directory of you project (for example, +`C:/Users/username/Documents/myProject/.env` on Windows or +`~/Documents/myProject/.env` on Unix-like systems). You can create or edit this +file with any plain text editor. + +Add your DistillerSR API key in the following format: + +`DISTILLER_API_KEY=` + +Once the file is saved, the variable will be correctly set for the library to +use during execution. + +### Setting the API key manually for the authentication request + +Alternatively, you can provide the API key directly in the `distiller_key` +argument of the `Client()` constructor. This is useful if you refer not to +store the API key globally. For example: + +```python +from pystiller import Client + +client = Client(distiller_key="") +``` + +Note that if an API key is explicitly provided, the API key set through the +`.env` file will be ignored, if any. + +### Setting the DistillerSR instance URL + +The *pystiller* package needs to know the instance URL on which DistillerSR is +running to function properly. You can provide the instance URL in one of two +ways: + +1. By setting it in the `.env` file.\ +2. By including it manually in each API request. + +If you prefer to store the URL in the `.env` file, add your DistillerSR +instance URL in the following format: + +`DISTILLER_INSTANCE_URL=` + +After saving the file, R will automatically read the API key on startup. + +Alternatively, you can provide the instance URL directly in the +`distiller_instance_url` argument of the `Client()` constructor. This is useful +if you refer not to store the instance URL globally. For example: + +```python +from pystiller import Client + +client = Client(distiller_instance_url="") +``` + +## Basic usage + +The main purpose of *pystiller* is to query the DistillerSR APIs for specific +project or report codes and retrieve relevant information across various +endpoints. + +Below are examples demonstrating how to use the functions in this package. +First, load the *pystiller* package: + +```python +from pystiller import * +``` + +Then, initialize the client by specifying the API key and/or the instance URL +you want to use: + +```python +# Use the API key and the instance URL defined in .env file. +client = Client() +# Manually define the API key and the instance URL. +client = Client( + distiller_key="", + distiller_instance_url="" +) +``` + +To explore the arguments and usage of a specific function, you can run: + +```python +help(function_name) +``` + +This will show the full documentation for the function, including its +arguments, return values, and usage examples. + +For example, if you are working with the `Client.get_report()` function, +you can check its documentation with: + +```python +help(Client.get_report) +``` + +## Getting an authentication token + +Before using functions of this package, you must obtain an authentication token +derived from the API key provided by DistillerSR. The client automatically +requests the token upon creation using the specified API key. + +By default, Distiller tokens expire after 60 minutes (1 hour). Automatic +refreshes of the token can be enabled by setting the `automatic_token_refresh` +parameter to `True` during client initialisation. For example: + +```python +client = Client(automatic_token_refresh=True) +``` + +The obtained token can be used to perform API calls using the +`Client.get_projects()`, `Client.get_reports()`, and `Client.get_report()` +functions. + +## Getting the list of projects associated with the user + +If you want to retrieve the list of all the available projects associated with +your DistillerSR account, you can browse them with the `Client.get_projects()` +function, as follows: + +```python +client = Client() + +projects = client.get_projects() + +print(projects) +``` + +## Getting the list of reports associated with a project + +Each individual project has its own associated set of projects. You can +retrieve the list of associated reports with the `Client.get_reports()` +function, as follows: + +```python +client = Client() + +reports = client.get_reports(project_id=123) + +print(reports) +``` + +## Getting a specific report + +You can retrieve a specific report with the `Client.get_report()` function by +specifying a project ID and a report ID, as follows: + +```python +client = Client() + +project_id_ = 123 +report_id_ = 456 + +report = client.get_report( + projectId=project_id_, + reportId=report_id_, + format=ReportFormat.CSV +) + +print(report.head()) +``` + +Note that for very large reports, CSV files are generally a better choice. +Exporting to Excel may cause issues when tables exceed one million rows, +whereas CSV handles large datasets more reliably. diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4f81af67c7c7e35871e79bc3b67598ac4bd8e6a4 GIT binary patch literal 53875 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

M=w;*Ir;Ljrw{?5oU_;H`PwkLZa9Fhv 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", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pystiller" +version = "1.0.0" +description = "A wrap around the DistillerSR APIs" +readme = "README.md" +authors = [ + { name = "Lorenzo Copelli" }, + { name = "Fulvio Barizzone" }, + { name = "Dayana Stephanie Buzle" }, + { name = "Rafael Vieira" }, + { name = "Luca Belmonte" } +] +maintainers = [ + { name = "Luca Belmonte", email = "luca.belmonte@efsa.europa.eu" } +] +license = "EUPL-1.2" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dependencies = [ + "numpy>=2.0", + "pandas>=2.2", + "requests>=2.32", + "python-dotenv>=1.0" +] + +[project.optional-dependencies] +dev = [ + "openpyxl>=3.1.5", + "coverage>=7.6", + "pytest>=8.0", +] + +[project.urls] +"Homepage" = "https://github.com/openefsa/pystiller" +"Repository" = "https://github.com/openefsa/pystiller" +"Bug Tracker" = "https://github.com/openefsa/pystiller/issues" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..af97454c1c335b65c25bbd8a6d172617f2e5405e GIT binary patch literal 1474 zcmZux(N4lZ5Zq@IKLuj}6(9Tu|A3TI0V&s7il85_&g|T^)fm!}-p$U=&hFj!*UC<| zv&z=?gkNSiHpg>o*LG!vmDXB^{TDpkpJHcf_drbjxr?~1R?`&ehYJ45ARw3@w zSr|Qw?DH5^W1q;}u|(WP#C?x=_vqX<_$T54h%7Qu;~ESFYM{yr3KGlI6)F=|E$xCV zl~+fE8x0fvW)ulm4t<56D;`dvMN;Bgn`q#rW#3{{+%Iq-AVUW|bXEN9JCuRBA*yEMPp@|`@ZNi2iwB8#3Nr=Wk7~QpP%kh zgL2eZFM*)COH{!~s?c0EsHZ?ZoSt#jbW#-)5l}Dt!tCl@>U{&&6Jj^9elicS&sg0+ z9W`_@o(eO2#9JDvt$`CY`c-;=mZ+3Y>|=yH8}xjDtHhpqP9{$1z}9&@dqdU4rrD={ z=1(yVvXCcLN@nI(Z0)hZ-o5UmV#srL$7W!hxNg=#U8zT!boPjS&Y^}*Ed7g)vgdq= zpO2=Ht8dV(8pS6rzSXzCmj zGe1kZw@#=q<&mz1`#EqN;mM`XosCXTXYxyT6la9h$oUR8pn9cSM0Y4pp0eu<@upm1 WB`GI|_RmmVdfnWknz>J*c7FhfaMnlw literal 0 HcmV?d00001 diff --git a/src/pystiller/__init__.py b/src/pystiller/__init__.py new file mode 100644 index 0000000..f920f1f --- /dev/null +++ b/src/pystiller/__init__.py @@ -0,0 +1,7 @@ +from .client import Client +from ._core._datarama import ReportFormat + +__all__ = [ + "Client", + "ReportFormat" +] diff --git a/src/pystiller/_core/__init__.py b/src/pystiller/_core/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/src/pystiller/_core/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/pystiller/_core/_authentication.py b/src/pystiller/_core/_authentication.py new file mode 100644 index 0000000..c269ea8 --- /dev/null +++ b/src/pystiller/_core/_authentication.py @@ -0,0 +1,42 @@ +"""This module contains core functions for working with the Authentication +endpoint of the DistillerSR API. +""" + +from pystiller._utils import _checks, _requests + + +def _get_authentication_token(distiller_instance_url, distiller_key, + timeout=1800): + """Authenticate to a DistillerSR session. + + This helper function authenticates a user to a DistillerSR instance + using the personal access key. The function sets a valid + authentication token that can be used to access protected DistillerSR + API endpoints and records the exact date and time the token was issued, + which can be used to manage future refresh operations. + + Args: + distiller_instance_url (str): The URL of the DistillerSR instance. + distiller_key (str): The personal access key generated in DistillerSR. + timeout (int, optional): The maximum number of seconds to wait for the + authentication response. Defaults to 1800 seconds (30 minutes). + + Returns: + str: The obtained DistillerSR authentication token. + """ + + _checks._require_type(value=distiller_instance_url, expected_type=str) + _checks._require_type(value=distiller_key, expected_type=str) + _checks._require_type(value=timeout, expected_type=int) + + authentication_response_ = _requests._perform_authentication_request( + distiller_instance_url=distiller_instance_url, + distiller_key=distiller_key, + timeout=timeout) + + _requests._handle_http_errors(authentication_response_) + + response_data_ = _requests._parse_json_response( + authentication_response_) + + return response_data_["token"] diff --git a/src/pystiller/_core/_datarama.py b/src/pystiller/_core/_datarama.py new file mode 100644 index 0000000..a33d20f --- /dev/null +++ b/src/pystiller/_core/_datarama.py @@ -0,0 +1,154 @@ +"""This module contains core functions for working with the Datarama endpoints +of the DistillerSR API. +""" +import time +import pandas as pd +from enum import StrEnum + +from pystiller._utils import _checks, _requests + + +class ReportFormat(StrEnum): + """The supported report formats.""" + CSV = "csv", + EXCEL = "excel" + + +def _get_reports(project_id, distiller_instance_url, distiller_token, + timeout=1800): + """Get the list of the Distiller reports associated to a project. + + This internal function queries the DistillerSR API to retrieve the list of + reports associated with a project. The result is a data frame listing + available reports. + + Args: + project_id (int): The ID of the project as provided by DistillerSR. + distiller_instance_url (str): The URL of the DistillerSR instance. + distiller_token (str): The Distiller authentication token. + timeout (int, optional): The maximum number of seconds to wait for the + service response. Defaults to 1800 seconds (30 minutes). + + Returns: + pd.DataFrame: A data frame with four columns: + - id: The project ID. + - name: The project name. + - date: The creation date of the report. + - view: The format of the report (e.g., html, csv, excel). + """ + + _checks._require_type(value=project_id, expected_type=int) + _checks._require_type(value=distiller_instance_url, expected_type=str) + _checks._require_type(value=distiller_token, expected_type=str) + _checks._require_type(value=timeout, expected_type=int) + + reports_url_ = (f"{distiller_instance_url}/projects/{project_id}" + + "/reports/datarama") + + service_response_ = _requests._perform_service_request( + service_url=reports_url_, + distiller_token=distiller_token, + timeout=timeout) + + _requests._handle_http_errors( + response=service_response_, + error_message="Unable to retrieve reports") + + response_data_ = _requests._parse_json_response( + response=service_response_, + error_message="Failed to parse reports service response") + + response_data_ = pd.DataFrame(response_data_) + + return response_data_ + + +def _get_report(project_id, report_id, distiller_instance_url, distiller_token, + report_format = ReportFormat.CSV, timeout=1800, attempts=1, + retry_each=600, verbose=True): + """Get a Distiller report associated to a project. + + This internal function queries the DistillerSR API to retrieve a saved + report associated with a project. The result is a data frame containing + metadata about the saved report. + + Args: + project_id (int): The ID of the project as provided by DistillerSR. + report_id (int): The ID of the report as provided by DistillerSR. + distiller_instance_url (str): The URL of the DistillerSR instance. + distiller_token (str): The Distiller authentication token. + report_format (ReportFormat, optional): The desired format of the + document. Defaults to CSV (Comma Separated Values). + timeout (int, optional): The maximum number of seconds to wait for the + service response. Defaults to 1800 seconds (30 minutes). + attempts (int, optional): The maximum number of attempts. Defaults to 1 + attempt. + retry_each (int, optional): The delay between attempts. Defaults to + 600 seconds (10 minutes). + verbose (bool, optional): A flag to specify whether to make the + function verbose or not. Defaults to True. + + Returns: + pd.DataFrame: A data frame containing the Distiller report as designed + within DistillerSR. + """ + + _checks._require_type(value=project_id, expected_type=int) + _checks._require_type(value=report_id, expected_type=int) + _checks._require_type(value=distiller_instance_url, expected_type=str) + _checks._require_type(value=distiller_token, expected_type=str) + _checks._require_type(value=report_format, expected_type=ReportFormat) + _checks._require_type(value=timeout, expected_type=int) + _checks._require_type(value=attempts, expected_type=int) + _checks._require_minimum(value=attempts, minimum=1) + _checks._require_type(value=retry_each, expected_type=int) + _checks._require_minimum(value=retry_each, minimum=0) + + report_url_ = f"{distiller_instance_url}/datarama/query" + + request_body_ = { + "project_id": project_id, + "saved_report_id": report_id, + "use_saved_format": True + } + + for attempt_ in range(attempts): + if verbose and attempts > 1: + print(f"Starting attempt {attempt_ + 1}...") + try: + service_response_ = _requests._perform_service_request( + service_url=report_url_, + distiller_token=distiller_token, + body=request_body_, + timeout=timeout) + + _requests._handle_http_errors( + response=service_response_, + error_message=f"Unable to retrieve report {report_id}") + + if report_format == ReportFormat.CSV: + response_data_ = _requests._parse_csv_response( + response=service_response_, + error_message="Failed to parse CSV for report " + + f"{report_id}") + else: + response_data_ = _requests._parse_xlsx_response( + response=service_response_, + error_message="Failed to parse XLSX for report " + + f"{report_id}") + + response_data_ = pd.DataFrame(response_data_) + + return response_data_ + + except Exception as e_: + if verbose: + print(f"Attempt failed with reason:\n{e_}") + + if attempt_ < attempts: + if verbose: + print(f"Sleeping for {retry_each} seconds...") + time.sleep(retry_each) + + raise RuntimeError(f"Unable to retrieve report {report_id}\nAll " + + "attempts to retrieve the report failed") diff --git a/src/pystiller/_core/_projects.py b/src/pystiller/_core/_projects.py new file mode 100644 index 0000000..e56af8d --- /dev/null +++ b/src/pystiller/_core/_projects.py @@ -0,0 +1,52 @@ +"""This module contains core functions for working with the Projects endpoints +of the DistillerSR API. +""" + +import pandas as pd + +from pystiller._utils import _checks, _requests + + +def _get_projects(distiller_instance_url, distiller_token, timeout=1800): + """Get the list of the Distiller projects associated to the user. + + This internal function queries the DistillerSR API to retrieve the list of + projects accessible to the authenticated user. The result is a data frame + listing available projects. + + Args: + distiller_instance_url (str): The URL of the DistillerSR instance. + distiller_token (str): The Distiller authentication token. + timeout (int, optional): The maximum number of seconds to wait for the + service response. Defaults to 1800 seconds (30 minutes). + + Returns: + pd.DataFrame: A data frame with four columns: + - id: The project ID. + - name: The project name. + - de_project_id. + - is_hidden. + """ + + _checks._require_type(value=distiller_instance_url, expected_type=str) + _checks._require_type(value=distiller_token, expected_type=str) + _checks._require_type(value=timeout, expected_type=int) + + projects_url_ = f"{distiller_instance_url}/projects" + + service_response_ = _requests._perform_service_request( + service_url=projects_url_, + distiller_token=distiller_token, + timeout=timeout) + + _requests._handle_http_errors( + response=service_response_, + error_message="Unable to retrieve projects") + + response_data_ = _requests._parse_json_response( + response=service_response_, + error_message="Failed to parse projects service response") + + response_data_ = pd.DataFrame(response_data_) + + return response_data_ diff --git a/src/pystiller/_utils/__init__.py b/src/pystiller/_utils/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/src/pystiller/_utils/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/pystiller/_utils/_checks.py b/src/pystiller/_utils/_checks.py new file mode 100644 index 0000000..d630cc6 --- /dev/null +++ b/src/pystiller/_utils/_checks.py @@ -0,0 +1,60 @@ +"""This module contains internal functions for performing type and data checks. +""" + + +def _require_type(value, expected_type): + """Check that a value is of the expected type. + + Args: + value: The value to check. + expected_type: The expected type. + + Raises: + TypeError: If the value is not of the expected type. + + Returns: + None: The function returns nothing if the check passes. + """ + + if not isinstance(value, expected_type): + raise TypeError(f"Expected type {expected_type}, got {type(value)}") + + +def _require_string_not_empty(value): + """Check that a string is not empty. + + Args: + value: The string to check. + + Raises: + ValueError: If the string is empty. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=value, expected_type=str) + + if not value.strip(): + raise ValueError("Expected non-empty string, got empty string") + + +def _require_minimum(value, minimum): + """Check that a value is not less than the given minimum. + + Args: + value: The value to check. + minimum: The minimum value. + + Raises: + ValueError: If the value is not less than the given minimum. + + Returns: + None: The function returns nothing if the check passes. + """ + + _require_type(value=value, expected_type=int) + _require_type(value=minimum, expected_type=int) + + if value < minimum: + raise ValueError(f"Expected value >= {minimum}, got {value}") diff --git a/src/pystiller/_utils/_data.py b/src/pystiller/_utils/_data.py new file mode 100644 index 0000000..dbd67b4 --- /dev/null +++ b/src/pystiller/_utils/_data.py @@ -0,0 +1,195 @@ +"""This module contains internal functions for processing API response data.""" + +import pandas as pd + + +def _flatten(nested_data, separator='_'): + """Flattens nested JSON structures into a pandas DataFrame. + + This helper function automatically expands lists into multiple rows and + flattens nested dictionaries. + + Args: + nested_data: The dictionary or list of dictionaries to flatten. + separator (str): The separator to use for nested columns names. + Defaults to '_'. + + Raises: + TypeError: If data is not a dictionary or a list of dictionaries. Data + can be empty. + + Returns: + pandas.DataFrame: A flattened DataFrame, where: + - Lists are expanded into multiple rows. + - Nested dictionaries become columns with separator-notation. + - Parent-level fields are repeated for each child row. + """ + + def _flatten_dictionary(dictionary, sep, parent_key=''): + """Flatten a nested dictionary without expanding lists. + + This helper function recursively traverses a dictionary and creates + flat key-value pairs for nested keys, using the specified separator. + Lists are kept as-is and not expanded by this function. + + Args: + dictionary (dict): The dictionary to flatten. + sep (str): The separator to use between parent and child keys. + parent_key (str, optional): The parent key for the flattened + dictionary. It is the prefix to prepend to keys (used in + recursion). Defaults to ''. + + Returns: + dict: A flattened dictionary with sep-notated keys for nested + structures. + """ + + dictionary_items_ = [] + + for key_, value_ in dictionary.items(): + new_key_ = f"{parent_key}{sep}{key_}" if parent_key else key_ + if isinstance(value_, dict): + dictionary_items_.extend( + _flatten_dictionary(dictionary=value_, sep=sep, + parent_key=new_key_).items()) + else: + dictionary_items_.append((new_key_, value_)) + + return dict(dictionary_items_) + + + def _is_dictionary_of_lists(dictionary): + """Check if a dictionary contains only lists as values. + + This helper function identifies the special case where the top-level + dictionary keys represent categories amd all values are lists. + + Args: + dictionary (dict): The dictionary to check. + + Returns: + bool: True if all values in the dictionary are lists, False + otherwise. + """ + + return all(isinstance(value_, list) for value_ in dictionary.values()) + + + def _expand_dictionary_of_lists(dictionary, sep): + """Expand a dictionary where all values are lists. + + This helper function handles the special case where each top-level key + maps to a list of items. It creates a "key" column to preserve the + original dictionary key and expands each list item into a separate row. + + Args: + dictionary (dict): The dictionary where all values are lists. + sep (str): The separator for flattening nested dictionaries within + list items. + + Returns: + pandas.DataFrame: DataFrame with a "key" column containing the + original dictionary key and additional columns from the list + items. + """ + + expanded_rows_ = [] + + for key_, value_ in dictionary.items(): + for item_ in value_: + if isinstance(item_, dict): + row_ = {"parent_key": key_} + flat_item_ = _flatten_dictionary(dictionary=item_, sep=sep) + row_.update(flat_item_) + expanded_rows_.append(row_) + else: + expanded_rows_.append({"parent_key": key_, + "atomic_value": item_}) + + return pd.DataFrame(expanded_rows_) + + + def _find_list_columns(dictionary): + """Finds dictionary keys that contain lists of dictionaries. + + This helper function identifies columns that need to be expanded into + multiple rows. It only considers lists that contain at least one + dictionary. + + Args: + dictionary (dict): The dictionary to check. + + Returns: + list: The list of keys whose values are lists of dictionaries. + """ + + list_keys_ = [] + + for key_, value_ in dictionary.items(): + if (isinstance(value_, list) and value_ + and any(isinstance(item_, dict) for item_ in value_)): + list_keys_.append(key_) + + return list_keys_ + + + def _expand_lists(dictionary, sep): + """Recursively expand lists in a dictionary into DataFrame rows. + + This helper is the main recursive function that handles the expansion + logic. It detects different types of structures and applies the + appropriate expansion strategy. It processes one list level at a time + and recurses for additional nested lists. + + Args: + dictionary (dict): The dictionary to process. + sep (str): The separator for nested column names. + + Returns: + pandas.DataFrame: A DataFrame with all lists expanded into rows and + nested dictionaries flattened. + """ + + if _is_dictionary_of_lists(dictionary=dictionary): + return _expand_dictionary_of_lists(dictionary=dictionary, sep=sep) + + list_keys_ = _find_list_columns(dictionary=dictionary) + + if not list_keys_: + flat_ = _flatten_dictionary(dictionary=dictionary, sep=sep) + return pd.DataFrame([flat_]) + + list_key_ = list_keys_[0] + list_data_ = dictionary[list_key_] + + base_data_ = {key_: value_ for key_, value_ in dictionary.items() + if key_ != list_key_} + + expanded_rows_ = [] + + for item_ in list_data_: + row_data_ = base_data_.copy() + + if isinstance(item_, dict): + for key_, value_ in item_.items(): + row_data_[f"{list_key_}{sep}{key_}"] = value_ + else: + row_data_[list_key_] = item_ + + expanded_rows_.append(_expand_lists(dictionary=row_data_, sep=sep)) + + return pd.concat(expanded_rows_, ignore_index=True) + + if not nested_data: + return pd.DataFrame() + + elif (isinstance(nested_data, list) + and all(isinstance(item_, dict) for item_ in nested_data)): + dataframes_ = [_expand_lists(dictionary=item_, sep=separator) + for item_ in nested_data] + return pd.concat(dataframes_, ignore_index=True) + + elif isinstance(nested_data, dict): + return _expand_lists(dictionary=nested_data, sep=separator) + + raise TypeError("Data must empty, a dictionary or a list of dictionaries") diff --git a/src/pystiller/_utils/_env.py b/src/pystiller/_utils/_env.py new file mode 100644 index 0000000..0a25dc7 --- /dev/null +++ b/src/pystiller/_utils/_env.py @@ -0,0 +1,32 @@ +"""This module contains internal functions for working with environment +variables.""" + +import os +from dotenv import load_dotenv + +from pystiller._utils import _checks + + +def _read_environment_variable(name): + """Reads an environment variable. + + Args: + name: The name of the environment variable to read. + + Raises: + ValueError: If the value is not set. + + Returns: + str: The value of the environment variable. + """ + + _checks._require_type(value=name, expected_type=str) + + load_dotenv() + + environment_variable_ = os.getenv(name) + + if environment_variable_ is None: + raise ValueError(f"The {name} environment variable is not set") + + return environment_variable_ diff --git a/src/pystiller/_utils/_requests.py b/src/pystiller/_utils/_requests.py new file mode 100644 index 0000000..3e9e9cf --- /dev/null +++ b/src/pystiller/_utils/_requests.py @@ -0,0 +1,217 @@ +"""This module contains internal functions for working with DistillerSR API +requests.""" + +import io +import requests +import pandas as pd +from json import JSONDecodeError + +from pystiller._utils import _checks, _data + + +def _perform_authentication_request(distiller_instance_url, distiller_key, + timeout=1800): + """Build and execute an authentication request for the DistillerSR API. + + This helper function configures and sends a GET authentication request to + the DistillerSR API, setting the necessary headers and authentication key. + It then performs the request and returns the corresponding response data. + + Args: + distiller_instance_url (str): The URL of the DistillerSR instance. + distiller_key (str): The personal access key generated in DistillerSR. + timeout (int, optional): The maximum number of seconds to wait for the + authentication response. Defaults to 1800 seconds (30 minutes). + + Returns: + class (requests.Response): The HTTP response object returned by + the request. + """ + + _checks._require_type(value=distiller_instance_url, expected_type=str) + _checks._require_type(value=distiller_key, expected_type=str) + _checks._require_type(value=timeout, expected_type=int) + + request_headers_ = { + "Authorization": f"Key {distiller_key}", + "Content-Type": "application/octet-stream" + } + + response_ = requests.post( + f"{distiller_instance_url}/auth", + headers=request_headers_, + timeout=timeout) + + return response_ + + +def _perform_service_request(service_url, distiller_token, body=None, + timeout=1800): + """Build and execute a service request to the DistillerSR API. + + This helper function configures and sends a service request to the + DistillerSR API, setting the necessary headers and authentication key. + It then performs the request and returns the corresponding response data. + + Args: + service_url (str): The URL of the service endpoint. + distiller_token (str): The personal access token generated by the + authentication request. + body (dict, optional): A dictionary containing the body parameters to + be encoded into JSON format. + timeout (int, optional): The maximum number of seconds to wait for the + service response. Defaults to 1800 seconds (30 minutes). + + Returns: + class (requests.Response): The HTTP response object returned by + the request. + """ + + _checks._require_type(value=service_url, expected_type=str) + _checks._require_type(value=distiller_token, expected_type=str) + if body is not None: + _checks._require_type(value=body, expected_type=dict) + _checks._require_type(value=timeout, expected_type=int) + + request_headers_ = { + "Authorization": f"Bearer {distiller_token}" + } + + if body is None: + request_headers_["Content-Type"] = "application/octet-stream" + + response_ = requests.get( + url=service_url, + headers=request_headers_, + timeout=timeout) + + else: + request_headers_["Content-Type"] = "application/json" + + response_ = requests.post( + url=service_url, + headers=request_headers_, + json=body, + timeout=timeout) + + return response_ + + +def _handle_http_errors(response, error_message="API request failed"): + """Handle non-successful HTTP responses from the DistillerSR API. + + This helper function checks whether an HTTP response from the DistillerSR + API indicates success (status code 200). If the response contains any other + status code, it raises a formatted error. + + Args: + response (requests.Response): The HTTP response object. + error_message (str, optional): The error message to be displayed. + + Raises: + requests.exceptions.HTTPError: If the request was not successful. + + Returns: + None: The function returns nothing if the request was successful. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + _checks._require_type(value=error_message, expected_type=str) + + if response.status_code != 200: + raise requests.HTTPError(f"{error_message}\n(HTTP " + + f"{response.status_code})") + + +def _parse_json_response(response, + error_message="Failed to parse JSON response", + flatten=False): + """Parse a JSON API response. + + This helper function parses the JSON body of an API response. If the API + response body cannot be parsed as valid JSON, the function raises an error. + + Args: + response (requests.Response): The HTTP response object. + error_message (str, optional): The message to display in case of + errors. + flatten (bool, optional): If True, flattens the response body into a + data frame. Defaults to False. + + Raises: + JSONDecodeError: If the response body can not be parsed as valid JSON. + + Returns: + pd.DataFrame: A Pandas DataFrame representing the parsed JSON response. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + _checks._require_type(value=error_message, expected_type=str) + + try: + response_data_ = response.json() + if flatten: + response_data_ = _data._flatten(response_data_) + except (JSONDecodeError, KeyError) as e_: + raise JSONDecodeError(f"{error_message}\n{e_.msg}", + e_.doc, e_.pos) + + return response_data_ + + +def _parse_csv_response(response, + error_message="Failed to parse CSV response"): + """Parse a CSV API response. + + This helper function parses the CSV body of an API response. If the API + response body cannot be parsed as valid CSV, the function raises an error. + + Args: + response (requests.Response): The HTTP response object. + error_message (str, optional): The message to display in case of errors. + + Raises: + ValueError: If the response body can not be parsed as valid CSV. + + Returns: + pd.DataFrame: A dictionary representing the parsed CSV response. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + _checks._require_type(value=error_message, expected_type=str) + + try: + response_csv_ = pd.read_csv(io.StringIO(response.text)) + except Exception as e_: + raise ValueError(f"{error_message}\n{str(e_)}") + + return response_csv_ + + +def _parse_xlsx_response(response, + error_message="Failed to parse XLSX response"): + """Parse an XLSX API response. + + This helper function parses the XLSX body of an API response. If the API + response body cannot be parsed as valid XLSX, the function raises an error. + + Args: + response (requests.Response): The HTTP response object. + error_message (str, optional): The message to display in case of errors. + + Raises: + ValueError: If the response body can not be parsed as valid XLSX. + + Returns: + pd.DataFrame: A dictionary representing the parsed XLSX response. + """ + + _checks._require_type(value=response, expected_type=requests.Response) + _checks._require_type(value=error_message, expected_type=str) + + try: + response_xlsx_ = pd.read_excel(io.BytesIO(response.content)) + except Exception as e_: + raise ValueError(f"{error_message}\n{str(e_)}") + + return response_xlsx_ diff --git a/src/pystiller/client.py b/src/pystiller/client.py new file mode 100644 index 0000000..a68c128 --- /dev/null +++ b/src/pystiller/client.py @@ -0,0 +1,242 @@ +from datetime import datetime, timedelta + +from pystiller._core._datarama import ReportFormat +from pystiller._utils import _checks, _env +from pystiller._core import _authentication, _projects, _datarama + + +class Client: + """Client class for working with the DistillerSR API. + + Attributes: + _distiller_key (str): The API key used for authentication. + _distiller_instance_url (str): The Distiller instance URL. + _distiller_token (str): The Distiller authorization token. + _automatic_token_refresh (bool): If True, automatic token refresh is + performed when the token is going to expire. + _distiller_token_last_update (datetime.datetime): The datetime of the + last Distiller authentication token update (needed for refreshes). + + Methods: + get_projects(): Gets the list of Distiller projects associated with the + user. + get_reports(project_id): Gets the list of Distiller reports associated + to a project. + get_report(project_id, report_id): Retrieves a specific Distiller + report. + _get_or_refresh_token(): Checks if the Distiller token is still valid. + If not, it will request a new one. If the token is not set (e.g., + at client initialisation), it will request it. + """ + + def __init__(self, distiller_key=None, distiller_instance_url=None, + automatic_token_refresh=False): + """Initialize the client. + + Args: + distiller_key (str, optional): The API key used for authentication. + distiller_instance_url (str, optional): The Distiller instance URL. + automatic_token_refresh (bool, optional): If True, automatically + refresh the Distiller token if it is going to expire. Defaults + to False. + + Examples: + >>> from pystiller import Client + + >>> # Create a client using the API key and the instance URL + >>> # defined in the .env file. + >>> client_with_default = Client() + + >>> # Create a client using manually specified API key and instance + >>> # URL. + >>> client_with_customs = Client( + >>> distiller_key="", + >>> distiller_instance_url="" + >>> ) + """ + + if distiller_key is not None: + self._distiller_key = distiller_key + else: + self._distiller_key = _env._read_environment_variable( + name="DISTILLER_API_KEY") + + _checks._require_type(value=self._distiller_key, expected_type=str) + _checks._require_string_not_empty(value=self._distiller_key) + + if distiller_instance_url is not None: + self._distiller_instance_url = distiller_instance_url + else: + self._distiller_instance_url = _env._read_environment_variable( + name="DISTILLER_INSTANCE_URL") + + _checks._require_type(value=self._distiller_instance_url, + expected_type=str) + _checks._require_string_not_empty(value=self._distiller_instance_url) + + if self._distiller_instance_url.endswith('/'): + self._distiller_instance_url = self._distiller_instance_url[:-1] + + self._automatic_token_refresh = automatic_token_refresh + _checks._require_type(value=self._automatic_token_refresh, + expected_type=bool) + + self._distiller_token = None + self._distiller_token_last_update = None + + self._get_or_refresh_token() + + + def _get_or_refresh_token(self): + """Get or refresh a Distiller token. + + This helper function checks if the Distiller token is still valid. If + not, it will request a new one. If the token is not set (e.g., at + client initialisation), it will request it. Checks are based on the + last token update timestamp. The default duration for Distiller tokens + is 60 minutes (1 hour). The refresh will happen if the token is older + than 55 minutes. + + Returns: + None: The functions returns nothing. + """ + + token_missing_ = not self._distiller_token + + now_ = datetime.now() + + token_expired_ = ( + self._distiller_token_last_update and + now_ - self._distiller_token_last_update >= timedelta(minutes=55) + ) + + if token_missing_ or token_expired_: + self._distiller_token = _authentication._get_authentication_token( + distiller_instance_url=self._distiller_instance_url, + distiller_key=self._distiller_key) + self._distiller_token_last_update = now_ + + + def get_projects(self, timeout=1800): + """Get the list of the Distiller projects associated to the user. + + This function queries the DistillerSR API to retrieve the list of + projects accessible to the authenticated user. The result is a data + frame listing available projects. + + Args: + timeout (int, optional): The maximum number of seconds to wait for + the response. Defaults to 1800 seconds (30 minutes). + + Returns: + pd.DataFrame: A data frame with four columns: + - id: The project ID. + - name: The project name. + - de_project_id. + - is_hidden. + + Examples: + >>> from pystiller import Client + + >>> client = Client() + + >>> # Get the list of available projects. + >>> projects = client.get_projects() + """ + + if self._automatic_token_refresh: + self._get_or_refresh_token() + + return _projects._get_projects( + distiller_instance_url=self._distiller_instance_url, + distiller_token=self._distiller_token, + timeout=timeout) + + + def get_reports(self, project_id, timeout=1800): + """Get the list of the Distiller reports associated to a project. + + This function queries the DistillerSR API to retrieve the list of + reports associated with a project. The result is a data frame listing + available reports. + + Args: + project_id (int): The ID of the project as provided by DistillerSR. + timeout (int, optional): The maximum number of seconds to wait for + the service response. Defaults to 1800 seconds (30 minutes). + + Returns: + pd.DataFrame: A data frame with four columns: + - id: The project ID. + - name: The project name. + - date: The creation date of the report. + - view: The format of the report (e.g., html, csv, excel). + + Examples: + >>> from pystiller import Client + + >>> client = Client() + + >>> # Get the list of available reports. + >>> reports = client.get_reports(project_id=123) + """ + + if self._automatic_token_refresh: + self._get_or_refresh_token() + + return _datarama._get_reports( + project_id=project_id, + distiller_instance_url=self._distiller_instance_url, + distiller_token=self._distiller_token, + timeout=timeout) + + + def get_report(self, project_id, report_id, + report_format = ReportFormat.CSV, timeout=1800, attempts=1, + retry_each=600, verbose=True): + """Get a Distiller report associated to a project. + + This function queries the DistillerSR API to retrieve a saved report + associated with a project. The result is a data frame containing + metadata about the saved report. + + Args: + project_id (int): The ID of the project as provided by DistillerSR. + report_id (int): The ID of the report as provided by DistillerSR. + report_format (ReportFormat, optional): The desired format of the + document. Defaults to CSV (Comma Separated Values). + timeout (int, optional): The maximum number of seconds to wait for + the service response. Defaults to 1800 seconds (30 minutes). + attempts (int, optional): The maximum number of attempts. Defaults + to 1 attempt. + retry_each (int, optional): The delay between attempts. Defaults to + 600 seconds (10 minutes). + verbose (bool, optional): A flag to specify whether to make the + function verbose or not. Defaults to True. + + Returns: + pd.DataFrame: A data frame containing the Distiller report as + designed within DistillerSR. + + Examples: + >>> from pystiller import Client, ReportFormat + + >>> client = Client() + + >>> # Get a specific report. + >>> report = client.get_report(project_id=123, report_id=456) + """ + + if self._automatic_token_refresh: + self._get_or_refresh_token() + + return _datarama._get_report( + project_id=project_id, + report_id=report_id, + distiller_instance_url=self._distiller_instance_url, + distiller_token=self._distiller_token, + report_format=report_format, + timeout=timeout, + attempts=attempts, + retry_each=retry_each, + verbose=verbose) diff --git a/tests/test__authentication.py b/tests/test__authentication.py new file mode 100644 index 0000000..f995271 --- /dev/null +++ b/tests/test__authentication.py @@ -0,0 +1,77 @@ +import os +import json +import unittest +from unittest.mock import patch +import requests +from dotenv import load_dotenv +from requests import Response + +from pystiller._core._authentication import _get_authentication_token + +load_dotenv() + + +class TestAuthentication(unittest.TestCase): + + ############################### + # _get_authentication_token() # + ############################### + + def test__get_authentication_token_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _get_authentication_token, + distiller_instance_url=123, distiller_key="") + self.assertRaises(TypeError, _get_authentication_token, + distiller_instance_url="", distiller_key=123) + self.assertRaises(TypeError, _get_authentication_token, + distiller_instance_url="", distiller_key="", + timeout="") + + @patch("pystiller._core._authentication._requests." + + "_perform_authentication_request") + def test__get_authentication_token_bad_url(self, mock_auth_req): + """Test the behaviour for bad instance URLs.""" + mock_auth_req.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _get_authentication_token, + distiller_instance_url="https://invalid-domain", + distiller_key="DISTILLER_API_KEY") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_authentication_token_bad_url_online(self): + """Test the behaviour for bad instance URLs.""" + self.assertRaises(Exception, _get_authentication_token, + distiller_instance_url="https://invalid-domain", + distiller_key="DISTILLER_API_KEY") + + @patch("pystiller._core._authentication._requests." + + "_perform_authentication_request") + def test__get_authentication_token_output(self, mock_auth_req): + """Test the output type of the request.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"token": "DISTILLER_TOKEN"}) + .encode("utf-8")) + mock_auth_req.return_value = response_ + token_ = _get_authentication_token( + distiller_instance_url="https://example.org", + distiller_key="DISTILLER_API_KEY" + ) + self.assertIsInstance(token_, str) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_authentication_token_output_online(self): + """Test the output type of the request.""" + token_ = _get_authentication_token( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + self.assertIsInstance(token_, str) diff --git a/tests/test__checks.py b/tests/test__checks.py new file mode 100644 index 0000000..975dd28 --- /dev/null +++ b/tests/test__checks.py @@ -0,0 +1,53 @@ +import unittest + +from pystiller._utils._checks import (_require_type, _require_string_not_empty, + _require_minimum) + + +class TestChecks(unittest.TestCase): + + ################### + # _require_type() # + ################### + + def test__require_type_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _require_type, value=123, + expected_type=str) + + def test__require_type_output(self): + """Test the behaviour for valid data.""" + self.assertIsNone(_require_type(value=123, expected_type=int)) + + ############################### + # _require_string_not_empty() # + ############################### + + def test__require_string_not_empty_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _require_string_not_empty, value=123) + + def test__require_string_not_empty_empty(self): + """Test the behaviour for empty strings.""" + self.assertRaises(ValueError, _require_string_not_empty, value="") + + def test__require_string_not_empty_output(self): + """Test the behaviour for valid data.""" + self.assertIsNone(_require_string_not_empty(value="test")) + + ###################### + # _require_minimum() # + ###################### + + def test__require_minimum_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _require_minimum, value='', minimum=0) + self.assertRaises(TypeError, _require_minimum, value=0, minimum='') + + def test__require_minimum_wrong(self): + """Test the behaviour for empty strings.""" + self.assertRaises(ValueError, _require_minimum, value=0, minimum=1) + + def test__require_minimum_output(self): + """Test the behaviour for valid data.""" + self.assertIsNone(_require_minimum(value=1, minimum=0)) diff --git a/tests/test__data.py b/tests/test__data.py new file mode 100644 index 0000000..4948c83 --- /dev/null +++ b/tests/test__data.py @@ -0,0 +1,130 @@ +import unittest + +from pystiller._utils._data import _flatten + + +class TestData(unittest.TestCase): + + ############## + # _flatten() # + ############## + + def test__flatten_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _flatten, nested_data=123) + + def test__flatten_empty(self): + """Test the behaviour for empty data.""" + self.assertTrue(_flatten(nested_data=list()).empty) + self.assertTrue(_flatten(nested_data=dict()).empty) + + def test__flatten_sep(self): + flattened_ = _flatten(nested_data={'a': {'b': 1}}) + self.assertEqual(flattened_.keys()[0], "a_b") + flattened_ = _flatten(nested_data={'a': {'b': 1}}, separator='.') + self.assertEqual(flattened_.keys()[0], "a.b") + + def test__flatten_dict1(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': 2, + 'c': 3, + 'd': { + '1': 4, + '2': 5 + } + }) + self.assertEqual( + list(flattened_.keys()), + ['a', 'b', 'c', "d_1", "d_2"] + ) + + def test__flatten_dict2(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': 2, + 'c': 3, + 'd': { + '1': 4, + '2': { + '3': 5 + } + } + }) + self.assertEqual( + list(flattened_.keys()), + ['a', 'b', 'c', "d_1", "d_2_3"] + ) + + def test__flatten_dict3(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ] + }) + self.assertEqual( + list(flattened_.keys()), + ['a', "b_x", "b_y", "b_z"] + ) + + def test__flatten_dict4(self): + """Test the behaviour for flattening a dictionary.""" + flattened_ = _flatten(nested_data={ + 'a': 1, + 'b': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6}, + "some text" + ] + }) + self.assertEqual( + list(flattened_.keys()), + ['a', "b_x", "b_y", "b_z", 'b'] + ) + + def test__flatten_list_of_dicts(self): + """Test the behaviour for flattening a list of dictionaries.""" + flattened_ = _flatten(nested_data=[ + {'a': 1, 'b': 2, 'c': 3}, + {'a': 4, 'b': 5, 'c': 6} + ]) + self.assertEqual(list(flattened_.keys()), ['a', 'b', 'c']) + + def test__flatten_dict_of_lists1(self): + """Test the behaviour for flattening a dictionary of lists.""" + flattened_ = _flatten(nested_data={ + 'a': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ], + 'b': [ + {'x': 7, 'y': 8, 'z': 9}, + {'x': 10, 'y': 11, 'z': 12} + ] + }) + self.assertEqual( + list(flattened_.keys()), + ["parent_key", 'x', 'y', 'z'] + ) + + def test__flatten_dict_of_lists2(self): + """Test the behaviour for flattening a dictionary of lists.""" + flattened_ = _flatten(nested_data={ + 'a': [ + {'x': 1, 'y': 2, 'z': 3}, + {'x': 4, 'y': 5, 'z': 6} + ], + 'b': [ + {'x': 7, 'y': 8, 'z': 9}, + "some text" + ] + }) + self.assertEqual( + list(flattened_.keys()), + ["parent_key", 'x', 'y', 'z', "atomic_value"] + ) diff --git a/tests/test__datarama.py b/tests/test__datarama.py new file mode 100644 index 0000000..07d7746 --- /dev/null +++ b/tests/test__datarama.py @@ -0,0 +1,240 @@ +import io +import json +import os +import unittest +from unittest.mock import patch +from requests.exceptions import HTTPError +import pandas as pd +import requests +from dotenv import load_dotenv +from pystiller._core import _authentication +from requests import Response + +from pystiller._core._datarama import _get_reports, _get_report, ReportFormat + +load_dotenv() + + +class TestDatarama(unittest.TestCase): + + ################## + # _get_reports() # + ################## + + def test__get_reports_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _get_reports, project_id='', + distiller_instance_url=123, distiller_token="") + self.assertRaises(TypeError, _get_reports, project_id=123, + distiller_instance_url=123, distiller_token="") + self.assertRaises(TypeError, _get_reports, project_id=123, + distiller_instance_url="", distiller_token=123) + self.assertRaises(TypeError, _get_reports, project_id=123, + distiller_instance_url="", distiller_token="", + timeout="") + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_reports_bad_url(self, mock_serv_req): + """Test the behaviour for bad instance URLs.""" + mock_serv_req.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _get_reports, project_id=123, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_reports_bad_url_online(self): + """Test the behaviour for bad instance URLs.""" + self.assertRaises(Exception, _get_reports, project_id=123, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_reports_output(self, mock_serv_req): + """Test the output type of the request.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = json.dumps({ + "a": [1], "b": [2], "c": [3], "d": [4] + }).encode("utf-8") + mock_serv_req.return_value = response_ + response_ = _get_reports( + project_id=123, + distiller_instance_url="https://example.org", + distiller_token="DISTILLER_TOKEN" + ) + self.assertIsInstance(response_, pd.DataFrame) + self.assertEqual(len(response_.columns), 4) + + # This test requires the DISTILLER_API_KEY, DISTILLER_INSTANCE_URL, and + # DISTILLER_PROJECT_ID_TEST environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_reports_output_online(self): + """Test the output type of the request.""" + token_ = _authentication._get_authentication_token( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + response_ = _get_reports( + project_id=int(os.getenv("DISTILLER_PROJECT_ID_TEST")), + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_token=token_ + ) + self.assertIsInstance(response_, pd.DataFrame) + self.assertEqual(len(response_.columns), 4) + + ################# + # _get_report() # + ################# + + def test__get_report_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _get_report, project_id="", report_id=456, + distiller_instance_url="", distiller_token="") + self.assertRaises(TypeError, _get_report, project_id=123, report_id="", + distiller_instance_url="", distiller_token="") + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url=123, + distiller_token="") + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token=123) + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=123) + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=ReportFormat.CSV, + timeout="") + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=ReportFormat.CSV, + timeout=1, attempts="") + self.assertRaises(TypeError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=ReportFormat.CSV, + timeout=1, attempts=1, retry_each="") + self.assertRaises(ValueError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=ReportFormat.CSV, + timeout=1, attempts=0, retry_each=1) + self.assertRaises(ValueError, _get_report, project_id=123, + report_id=456, distiller_instance_url="", + distiller_token="", report_format=ReportFormat.CSV, + timeout=1, attempts=1, retry_each=-1) + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_report_bad_url(self, mock_serv_req): + """Test the behaviour for bad instance URLs.""" + mock_serv_req.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _get_report, project_id=123, + report_id=456, attempts=1, retry_each=1, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_report_bad_url_online(self): + """Test the behaviour for bad instance URLs.""" + self.assertRaises(Exception, _get_report, project_id=123, + report_id=456, attempts=1, retry_each=1, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_report_output_xlsx(self, mock_serv_req): + """Test the output type of the request.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = ( + "application/vnd.openxmlformats-officedocuments." + + "spreadsheetml.sheet" + ) + dataframe_ = pd.DataFrame({ + 'a': [1], 'b': [2], 'c': [3], 'd': [4] + }) + buffer_ = io.BytesIO() + dataframe_.to_excel(buffer_, index=True) # type: ignore[arg-type] + buffer_.seek(0) + response_._content = buffer_.getvalue() + mock_serv_req.return_value = response_ + response_ = _get_report( + project_id=123, report_id=456, report_format=ReportFormat.EXCEL, + distiller_instance_url="https://example.org", attempts=1, + distiller_token="DISTILLER_TOKEN" + ) + self.assertIsInstance(response_, pd.DataFrame) + + # This test requires the DISTILLER_API_KEY, DISTILLER_INSTANCE_URL, + # DISTILLER_PROJECT_ID_TEST, and DISTILLER_REPORT_ID_TEST environment + # variables to be set. This test performs real requests to the DistillerSR + # API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_report_output_xlsx_online(self): + """Test the output type of the request.""" + token_ = _authentication._get_authentication_token( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + response_ = _get_report( + project_id=int(os.getenv("DISTILLER_PROJECT_ID_TEST")), + report_id=int(os.getenv("DISTILLER_REPORT_ID_TEST")), + report_format=ReportFormat.EXCEL, + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_token=token_, attempts=1 + ) + self.assertIsInstance(response_, pd.DataFrame) + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_report_output_csv(self, mock_serv_req): + """Test the output type of the request.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "text/csv" + response_._content = "a,b\n1,2".encode("utf-8") + mock_serv_req.return_value = response_ + response_ = _get_report( + project_id=123, report_id=456, report_format=ReportFormat.CSV, + distiller_instance_url="https://example.org", + distiller_token="DISTILLER_TOKEN", attempts=1 + ) + self.assertIsInstance(response_, pd.DataFrame) + + @patch("pystiller._core._datarama._requests._perform_service_request") + @patch("pystiller._core._datarama.time.sleep") + def test__get_report_delay(self, mock_sleep, mock_serv_req): + """Test the output type of the request.""" + sleep_called_ = False + def _mark_sleep(*args, **kwargs): + nonlocal sleep_called_ + sleep_called_ = True + mock_sleep.side_effect = _mark_sleep + mock_serv_req.side_effect = HTTPError + self.assertRaises(RuntimeError, _get_report, project_id=123, + report_id=456, report_format=ReportFormat.CSV, + distiller_instance_url="https://example.org", + distiller_token="DISTILLER_TOKEN", attempts=2, + retry_each=1, verbose=False) + self.assertTrue(sleep_called_) + + @patch("pystiller._core._datarama._requests._perform_service_request") + def test__get_report_verbose(self, mock_serv_req): + """Test the output type of the request.""" + mock_serv_req.side_effect = HTTPError + self.assertRaises(RuntimeError, _get_report, project_id=123, + report_id=456, report_format=ReportFormat.CSV, + distiller_instance_url="https://example.org", + distiller_token="DISTILLER_TOKEN", attempts=2, + retry_each=1) diff --git a/tests/test__env.py b/tests/test__env.py new file mode 100644 index 0000000..476644a --- /dev/null +++ b/tests/test__env.py @@ -0,0 +1,28 @@ +import os +import unittest + +from pystiller._utils._env import _read_environment_variable + + +class TestEnv(unittest.TestCase): + + #################################### + # _read_environment_variable() # + #################################### + + def test__read_environment_variable_invalid(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _read_environment_variable, name=123) + + def test__read_environment_variable_not_set(self): + """Test the behaviour for unset environment variables.""" + if "TEST_VAR" in os.environ: + del os.environ["TEST_VAR"] + self.assertRaises(ValueError, _read_environment_variable, + name="TEST_VAR") + + def test__read_environment_variable_output(self): + """Test the behaviour for valid data.""" + os.environ["TEST_VAR"] = "some value" + self.assertIsInstance(_read_environment_variable(name="TEST_VAR"), str) + del os.environ["TEST_VAR"] diff --git a/tests/test__projects.py b/tests/test__projects.py new file mode 100644 index 0000000..37dce0b --- /dev/null +++ b/tests/test__projects.py @@ -0,0 +1,84 @@ +import json +import os +import unittest +from unittest.mock import patch +import pandas as pd +import requests +from dotenv import load_dotenv +from pystiller._core import _authentication +from requests import Response + +from pystiller._core._projects import _get_projects + +load_dotenv() + + +class TestProjects(unittest.TestCase): + + ################### + # _get_projects() # + ################### + + def test__get_projects_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _get_projects, + distiller_instance_url=123, distiller_token="") + self.assertRaises(TypeError, _get_projects, + distiller_instance_url="", distiller_token=123) + self.assertRaises(TypeError, _get_projects, + distiller_instance_url="", distiller_token="", + timeout="") + + @patch("pystiller._core._projects._requests._perform_service_request") + def test__get_projects_bad_url(self, mock_serv_req): + """Test the behaviour for bad instance URLs.""" + mock_serv_req.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _get_projects, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_projects_bad_url_online(self): + """Test the behaviour for bad instance URLs.""" + self.assertRaises(Exception, _get_projects, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN") + + @patch("pystiller._core._projects._requests._perform_service_request") + def test__get_projects_output(self, mock_serv_req): + """Test the output type of the request.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = json.dumps({ + "a": [1], "b": [2], "c": [3], "d": [4] + }).encode("utf-8") + mock_serv_req.return_value = response_ + response_ = _get_projects( + distiller_instance_url="https://example.org", + distiller_token="DISTILLER_TOKEN" + ) + self.assertIsInstance(response_, pd.DataFrame) + self.assertEqual(len(response_.columns), 4) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__get_projects_output_online(self): + """Test the output type of the request.""" + token_ = _authentication._get_authentication_token( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + response_ = _get_projects( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_token=token_ + ) + self.assertIsInstance(response_, pd.DataFrame) + self.assertEqual(len(response_.columns), 4) diff --git a/tests/test__requests.py b/tests/test__requests.py new file mode 100644 index 0000000..d3bb126 --- /dev/null +++ b/tests/test__requests.py @@ -0,0 +1,356 @@ +import io +import json +import os +import unittest +from json import JSONDecodeError +from unittest.mock import patch +import pandas as pd +import requests +from dotenv import load_dotenv +from requests import Response, HTTPError + +from pystiller._utils._requests import (_perform_authentication_request, + _perform_service_request, + _handle_http_errors, + _parse_json_response, + _parse_csv_response, + _parse_xlsx_response) + +load_dotenv() + + +class TestRequests(unittest.TestCase): + + ##################################### + # _perform_authentication_request() # + ##################################### + + def test__perform_authentication_request_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _perform_authentication_request, + distiller_instance_url=123, distiller_key="") + self.assertRaises(TypeError, _perform_authentication_request, + distiller_instance_url="", distiller_key=123) + self.assertRaises(TypeError, _perform_authentication_request, + distiller_instance_url="", distiller_key="", + timeout="") + + @patch("pystiller._utils._requests.requests.post") + def test__perform_authentication_request_bad_url(self, mock_post): + """Test the behaviour for bad instance URLs.""" + mock_post.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _perform_authentication_request, + distiller_instance_url="https://invalid-domain", + distiller_key="DISTILLER_API_KEY") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_authentication_request_bad_url_online(self): + """Test the behaviour for bad instance URLs.""" + self.assertRaises(Exception, _perform_authentication_request, + distiller_instance_url="https://invalid-domain", + distiller_key="DISTILLER_API_KEY") + + @patch("pystiller._utils._requests.requests.post") + def test__perform_authentication_request_output(self, mock_post): + """Test the output type of the request.""" + mock_post.return_value = Response() + response_ = _perform_authentication_request( + distiller_instance_url="https://example.org", + distiller_key="DISTILLER_API_KEY" + ) + self.assertIsInstance(response_, Response) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_authentication_request_output_online(self): + """Test the output type of the request.""" + response_ = _perform_authentication_request( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + self.assertIsInstance(response_, Response) + + ############################## + # _perform_service_request() # + ############################## + + def test__perform_service_request_types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, _perform_service_request, service_url=123, + distiller_token="") + self.assertRaises(TypeError, _perform_service_request, service_url="", + distiller_token=123) + self.assertRaises(TypeError, _perform_service_request, service_url="", + distiller_token="", body=123) + self.assertRaises(TypeError, _perform_service_request, service_url="", + distiller_token="", body={}, timeout="") + + @patch("pystiller._utils._requests.requests.get") + def test__perform_service_request_get_bad_url(self, mock_get): + """Test the behaviour for bad instance URLs in GET requests.""" + mock_get.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _perform_service_request, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_API_TOKEN") + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_service_request_get_bad_url_online(self): + """Test the behaviour for bad instance URLs in GET requests.""" + self.assertRaises(Exception, _perform_service_request, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_API_TOKEN") + + @patch("pystiller._utils._requests.requests.get") + def test__perform_service_request_get_output(self, mock_get): + """Test the output type of GET requests.""" + mock_get.return_value = Response() + response_ = _perform_service_request( + service_url="https://example.org/projects", + distiller_token="DISTILLER_API_TOKEN" + ) + self.assertIsInstance(response_, Response) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_service_request_get_output_online(self): + """Test the output type of GET requests.""" + auth_response_ = _perform_authentication_request( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + token_ = auth_response_.json()["token"] + + response_ = _perform_service_request( + service_url=f"{os.getenv("DISTILLER_INSTANCE_URL")}/projects", + distiller_token=token_ + ) + self.assertIsInstance(response_, Response) + + @patch("pystiller._utils._requests.requests.post") + def test__perform_service_request_post_bad_url(self, mock_post): + """Test the behaviour for bad instance URLs in POST requests.""" + mock_post.side_effect = requests.exceptions.ConnectionError + self.assertRaises(Exception, _perform_service_request, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN", body={'a': 1}) + + # This test performs real requests. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_service_request_post_bad_url_online(self): + """Test the behaviour for bad instance URLs in POST requests.""" + self.assertRaises(Exception, _perform_service_request, + distiller_instance_url="https://invalid-domain", + distiller_token="DISTILLER_TOKEN", body={'a': 1}) + + @patch("pystiller._utils._requests.requests.post") + def test__perform_service_request_post_output(self, mock_post): + """Test the output type of the POST request.""" + mock_post.return_value = Response() + response_ = _perform_service_request( + service_url="https://example.org/datarama/query", + distiller_token="DISTILLER_TOKEN", body={'a': 1} + ) + self.assertIsInstance(response_, Response) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test__perform_service_request_post_output_online(self): + """Test the output type of the POST request.""" + auth_response_ = _perform_authentication_request( + distiller_instance_url=os.getenv("DISTILLER_INSTANCE_URL"), + distiller_key=os.getenv("DISTILLER_API_KEY") + ) + token_ = auth_response_.json()["token"] + + response_ = _perform_service_request( + service_url=f"{os.getenv("DISTILLER_INSTANCE_URL")}/datarama/query", + distiller_token=token_, + body={ + "project_id": 42483, + "saved_report_id": 155, + "use_saved_format": True + } + ) + self.assertIsInstance(response_, Response) + + ######################### + # _handle_http_errors() # + ######################### + + def test__handle_http_errors_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _handle_http_errors, response=123) + self.assertRaises(TypeError, _handle_http_errors, + response=requests.Response(), error_message=123) + + def test__handle_http_errors_valid(self): + """Test the behaviour for status code 200.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "GET" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + self.assertIsNone(_handle_http_errors(response=response_)) + + def test__handle_http_errors_invalid(self): + """Test the behaviour for bad status codes.""" + response_ = Response() + response_.status_code = 502 + response_.url = "https://example.org" + response_.method = "GET" + self.assertRaises(HTTPError, _handle_http_errors, response=response_) + + ########################## + # _parse_json_response() # + ########################## + + def test__parse_json_response_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _parse_json_response, response=123) + self.assertRaises(TypeError, _parse_json_response, + response=requests.Response(), error_message=123) + + def test__parse_json_response_valid(self): + """Test the behaviour for valid parameters and data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + self.assertIsInstance( + _parse_json_response(response=response_), + dict + ) + + def test__parse_json_response_valid_flatten(self): + """Test the behaviour for valid parameters and data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "application/json" + response_._content = (json.dumps({"data": "Custom data"}) + .encode("utf-8")) + self.assertIsInstance( + _parse_json_response(response=response_, flatten=True), + pd.DataFrame + ) + + def test__parse_json_response_invalid(self): + """Test the behaviour for invalid body data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "text/html" + response_._content = "content".encode("utf-8") + self.assertRaises( + JSONDecodeError, + _parse_json_response, + response=response_ + ) + + ######################### + # _parse_csv_response() # + ######################### + + def test__parse_csv_response_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _parse_csv_response, response=123) + self.assertRaises(TypeError, _parse_csv_response, + response=requests.Response(), error_message=123) + + def test__parse_csv_response_valid(self): + """Test the behaviour for valid parameters and data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "text/csv" + response_._content = "a,b\n1,2".encode("utf-8") + self.assertIsInstance( + _parse_csv_response(response=response_), + pd.DataFrame + ) + + def test__parse_csv_response_invalid(self): + """Test the behaviour for invalid body data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = "text/csv" + response_._content = "a,b\n1,2\n3\n4,5,6".encode("utf-8") + self.assertRaises( + Exception, + _parse_csv_response, + response=response_ + ) + + ########################## + # _parse_xlsx_response() # + ########################## + + def test__parse_xlsx_response_types(self): + """Test the behaviour for invalid parameters.""" + self.assertRaises(TypeError, _parse_xlsx_response, response=123) + self.assertRaises(TypeError, _parse_xlsx_response, + response=requests.Response(), error_message=123) + + def test__parse_xlsx_response_valid(self): + """Test the behaviour for valid parameters and data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = ( + "application/vnd.openxmlformats-officedocuments." + + "spreadsheetml.sheet" + ) + dataframe_ = pd.DataFrame({ + 'a': [1, 2], + 'b': [3, 4] + }) + buffer_ = io.BytesIO() + dataframe_.to_excel(buffer_, index=True) # type: ignore[arg-type] + buffer_.seek(0) + response_._content = buffer_.getvalue() + self.assertIsInstance( + _parse_xlsx_response(response=response_), + pd.DataFrame + ) + + def test__parse_xlsx_response_invalid(self): + """Test the behaviour for invalid body data.""" + response_ = Response() + response_.status_code = 200 + response_.url = "https://example.org" + response_.method = "POST" + response_.headers["Content-Type"] = ( + "application/vnd.openxmlformats-officedocuments." + + "spreadsheetml.sheet" + ) + response_._content = b"Not an XLSX content" + self.assertRaises( + Exception, + _parse_xlsx_response, + response=response_ + ) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c57b165 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,150 @@ +import os +import unittest +from unittest.mock import patch +import pandas as pd +from dotenv import load_dotenv + +from pystiller.client import Client + +load_dotenv() + +class TestClient(unittest.TestCase): + + ############## + # __init__() # + ############## + + def test___init___types(self): + """Test the behaviour for invalid data.""" + self.assertRaises(TypeError, Client, distiller_key=123, + distiller_instance_url="https://example.org") + self.assertRaises(ValueError, Client, distiller_key='', + distiller_instance_url="https://example.org") + self.assertRaises(TypeError, Client, distiller_key="DISTILLER_API_KEY", + distiller_instance_url=123) + self.assertRaises(ValueError, Client, + distiller_key="DISTILLER_API_KEY", + distiller_instance_url='') + + @patch("pystiller.client._env._read_environment_variable") + @patch("pystiller.client._authentication._get_authentication_token") + def test___init__1(self, mock_get_auth_token, mock_read_env_var): + """Test the correct creation of the object.""" + mock_read_env_var.return_value = "test" + mock_get_auth_token.return_value = "test" + with patch.dict(os.environ, { + "DISTILLER_API_KEY": "test", + "DISTILLER_INSTANCE_URL": "test", + }, clear=True): + self._client = Client() + self.assertIsInstance(self._client, Client) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test___init__1_online(self): + """Test the correct creation of the object.""" + self._client = Client() + self.assertIsInstance(self._client, Client) + + @patch("pystiller.client._authentication._get_authentication_token") + def test___init__2(self, mock_get_auth_token): + """Test the correct creation of the object.""" + mock_get_auth_token.return_value = "test" + self.assertIsInstance(Client( + distiller_key="DISTILLER_API_KEY", + distiller_instance_url="DISTILLER_INSTANCE_URL" + ), Client) + + @patch("pystiller.client._authentication._get_authentication_token") + def test___init___instance_url_trailing_slash(self, mock_get_auth_token): + """Test the behaviour for trailing slash in instance URLs.""" + mock_get_auth_token.return_value = "test" + client_ = Client( + distiller_key="DISTILLER_API_KEY", + distiller_instance_url="https://example.org/") + self.assertEqual( + client_._distiller_instance_url, + "https://example.org") + + ################## + # get_projects() # + ################## + + @patch("pystiller.client._authentication._get_authentication_token") + @patch("pystiller.client._projects._get_projects") + def test_get_projects(self, mock_get_projects, mock_get_auth): + mock_get_projects.return_value = pd.DataFrame() + mock_get_auth.return_value = "test_token" + client_ = Client(distiller_key="DISTILLER_API_KEY", + distiller_instance_url="https://example.org", + automatic_token_refresh=True) + projects_ = client_.get_projects() + self.assertIsInstance(projects_, pd.DataFrame) + + # This test requires the DISTILLER_API_KEY and DISTILLER_INSTANCE_URL + # environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_get_projects_online(self): + client_ = Client() + projects_ = client_.get_projects() + self.assertIsInstance(projects_, pd.DataFrame) + + ################# + # get_reports() # + ################# + + @patch("pystiller.client._authentication._get_authentication_token") + @patch("pystiller.client._datarama._get_reports") + def test_get_reports(self, mock_get_reports, mock_get_auth): + mock_get_reports.return_value = pd.DataFrame() + mock_get_auth.return_value = "test_token" + client_ = Client(distiller_key="DISTILLER_API_KEY", + distiller_instance_url="https://example.org", + automatic_token_refresh=True) + reports_ = client_.get_reports(project_id=123) + self.assertIsInstance(reports_, pd.DataFrame) + + # This test requires the DISTILLER_API_KEY, DISTILLER_INSTANCE_URL, and + # DISTILLER_PROJECT_ID_TEST environment variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_get_reports_online(self): + client_ = Client() + reports_ = client_.get_reports( + project_id=int(os.getenv("DISTILLER_PROJECT_ID_TEST"))) + self.assertIsInstance(reports_, pd.DataFrame) + self.assertIsInstance(reports_, pd.DataFrame) + + ################ + # get_report() # + ################ + + @patch("pystiller.client._authentication._get_authentication_token") + @patch("pystiller.client._datarama._get_report") + def test_get_report(self, mock_get_report, mock_get_auth): + mock_get_report.return_value = pd.DataFrame() + mock_get_auth.return_value = "test_token" + client_ = Client(distiller_key="DISTILLER_API_KEY", + distiller_instance_url="https://example.org", + automatic_token_refresh=True) + report_ = client_.get_report(project_id=123, report_id=456) + self.assertIsInstance(report_, pd.DataFrame) + + # This test requires the DISTILLER_API_KEY, DISTILLER_INSTANCE_URL, + # DISTILLER_PROJECT_ID_TEST, and DISTILLER_REPORT_ID_TEST environment + # variables to be set. + # This test performs real requests to the DistillerSR API. + @unittest.skipIf(os.getenv("SKIP_ONLINE_TESTS") == "true", + "Skip online tests") + def test_get_report_online(self): + client_ = Client() + report_ = client_.get_report( + project_id=int(os.getenv("DISTILLER_PROJECT_ID_TEST")), + report_id=int(os.getenv("DISTILLER_REPORT_ID_TEST"))) + self.assertIsInstance(report_, pd.DataFrame) + self.assertIsInstance(report_, pd.DataFrame) From 9a3225b4315560b4afce8ff148c118efe4400e28 Mon Sep 17 00:00:00 2001 From: Lorenzo Copelli Date: Wed, 8 Apr 2026 18:27:04 +0200 Subject: [PATCH 2/5] Minor fixes in tests. (#2) --- tests/test__requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test__requests.py b/tests/test__requests.py index d3bb126..29288eb 100644 --- a/tests/test__requests.py +++ b/tests/test__requests.py @@ -131,7 +131,7 @@ def test__perform_service_request_get_output_online(self): token_ = auth_response_.json()["token"] response_ = _perform_service_request( - service_url=f"{os.getenv("DISTILLER_INSTANCE_URL")}/projects", + service_url=f"{os.getenv('DISTILLER_INSTANCE_URL')}/projects", distiller_token=token_ ) self.assertIsInstance(response_, Response) @@ -177,7 +177,7 @@ def test__perform_service_request_post_output_online(self): token_ = auth_response_.json()["token"] response_ = _perform_service_request( - service_url=f"{os.getenv("DISTILLER_INSTANCE_URL")}/datarama/query", + service_url=f"{os.getenv('DISTILLER_INSTANCE_URL')}/datarama/query", distiller_token=token_, body={ "project_id": 42483, From 6bacdc09b19824f17fe807a5a98b9060353195b7 Mon Sep 17 00:00:00 2001 From: Lorenzo Copelli Date: Tue, 14 Apr 2026 14:59:17 +0200 Subject: [PATCH 3/5] Minor fixes. (#4) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d15b2a2..a7dff64 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# pystiller +# pystiller [![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![codecov](https://codecov.io/gh/openefsa/pystiller/branch/main/graph/badge.svg?token=VL7426RVCI)](https://codecov.io/gh/openefsa/pystiller) From 2f7fbc3840b92da7a7badcfcf812298201bcc7ab Mon Sep 17 00:00:00 2001 From: Lorenzo Copelli Date: Tue, 14 Apr 2026 15:06:03 +0200 Subject: [PATCH 4/5] Minor fixes. (#6) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7dff64..895b36b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# pystiller +# pystiller [![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![codecov](https://codecov.io/gh/openefsa/pystiller/branch/main/graph/badge.svg?token=VL7426RVCI)](https://codecov.io/gh/openefsa/pystiller) From 2083f33eec043c2977197835a0199b5c897dcbb0 Mon Sep 17 00:00:00 2001 From: Lorenzo Copelli Date: Tue, 14 Apr 2026 16:44:57 +0200 Subject: [PATCH 5/5] Minor fixes. (#8) --- .github/workflows/mkdocs.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index b40ad50..fac16a9 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -26,8 +26,6 @@ jobs: run: | cp README.md docs/index.md sed -i 's|docs/guide.md|guide/|g' docs/index.md - mkdir docs/media - cp media/logo.png docs/media/logo.png - name: Build mkdocs site run: mkdocs build