From 5fb63b709c3ef4c7b3161b6d918f410ad664b5c9 Mon Sep 17 00:00:00 2001 From: hbsbti Date: Fri, 6 Mar 2026 13:43:32 -0500 Subject: [PATCH] API v2.0.0: Complete revamp for sbti-finance-tool >= 1.2.5 - Modernize stack: Python 3.11+, FastAPI latest, pydantic v2, uvicorn direct - Split monolith endpoint into separate routes: temperature scoring, portfolio coverage, what-if analysis, file upload - Add /v1/ versioned URL prefix and OpenAPI docs with field descriptions - Add health/readiness endpoints for container orchestration - Simplify Docker: drop nginx, supervisord; uvicorn runs directly on python:3.11-slim - Add CI workflow (lint + test, Python 3.11/3.12) and Docker publish workflow (ghcr.io) - Remove dead code: wsgi.py, static swagger.json, unused config files, extra docker-compose variants - Add pytest test suite for all endpoints --- .dockerignore | 10 + .github/workflows/ci.yml | 27 + .github/workflows/docker-publish.yml | 39 ++ .vscode/settings.json | 15 +- Dockerfile | 50 +- README.md | 163 ++---- app/config.json | 44 +- app/config.py | 21 + app/dependencies.py | 18 + app/main.py | 211 ++----- config/config.yaml => app/routers/__init__.py | 0 app/routers/coverage.py | 40 ++ app/routers/health.py | 16 + app/routers/providers.py | 22 + app/routers/temperature.py | 56 ++ app/routers/upload.py | 121 ++++ app/routers/whatif.py | 60 ++ app/schemas/__init__.py | 0 app/schemas/common.py | 18 + app/schemas/coverage.py | 17 + app/schemas/temperature.py | 26 + app/schemas/upload.py | 10 + app/schemas/whatif.py | 31 + app/static/swagger.json | 297 ---------- app/uploads/.gitignore | 3 - app/wsgi.py | 8 - config/api-nginx.conf | 33 -- config/nginx.conf | 73 --- config/supervisord.conf | 17 - config/uwsgi.ini | 13 - docker-compose-ui-dev.yml | 12 - docker-compose-ui.yml | 12 - docker-compose.yml | 11 +- docker-compose_aws_example.yml | 8 - poetry.lock | 553 ------------------ pyproject.toml | 42 +- requirements.txt | 26 - tests/__init__.py | 0 tests/conftest.py | 27 + tests/test_coverage.py | 18 + tests/test_health.py | 12 + tests/test_providers.py | 8 + tests/test_temperature.py | 19 + tests/test_upload.py | 28 + tests/test_whatif.py | 22 + 45 files changed, 820 insertions(+), 1437 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 app/config.py create mode 100644 app/dependencies.py rename config/config.yaml => app/routers/__init__.py (100%) create mode 100644 app/routers/coverage.py create mode 100644 app/routers/health.py create mode 100644 app/routers/providers.py create mode 100644 app/routers/temperature.py create mode 100644 app/routers/upload.py create mode 100644 app/routers/whatif.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/common.py create mode 100644 app/schemas/coverage.py create mode 100644 app/schemas/temperature.py create mode 100644 app/schemas/upload.py create mode 100644 app/schemas/whatif.py delete mode 100644 app/static/swagger.json delete mode 100644 app/uploads/.gitignore delete mode 100644 app/wsgi.py delete mode 100644 config/api-nginx.conf delete mode 100644 config/nginx.conf delete mode 100644 config/supervisord.conf delete mode 100644 config/uwsgi.ini delete mode 100644 docker-compose-ui-dev.yml delete mode 100644 docker-compose-ui.yml delete mode 100644 docker-compose_aws_example.yml delete mode 100644 poetry.lock delete mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_coverage.py create mode 100644 tests/test_health.py create mode 100644 tests/test_providers.py create mode 100644 tests/test_temperature.py create mode 100644 tests/test_upload.py create mode 100644 tests/test_whatif.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d64f19 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.venv +.vscode +__pycache__ +*.pyc +tests/ +.dockerignore +.gitignore +README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ef03b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install poetry + poetry install + - name: Lint + run: poetry run ruff check app/ tests/ + - name: Test + run: poetry run pytest tests/ -v diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..3439fad --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,39 @@ +name: Docker Publish + +on: + release: + types: [published] + push: + tags: ["v*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.vscode/settings.json b/.vscode/settings.json index 96750c9..c501179 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,7 @@ { "editor.formatOnSave": true, - "python.linting.enabled": true, - "python.linting.lintOnSave": true, - "python.testing.unittestEnabled": true, - "python.testing.pytestEnabled": false, - "jupyter.notebookFileRoot": "${workspaceFolder}", - "python.testing.cwd": "${cwd}", "python.defaultInterpreterPath": ".venv/bin/python", - "python.formatting.provider": "black", - "python.testing.unittestArgs": ["-v", "-s", "./test", "-p", "*test*.py"], - "restructuredtext.confPath": "${workspaceFolder}/docs" - } - \ No newline at end of file + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": ["-v", "tests/"] +} diff --git a/Dockerfile b/Dockerfile index 5dac9b2..c14ccf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,43 +1,21 @@ -FROM python:3.8 +FROM python:3.11-slim -COPY requirements.txt config/config.yaml /project/ - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - libatlas-base-dev gfortran nginx supervisor \ - && rm -rf /var/lib/apt/lists/* \ - && pip3 install -r /project/requirements.txt \ - && rm -r /root/.cache - -ARG uid=210 -ARG gid=210 - -RUN groupadd -g ${gid} dock_sbtiapi \ - && useradd -u ${uid} -g ${gid} dock_sbtiapi \ - && mkdir /home/dock_sbtiapi \ - && chown -R dock_sbtiapi:dock_sbtiapi /home/dock_sbtiapi - -RUN rm /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default \ - && mkdir -p /vol/log/nginx /vol/tmp/nginx \ - && touch /vol/log/nginx/{access.log,error.log} \ - && rm -rf /var/log/nginx \ - && ln -s /vol/log/nginx /var/log/nginx - -COPY app /project/app -COPY config/nginx.conf /etc/nginx/nginx.conf -COPY config/api-nginx.conf /etc/nginx/sites-available/api-nginx.conf -COPY config/supervisord.conf /etc/supervisord.conf -COPY app/config.json /project/config.json -COPY app/data /project/data +WORKDIR /project +COPY pyproject.toml poetry.lock* ./ +RUN pip install --no-cache-dir poetry \ + && poetry config virtualenvs.create false \ + && poetry install --without dev --no-interaction --no-ansi \ + && pip uninstall -y poetry -RUN ln -s /etc/nginx/sites-available/api-nginx.conf /etc/nginx/sites-enabled/api-nginx.conf \ - && chown -R dock_sbtiapi:dock_sbtiapi /project /vol +COPY app/ ./app/ -WORKDIR /project +RUN useradd -m -r appuser && chown -R appuser:appuser /project +USER appuser -USER dock_sbtiapi -EXPOSE 80 -CMD ["/usr/bin/supervisord"] +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/README.md b/README.md index a950b3a..121248b 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,83 @@ -> Visit http://getting-started.sbti-tool.org/ for the full documentation - > If you have any additional questions or comments send a mail to: finance@sciencebasedtargets.org -# SBTi Temperature Alignment tool API -This package helps companies and financial institutions to assess the temperature alignment of current -targets, commitments, and investment and lending portfolios, and to use this information to develop -targets for official validation by the SBTi. - -Under the hood, this API uses the SBTi Python module. The complete structure that consists of a Python module, API and a UI looks as follows: - - +-------------------------------------------------+ - | UI : Simple user interface on top of API | - | Install: via dockerhub | - | docker.io/sbti/ui:latest | - | | - | +-----------------------------------------+ | - | | REST API: Dockerized FastAPI/NGINX | | - | | Source : github.com/OFBDABV/SBTi_api | | - | | Install: via source or dockerhub | | - | | docker.io/sbti/sbti/api:latest | | - | | | | - | | +---------------------------------+ | | - | | | | | | - | | |Core : Python Module | | | - | | |Source : github.com/OFBDABV/SBTi | | | - | | |Install: via source or PyPi | | | - | | | | | | - | | +---------------------------------+ | | - | +-----------------------------------------+ | - +-------------------------------------------------+ - -Note that one can deploy the api also including a User interface. This repo depends on a docker image -(docker.io/sbti/ui:latest) that can be spinned up if necessary, see instruction in the deployment section. - -## Structure -The folder structure for this project is as follows: - - . - ├── .github # Github specific files (Github Actions workflows) - ├── app # FastAPI app files for the API endpoints - └── config # Config files for the Docker container - -## Deployment -This service can be deployed in two ways, either as a standalone API or in conjunction with a no-frills UI. -For both of these options a docker configuration has been set up. +# SBTi Temperature Alignment Tool — REST API -In order to run the docker container locally or non linux machines one needs to install [Docker Desktop](https://www.docker.com/products/docker-desktop) available for Mac and Windows +REST API for portfolio temperature scoring, coverage analysis, and what-if scenario modeling using the [SBTi Finance Tool](https://github.com/ScienceBasedTargets/SBTi-finance-tool) Python package. -### API-only -The master branch of this repo has a public image at Dockerhub. To run them, use the following commands: - -```bash -docker run -d -p 5000:8080 sbti/api:latest # to run the latest stable release -``` -In order to run a locally build version run: +## Quickstart ```bash docker-compose up --build ``` -The API swagger documentation should now be available at [http://localhost:5000/docs/](http://localhost:5000/docs/). +API docs available at [http://localhost:8000/docs](http://localhost:8000/docs). -### API and UI -To launch both the API and the UI, you need to use the provided docker-compose files. -This will spin up two containers that work in conjunction with one another. +## Endpoints -To launch the latest release: -```bash -docker-compose -f docker-compose-ui.yml up -d --build -``` +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check | +| GET | `/health/ready` | Readiness check with version | +| GET | `/v1/data-providers` | List configured data providers | +| POST | `/v1/temperature/score` | Calculate portfolio temperature scores | +| POST | `/v1/coverage` | Calculate portfolio coverage | +| POST | `/v1/temperature/whatif` | Run what-if scenario analysis | +| POST | `/v1/upload/csv` | Upload CSV portfolio and score | +| POST | `/v1/upload/excel` | Upload Excel portfolio and score | +| POST | `/v1/upload/parse` | Parse Excel file to JSON | -To use your local code-base: -```bash -docker-compose -f docker-compose-ui-dev.yml up -d --build -``` - -The UI should now be available at [http://localhost:5000/](http://localhost:5000/) and check [http://localhost:5001/docs/](http://localhost:5001/docs/) for the API documentation +## Structure -To build an run the docker container locally use the following command: -```bash -docker-compose up -d --build +``` +. +├── .github/workflows # CI and Docker publish workflows +├── app/ # FastAPI application +│ ├── main.py # App entry point +│ ├── config.py # Configuration loader +│ ├── config.json # Data provider configuration +│ ├── dependencies.py # Shared utilities +│ ├── routers/ # Endpoint definitions +│ ├── schemas/ # Request/response models +│ └── data/ # Sample data files +├── tests/ # Pytest test suite +├── Dockerfile # Container image +└── docker-compose.yml # Local deployment ``` -## Deploy on Amazon Web Services -These instructions assume that you've installed and configured the Amazon [AWS CLI tools](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) and the [ECS CLI tools](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_CLI_Configuration.html) with an IAM account that has at least write access to ECS and EC2 and the capability of creating AIM roles. +## Configuration + +Data providers are configured in `app/config.json`. Override the config path with the `SBTI_CONFIG_PATH` environment variable. + +## Development -1. Configure the cluster. You can update the region and names as you see fit -```bash -ecs-cli configure --cluster sbti-ecs-cluster --region eu-central-1 --config-name sbti-ecs-conf --cfn-stack-name sbti-ecs-stack --default-launch-type ec2 -``` -2. Create a new key pair. The result of this command is a key. Store this safely as you can later use it to access your instance through SSH. -```bash -aws ec2 create-key-pair --key-name sbti -``` -3. Create the instance that'll run the image. Here we used 1 server of type t2.medium. Change this as you see fit. -```bash -ecs-cli up --keypair sbti --capability-iam --size 1 --instance-type t2.medium --cluster-config sbti-ecs-conf -``` -4. Update the server and make it run the docker image. -```bash -ecs-cli compose -f docker-compose_aws.yml up --cluster-config sbti-ecs-conf -``` -5. Now that the instance is running we can't access it yet. That's because NGINX only listens to localhost. We need to change this to make sure it's accessible on the WWW. -6. Login to the Amazon AWS console -7. Go to the EC2 service -8. In the instance list find the instance running the Docker image -9. Copy the public IP address of the instance -10. In ```config/api-nginx.conf``` update the server name to the public IP. -11. Now we need to rebuild and re-upload the image. ```bash -docker-compose -f docker-compose_aws.yml build --no-cache -docker-compose -f docker-compose_aws.yml push -ecs-cli compose -f docker-compose_aws.yml up --cluster-config sbti-ecs-conf --force-update -``` -12. You should now be able to access the API. +# Install dependencies +poetry install -> :warning: This will make the API publicly available on the world wide web! Please note that this API is not protected in any way. Therefore it's recommended to run your instance in a private subnet and only access it through there. Alternatively you can change the security group settings to only allow incoming connections from your local IP or company VPN. +# Run locally +uvicorn app.main:app --reload -## Development +# Run tests +pytest tests/ -v + +# Lint +ruff check app/ tests/ +``` -To set up the local dev environment with all dependencies, [install poetry](https://python-poetry.org/docs/#osx--linux--bashonwindows-install-instructions) and run +## Docker ```bash -poetry install +# Build and run +docker-compose up --build + +# Or directly +docker build -t sbti-api . +docker run -p 8000:8000 sbti-api ``` -This will create a virtual environment inside the project folder under `.venv`. +The container runs as a non-root user with a built-in health check. -### Updating Dependencies +## Requirements -always run `poetry export -f requirements.txt --output requirements.txt --without-hashes` after updating a dependency to keep the `requirements.txt` file up to date as well. \ No newline at end of file +- Python >= 3.11 +- [sbti-finance-tool](https://github.com/ScienceBasedTargets/SBTi-finance-tool) >= 1.2.5 diff --git a/app/config.json b/app/config.json index 770dee8..7d6ebeb 100644 --- a/app/config.json +++ b/app/config.json @@ -1,51 +1,21 @@ { "default_score": 3.2, - "verbosity": "INFO", "aggregation_method": "WATS", - "frontend_path": "static/frontend/dist/frontend/", "data_providers": [ { "type": "excel", "name": "Excel", "parameters": { - "path": "data/input_format.xlsx" + "path": "app/data/input_format.xlsx" } }, { - "type": "bloomberg", - "name": "Bloomberg (Coming soon)", - "parameters": { - } - }, - { - "type": "cdp", - "name": "CDP (Coming soon)", - "parameters": { - } - }, - { - "type": "iss", - "name": "ISS (Coming soon)", - "parameters": { - } - }, - { - "type": "trucost", - "name": "Trucost (Coming soon)", - "parameters": { - } - }, - { - "type": "urgentem", - "name": "Urgentem (Coming soon)", + "type": "csv", + "name": "CSV", "parameters": { + "path": "app/data/data_test_temperature_score.csv", + "path_targets": "app/data/data_test_temperature_score_targets.csv" } } - ], - "server": { - "host": "127.0.0.1", - "port": 5000, - "log_level": "info", - "reload": true - } -} \ No newline at end of file + ] +} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3fdc8f4 --- /dev/null +++ b/app/config.py @@ -0,0 +1,21 @@ +import json +import os +from functools import lru_cache + + +class AppConfig: + def __init__(self): + config_path = os.environ.get( + "SBTI_CONFIG_PATH", + os.path.join(os.path.dirname(os.path.realpath(__file__)), "config.json"), + ) + with open(config_path) as f: + raw = json.load(f) + self.default_score: float = raw.get("default_score", 3.2) + self.aggregation_method: str = raw.get("aggregation_method", "WATS") + self.data_providers: list = raw.get("data_providers", []) + + +@lru_cache +def get_config() -> AppConfig: + return AppConfig() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..617eb48 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,18 @@ +import pandas as pd +from typing import List + +import SBTi.utils +from SBTi.data import DataProvider +from app.config import AppConfig + + +def resolve_providers(provider_names: List[str], config: AppConfig) -> List[DataProvider]: + """Resolve provider name strings to DataProvider instances.""" + if not provider_names: + provider_names = [config.data_providers[0]["name"]] + return SBTi.utils.get_data_providers(config.data_providers, provider_names) + + +def df_to_records(df: pd.DataFrame) -> List[dict]: + """Convert a DataFrame to a list of dicts, replacing NaN with None for JSON serialization.""" + return df.where(pd.notnull(df), None).to_dict(orient="records") diff --git a/app/main.py b/app/main.py index dc8ee0f..037ae2d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,182 +1,59 @@ -import json -import os +from contextlib import asynccontextmanager -from typing import List, Optional +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse -import pandas as pd -import numpy as np -import uvicorn -from SBTi.interfaces import PortfolioCompany, ScenarioInterface, EScope, ETimeFrames, ScoreAggregations -from SBTi.portfolio_coverage_tvp import PortfolioCoverageTVP +from app.routers import health, providers, temperature, coverage, whatif, upload + + +DESCRIPTION = """ +REST API for the SBTi Finance Temperature Alignment Tool. + +Wraps the [sbti-finance-tool](https://github.com/ScienceBasedTargets/SBTi-finance-tool) +Python package. Provides endpoints for temperature scoring, portfolio coverage, +what-if scenario analysis, and file upload scoring. +""" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield -from fastapi import FastAPI, File, Form, UploadFile, Body, HTTPException, Request -from pydantic import BaseModel -import mimetypes -import SBTi -from SBTi.portfolio_aggregation import PortfolioAggregationMethod app = FastAPI( - title="SBTi Finance Temperature Alignment tool API", - description="This tool helps companies and financial institutions to assess the temperature alignment of current " - "targets, commitments, and investment and lending portfolios, and to use this information to develop " - "targets for official validation by the SBTi.", - version="0.1.0", + title="SBTi Finance Temperature Alignment Tool API", + description=DESCRIPTION, + version="2.0.0", + lifespan=lifespan, ) -mimetypes.init() -UPLOAD_FOLDER = 'data' - @app.middleware("http") -async def add_headers(request: Request, call_next): +async def add_security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Frame-Options"] = "deny" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Cache-Control"] = "no-cache" - response.headers["Pragma"] = "no-cache" return response -with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'config.json')) as f_config: - config = json.load(f_config) - - -class ResponseTemperatureScore(BaseModel): - aggregated_scores: Optional[ScoreAggregations] - scores: List[dict] - coverage: float - companies: List[dict] - - -@app.post("/temperature_score/", response_model=ResponseTemperatureScore, response_model_exclude_none=True) -def calculate_temperature_score( - companies: List[PortfolioCompany] = Body( - ..., - description="A portfolio containing the companies. If you want to use other fields later on or for grouping " - "you can include these in the 'user_fields' object."), - default_score: float = Body( - default=config["default_score"], - gte=0, - description="The default score to fall back on when there's no target available."), - data_providers: Optional[List[str]] = Body( - default=[], - description="A list of data provider names to use. These names should be available in the list that can be " - "retrieved through the /data_providers/ endpoint."), - aggregation_method: Optional[PortfolioAggregationMethod] = Body( - default=config["aggregation_method"], - description="The aggregation method to use."), - grouping_columns: Optional[List[str]] = Body( - default=None, - description="A list of column names that should be grouped on."), - include_columns: Optional[List[str]] = Body( - default=[], - description="A list of column names that should be included in the output."), - scenario: Optional[ScenarioInterface] = Body( - default=None, - description="The scenario that should be used. This will change (some of the) targets, to simulate a " - "what-if scenario."), - anonymize_data_dump: Optional[bool] = Body( - default=False, - description="Whether or not the resulting data set should be anonymized or not."), - aggregate: Optional[bool] = Body( - default=True, - description="Whether to calculate aggregations or not."), - scopes: Optional[List[EScope]] = Body( - default=[], - description="The scopes that should be included in the results."), - time_frames: Optional[List[ETimeFrames]] = Body( - default=[], - description="The time frames that should be included in the results.") -) -> ResponseTemperatureScore: - """ - Calculate the temperature score for a given set of parameters. - """ - try: - data_providers = SBTi.utils.get_data_providers(config["data_providers"], data_providers) - portfolio_data = SBTi.utils.get_data(data_providers, companies) - scores, aggregations = SBTi.utils.calculate( - portfolio_data=portfolio_data, - fallback_score=default_score, - time_frames=time_frames, - scopes=scopes, - aggregation_method=aggregation_method, - grouping=grouping_columns, - scenario=SBTi.temperature_score.Scenario.from_interface(scenario), - anonymize=anonymize_data_dump, - aggregate=aggregate - ) - - coverage = PortfolioCoverageTVP().get_portfolio_coverage(portfolio_data, aggregation_method) - except ValueError as e: - raise HTTPException(status_code=400, detail=repr(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=repr(e)) - - # Include columns - include_columns = ["company_name", "scope", "time_frame", "temperature_score"] + \ - [column for column in include_columns if column in scores.columns] - - return ResponseTemperatureScore( - aggregated_scores=aggregations, - scores=scores.where(pd.notnull(scores), None).to_dict(orient="records"), - coverage=coverage, - companies=scores[include_columns].replace({np.nan: None}).to_dict(orient="records") - ) - - -class ResponseDataProvider(BaseModel): - name: str - type: str - - -@app.get("/data_providers/", response_model=List[ResponseDataProvider]) -def get_data_providers() -> List[ResponseDataProvider]: - """ - Get a list of the available data providers. - """ - return [ResponseDataProvider(name=data_provider["name"], type=data_provider["type"]) - for data_provider in config["data_providers"]] - - -@app.post("/parse_portfolio/", response_model=List[dict]) -def parse_portfolio(file: bytes = File(...), skiprows: int = Form(...)): - """ - Parse a portfolio Excel file and return it as a list of dictionaries. - - *Note: This endpoint is only for use in the frontend* - """ - df = pd.read_excel(file, skiprows=int(skiprows)) - - return df.replace(r'^\s*$', np.nan, regex=True).dropna(how='all').replace({np.nan: None}).to_dict(orient="records") - - -@app.post("/import_data_provider/") -def import_data_provider(file: UploadFile = File(...)): - """ - Import a new Excel data provider file. This will overwrite the current "dummy" data provider input file. - - *Note: This endpoint is only for use in the frontend during the beta testing phase.* - """ - # TODO: Remove this endpoint after the beta testing phase - file_name = file.filename - file_type = file_name.split('.')[-1] - if file_type == 'xlsx': - x = 10000000 / 10000 - xi = 0 - with open(os.path.join(UPLOAD_FOLDER, 'input_format_tmp.xlsx'), 'ab') as f: - for chunk in iter(lambda: file.file.read(10000), b''): - f.write(chunk) - xi += 1 - if xi > x: - f.close() - os.remove(os.path.join(UPLOAD_FOLDER, 'input_format_tmp.xlsx')) - return {'POST Request': {'Response': {'Status Code': 400, 'Message': 'Error. File did not save.'}}} - os.replace(os.path.join(UPLOAD_FOLDER, 'input_format_tmp.xlsx'), - os.path.join(UPLOAD_FOLDER, 'input_format.xlsx')) - return {'detail': 'Data Provider Imported'} - else: - raise HTTPException(status_code=500, detail='Error. File did not save.') - - -if __name__ == "__main__": - uvicorn.run("main:app", host=config["server"]["host"], port=config["server"]["port"], - log_level=config["server"]["log_level"], reload=config["server"]["reload"]) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(ValueError) +async def value_error_handler(request: Request, exc: ValueError): + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + +app.include_router(health.router, tags=["Health"]) +app.include_router(providers.router, prefix="/v1", tags=["Data Providers"]) +app.include_router(temperature.router, prefix="/v1/temperature", tags=["Temperature Score"]) +app.include_router(coverage.router, prefix="/v1", tags=["Portfolio Coverage"]) +app.include_router(whatif.router, prefix="/v1/temperature", tags=["What-If Analysis"]) +app.include_router(upload.router, prefix="/v1/upload", tags=["File Upload"]) diff --git a/config/config.yaml b/app/routers/__init__.py similarity index 100% rename from config/config.yaml rename to app/routers/__init__.py diff --git a/app/routers/coverage.py b/app/routers/coverage.py new file mode 100644 index 0000000..fdf426c --- /dev/null +++ b/app/routers/coverage.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException + +import SBTi.utils +from SBTi.interfaces import PortfolioCompany +from SBTi.portfolio_coverage_tvp import PortfolioCoverageTVP + +from app.config import AppConfig, get_config +from app.dependencies import resolve_providers +from app.schemas.coverage import CoverageRequest, CoverageResponse + +router = APIRouter() + + +@router.post("/coverage", response_model=CoverageResponse, summary="Calculate portfolio coverage") +def calculate_coverage( + request: CoverageRequest, + config: AppConfig = Depends(get_config), +): + """Returns the percentage of the portfolio covered by companies with approved SBTi targets.""" + try: + portfolio = [PortfolioCompany(**c.model_dump()) for c in request.companies] + providers = resolve_providers(request.data_providers, config) + reporting_date = ( + datetime.fromisoformat(request.reporting_date) + if request.reporting_date + else None + ) + + portfolio_data = SBTi.utils.get_data(providers, portfolio, reporting_date=reporting_date) + coverage = PortfolioCoverageTVP().get_portfolio_coverage( + portfolio_data, request.aggregation_method + ) + + return CoverageResponse(coverage=coverage or 0.0) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..f6f9255 --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +router = APIRouter() + +API_VERSION = "2.0.0" + + +@router.get("/health", summary="Liveness check") +def health(): + return {"status": "ok"} + + +@router.get("/health/ready", summary="Readiness check") +def health_ready(): + """Returns API version. Use for readiness probes.""" + return {"status": "ok", "version": API_VERSION} diff --git a/app/routers/providers.py b/app/routers/providers.py new file mode 100644 index 0000000..c27d648 --- /dev/null +++ b/app/routers/providers.py @@ -0,0 +1,22 @@ +from typing import List + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.config import AppConfig, get_config + +router = APIRouter() + + +class DataProviderInfo(BaseModel): + name: str + type: str + + +@router.get("/data-providers", response_model=List[DataProviderInfo], summary="List data providers") +def list_data_providers(config: AppConfig = Depends(get_config)): + """Returns the data providers configured on this instance.""" + return [ + DataProviderInfo(name=dp["name"], type=dp["type"]) + for dp in config.data_providers + ] diff --git a/app/routers/temperature.py b/app/routers/temperature.py new file mode 100644 index 0000000..de21e4e --- /dev/null +++ b/app/routers/temperature.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException + +import SBTi.utils +import SBTi.temperature_score +from SBTi.interfaces import PortfolioCompany + +from app.config import AppConfig, get_config +from app.dependencies import resolve_providers, df_to_records +from app.schemas.temperature import TemperatureScoreRequest, TemperatureScoreResponse + +router = APIRouter() + + +@router.post("/score", response_model=TemperatureScoreResponse, response_model_exclude_none=True, + summary="Calculate temperature scores") +def calculate_temperature_score( + request: TemperatureScoreRequest, + config: AppConfig = Depends(get_config), +): + """Calculate temperature alignment scores for a portfolio of companies.""" + try: + portfolio = [PortfolioCompany(**c.model_dump()) for c in request.companies] + providers = resolve_providers(request.data_providers, config) + reporting_date = ( + datetime.fromisoformat(request.reporting_date) + if request.reporting_date + else None + ) + + portfolio_data = SBTi.utils.get_data(providers, portfolio, reporting_date=reporting_date) + scores_df, aggregations = SBTi.utils.calculate( + portfolio_data=portfolio_data, + fallback_score=request.fallback_score, + time_frames=request.time_frames, + scopes=request.scopes, + aggregation_method=request.aggregation_method, + grouping=request.grouping_columns, + scenario=None, + anonymize=request.anonymize, + aggregate=request.aggregate, + ) + + company_cols = ["company_name", "scope", "time_frame", "temperature_score"] + available_cols = [c for c in company_cols if c in scores_df.columns] + + return TemperatureScoreResponse( + aggregated_scores=aggregations, + scores=df_to_records(scores_df), + companies=df_to_records(scores_df[available_cols]), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routers/upload.py b/app/routers/upload.py new file mode 100644 index 0000000..b12c8e1 --- /dev/null +++ b/app/routers/upload.py @@ -0,0 +1,121 @@ +import io +from typing import List + +import pandas as pd +import numpy as np +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile + +import SBTi.utils +from SBTi.portfolio_aggregation import PortfolioAggregationMethod + +from app.config import AppConfig, get_config +from app.dependencies import resolve_providers, df_to_records +from app.schemas.upload import UploadScoreResponse + +router = APIRouter() + + +@router.post("/csv", response_model=UploadScoreResponse, response_model_exclude_none=True, + summary="Upload CSV and score") +async def upload_csv( + file: UploadFile = File(...), + fallback_score: float = Form(3.2), + aggregation_method: str = Form("WATS"), + data_providers: str = Form(""), + config: AppConfig = Depends(get_config), +): + """Upload a portfolio CSV and return temperature scores.""" + if not file.filename or not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="File must be a CSV") + + try: + contents = await file.read() + df = pd.read_csv(io.BytesIO(contents)) + portfolio = SBTi.utils.dataframe_to_portfolio(df) + + provider_names = [n.strip() for n in data_providers.split(",") if n.strip()] + providers = resolve_providers(provider_names, config) + agg_method = PortfolioAggregationMethod(aggregation_method) + + portfolio_data = SBTi.utils.get_data(providers, portfolio) + scores_df, aggregations = SBTi.utils.calculate( + portfolio_data=portfolio_data, + fallback_score=fallback_score, + time_frames=[], + scopes=[], + aggregation_method=agg_method, + grouping=None, + scenario=None, + anonymize=False, + aggregate=True, + ) + + return UploadScoreResponse( + portfolio_count=len(portfolio), + aggregated_scores=aggregations, + scores=df_to_records(scores_df), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/excel", response_model=UploadScoreResponse, response_model_exclude_none=True, + summary="Upload Excel and score") +async def upload_excel( + file: UploadFile = File(...), + skiprows: int = Form(0), + fallback_score: float = Form(3.2), + aggregation_method: str = Form("WATS"), + data_providers: str = Form(""), + config: AppConfig = Depends(get_config), +): + """Upload a portfolio Excel file and return temperature scores.""" + if not file.filename or not file.filename.endswith((".xlsx", ".xls")): + raise HTTPException(status_code=400, detail="File must be an Excel file (.xlsx or .xls)") + + try: + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents), skiprows=skiprows) + df = df.replace(r"^\s*$", np.nan, regex=True).dropna(how="all") + portfolio = SBTi.utils.dataframe_to_portfolio(df) + + provider_names = [n.strip() for n in data_providers.split(",") if n.strip()] + providers = resolve_providers(provider_names, config) + agg_method = PortfolioAggregationMethod(aggregation_method) + + portfolio_data = SBTi.utils.get_data(providers, portfolio) + scores_df, aggregations = SBTi.utils.calculate( + portfolio_data=portfolio_data, + fallback_score=fallback_score, + time_frames=[], + scopes=[], + aggregation_method=agg_method, + grouping=None, + scenario=None, + anonymize=False, + aggregate=True, + ) + + return UploadScoreResponse( + portfolio_count=len(portfolio), + aggregated_scores=aggregations, + scores=df_to_records(scores_df), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/parse", summary="Parse Excel to JSON") +async def parse_portfolio(file: UploadFile = File(...), skiprows: int = Form(0)) -> List[dict]: + """Parse an Excel file and return rows as a list of dicts without scoring.""" + if not file.filename or not file.filename.endswith((".xlsx", ".xls")): + raise HTTPException(status_code=400, detail="File must be an Excel file (.xlsx or .xls)") + + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents), skiprows=skiprows) + df = df.replace(r"^\s*$", np.nan, regex=True).dropna(how="all") + return df_to_records(df) diff --git a/app/routers/whatif.py b/app/routers/whatif.py new file mode 100644 index 0000000..1adbdf3 --- /dev/null +++ b/app/routers/whatif.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException + +import SBTi.utils +import SBTi.temperature_score +from SBTi.interfaces import PortfolioCompany +from SBTi.portfolio_coverage_tvp import PortfolioCoverageTVP + +from app.config import AppConfig, get_config +from app.dependencies import resolve_providers, df_to_records +from app.schemas.whatif import WhatIfRequest, WhatIfResponse + +router = APIRouter() + + +@router.post("/whatif", response_model=WhatIfResponse, response_model_exclude_none=True, + summary="Run what-if scenario") +def calculate_whatif( + request: WhatIfRequest, + config: AppConfig = Depends(get_config), +): + """Recalculate temperature scores under an engagement scenario.""" + try: + portfolio = [PortfolioCompany(**c.model_dump()) for c in request.companies] + providers = resolve_providers(request.data_providers, config) + reporting_date = ( + datetime.fromisoformat(request.reporting_date) + if request.reporting_date + else None + ) + + portfolio_data = SBTi.utils.get_data(providers, portfolio, reporting_date=reporting_date) + scenario = SBTi.temperature_score.Scenario.from_interface(request.scenario) + + scores_df, aggregations = SBTi.utils.calculate( + portfolio_data=portfolio_data, + fallback_score=request.fallback_score, + time_frames=request.time_frames, + scopes=request.scopes, + aggregation_method=request.aggregation_method, + grouping=request.grouping_columns, + scenario=scenario, + anonymize=request.anonymize, + aggregate=True, + ) + + coverage = PortfolioCoverageTVP().get_portfolio_coverage( + portfolio_data, request.aggregation_method + ) + + return WhatIfResponse( + aggregated_scores=aggregations, + scores=df_to_records(scores_df), + coverage=coverage or 0.0, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..14baa6d --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class PortfolioCompanyInput(BaseModel): + company_name: str + company_id: str = Field(description="Unique identifier (e.g. ISIN, LEI, or internal ID).") + company_isin: Optional[str] = None + company_lei: Optional[str] = None + investment_value: float = Field(description="Investment value, used for weighting.") + engagement_target: Optional[bool] = Field(default=False, description="Used in scenario 4 what-if analysis.") + user_fields: Optional[dict] = Field(default=None, description="Custom fields for grouping or display.") + + +class ErrorDetail(BaseModel): + detail: str + type: Optional[str] = None diff --git a/app/schemas/coverage.py b/app/schemas/coverage.py new file mode 100644 index 0000000..5858147 --- /dev/null +++ b/app/schemas/coverage.py @@ -0,0 +1,17 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field +from SBTi.portfolio_aggregation import PortfolioAggregationMethod + +from app.schemas.common import PortfolioCompanyInput + + +class CoverageRequest(BaseModel): + companies: List[PortfolioCompanyInput] + data_providers: List[str] = Field(default=[], description="Provider names. Defaults to first configured.") + aggregation_method: PortfolioAggregationMethod = PortfolioAggregationMethod.WATS + reporting_date: Optional[str] = Field(default=None, description="ISO 8601 date. Defaults to today.") + + +class CoverageResponse(BaseModel): + coverage: float = Field(description="Percentage of portfolio covered by approved SBTi targets.") diff --git a/app/schemas/temperature.py b/app/schemas/temperature.py new file mode 100644 index 0000000..88c0d9a --- /dev/null +++ b/app/schemas/temperature.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field +from SBTi.interfaces import EScope, ETimeFrames, ScoreAggregations +from SBTi.portfolio_aggregation import PortfolioAggregationMethod + +from app.schemas.common import PortfolioCompanyInput + + +class TemperatureScoreRequest(BaseModel): + companies: List[PortfolioCompanyInput] + data_providers: List[str] = Field(default=[], description="Provider names from /v1/data-providers. Defaults to first configured.") + fallback_score: float = Field(default=3.2, ge=0, description="Score for companies without valid targets.") + aggregation_method: PortfolioAggregationMethod = PortfolioAggregationMethod.WATS + scopes: List[EScope] = Field(default=[], description="Empty = all scopes.") + time_frames: List[ETimeFrames] = Field(default=[], description="Empty = all time frames.") + grouping_columns: Optional[List[str]] = Field(default=None, description="Columns to group results by.") + anonymize: bool = False + aggregate: bool = True + reporting_date: Optional[str] = Field(default=None, description="ISO 8601 date. Defaults to today.") + + +class TemperatureScoreResponse(BaseModel): + aggregated_scores: Optional[ScoreAggregations] = None + scores: List[dict] = Field(description="Full per-company scoring data.") + companies: List[dict] = Field(description="Simplified view: name, scope, time frame, score.") diff --git a/app/schemas/upload.py b/app/schemas/upload.py new file mode 100644 index 0000000..e8e1d62 --- /dev/null +++ b/app/schemas/upload.py @@ -0,0 +1,10 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field +from SBTi.interfaces import ScoreAggregations + + +class UploadScoreResponse(BaseModel): + portfolio_count: int = Field(description="Number of companies parsed from the file.") + aggregated_scores: Optional[ScoreAggregations] = None + scores: List[dict] diff --git a/app/schemas/whatif.py b/app/schemas/whatif.py new file mode 100644 index 0000000..285c770 --- /dev/null +++ b/app/schemas/whatif.py @@ -0,0 +1,31 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field +from SBTi.interfaces import EScope, ETimeFrames, ScenarioInterface, ScoreAggregations +from SBTi.portfolio_aggregation import PortfolioAggregationMethod + +from app.schemas.common import PortfolioCompanyInput + + +class WhatIfRequest(BaseModel): + """Scenarios 1-4 model engagement effects on portfolio temperature scores. + + Set engagement_type to "SET_TARGETS" (2.0C) or "SET_SBTI_TARGETS" (1.75C). + """ + + companies: List[PortfolioCompanyInput] + scenario: ScenarioInterface = Field(description="e.g. {\"number\": 1, \"engagement_type\": \"SET_TARGETS\"}") + data_providers: List[str] = Field(default=[], description="Provider names. Defaults to first configured.") + fallback_score: float = Field(default=3.2, ge=0, description="Score for companies without valid targets.") + aggregation_method: PortfolioAggregationMethod = PortfolioAggregationMethod.WATS + scopes: List[EScope] = Field(default=[], description="Empty = all scopes.") + time_frames: List[ETimeFrames] = Field(default=[], description="Empty = all time frames.") + grouping_columns: Optional[List[str]] = None + anonymize: bool = False + reporting_date: Optional[str] = Field(default=None, description="ISO 8601 date. Defaults to today.") + + +class WhatIfResponse(BaseModel): + aggregated_scores: Optional[ScoreAggregations] = None + scores: List[dict] + coverage: float diff --git a/app/static/swagger.json b/app/static/swagger.json deleted file mode 100644 index cbfb1e0..0000000 --- a/app/static/swagger.json +++ /dev/null @@ -1,297 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "description": "Portfolio Coverage & Temperature Score Application", - "version": "1.0.0", - "title": "SBTi API", - "license": { - "name": "MIT", - "url": "https://opensource.org/licenses/MIT" - } - }, - "servers": [ - { - "url": "/" - } - ], - "tags": [ - { - "name": "SBTi Request", - "description": "Temperature Score API call for portfolio environmental impact evaluation." - } - ], - "paths": { - "/temperature_score/": { - "parameters": [], - "post": { - "tags": [ - "SBTi Request" - ], - "summary": "Retrieve the temperature score for your portfolio of companies", - "requestBody": { - "description": "Temperature Score Evaluation", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/temperature_score" - } - } - } - }, - "produces": [ - "application/json" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/temperature_score_response" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "temperature_score": { - "type": "object", - "properties": { - "companies": { - "required": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/company" - } - }, - "anonymize_data_dump": { - "required": false, - "description": "Whether or not the resulting data set should be anonymized or not.", - "type": "boolean" - }, - "aggregation_method": { - "required": true, - "description": "The aggregation method that should be used.", - "type": "string", - "format": "string", - "items": [ - "WATS", - "TETS", - "MOTS", - "EOTS", - "ECOTS", - "AOTS" - ] - }, - "data_providers": { - "required": false, - "description": "The names of the data providers that should be used. To get a list of data providers, use the /data_providers endpoint.", - "type": "array", - "items": { - "type": "string", - "format": "string" - } - }, - "default_score": { - "required": false, - "description": "The score that should be used when no target is available.", - "type": "number", - "format": "number" - }, - "scenario": { - "required": false, - "description": "The scenario that should be used. This will change (some of the) targets, to simulate a what-if scenario.", - "$ref": "#/components/schemas/scenario" - }, - "filter_time_frame": { - "required": false, - "type": "array", - "description": "The time frames that should be included in the results", - "items": { - "type": "string", - "format": "string", - "enum": [ - "short", - "mid", - "long" - ] - } - }, - "filter_scope_category": { - "required": false, - "type": "array", - "description": "The scopes that should be included in the results", - "items": { - "type": "string", - "format": "string", - "enum": [ - "s1s2", - "s3", - "s1s2s3" - ] - } - }, - "include_columns": { - "required": false, - "type": "array", - "description": "The names of the columns that should be included in the output", - "items": { - "type": "string", - "format": "string", - "enum": [ - "company_id", - "industry", - "s1s2_emissions", - "s3_emissions", - "market_cap", - "investment_value", - "company_enterprise_value", - "company_ev_plus_cash", - "company_total_assets", - "target_reference_number", - "scope", - "base_year", - "start_year", - "target_year", - "reduction_from_base_year", - "emissions_in_scope", - "achieved_reduction" - ] - } - }, - "grouping_columns": { - "required": false, - "type": "array", - "description": "The names of the columns by which the aggregated temperature scores should be grouped. In addition to the column names defined in the enum, you can also specify a custom column name that was included in the uploaded portfolio.", - "items": { - "type": "string", - "format": "string", - "enum": [ - "country", - "region", - "Industry_lvl1", - "Industry_lvl2", - "Industry_lvl3", - "Industry_lvl4", - "sector" - ] - } - } - } - }, - "portfolio": { - "type": "object", - "properties": { - "companies": { - "type": "array", - "items": { - "$ref": "#/components/schemas/company" - } - }, - "aggregation_method": { - "type": "string", - "format": "string", - "description": "The aggregation method that should be used.", - "items": [ - "WATS", - "TETS", - "MOTS", - "EOTS", - "ECOTS", - "AOTS" - ] - }, - "data_providers": { - "required": false, - "description": "The names of the data providers that should be used. To get a list of data providers, use the /data_providers endpoint.", - "type": "array", - "items": { - "type": "string", - "format": "string" - } - } - } - }, - "scenario": { - "type": "object", - "properties": { - "number": { - "description": "The ID of the scenario. \n * `1` - In scenario 1 we engage companies to set targets. This means that the default score (i.e. the score when a company didn't set a target) will be set to 2 degrees celcius. \n * `2` - In scenario 2 we engage companies to have their targets validated by the SBTi. This means that the maximum score for all targets will be capped at 1.75 degrees celcius. \n * `3` In scenario 3a we engage the top 10 contributors. How they are engaged depends on the `engagement_type` parameter. \n * `4` In scenario 4 we engage a predefined list of company (those that have the value 'true' in the 'engagement_target' column in the portfolio). How they are engaged depends on the `engagement_type` parameter.", - "type": "number", - "format": "number", - "enum": [ - 1, - 2, - 3, - 4 - ] - }, - "engagement_type": { - "description": "The engagement type, i.e. how the companies should be engaged. This parameter is only used for scenarios 3 and 4. \n * `SET_TARGETS` The companies are engaged to set targets. This means that their temperature scores are capped at 2 degrees celsius. \n * `SET_SBTI_TARGETS` The companies are engaged to set SBTi approved targets. This means that their temperature scores are capped at 1.75 degrees celsius.", - "type": "string", - "format": "string", - "enum": [ - "SET_TARGETS", - "SET_SBTI_TARGETS" - ] - } - } - }, - "company": { - "type": "object", - "properties": { - "company_name": { - "type": "string", - "format": "string" - }, - "company_id": { - "type": "string", - "format": "string" - }, - "investment_value": { - "type": "number", - "format": "number" - } - } - }, - "temperature_score_response": { - "type": "object", - "properties": { - "aggregated_scores": { - "type": "object", - "format": "object", - "description": "The aggregated scores, split by time-frame, scope and the grouping column." - }, - "scores": { - "type": "array", - "items": { - "type": "object" - }, - "description": "A dump of the raw target/company data." - }, - "coverage": { - "type": "number", - "format": "number", - "description": "The coverage of the SBTi approved targets (as a percentage)." - }, - "companies": { - "type": "array", - "items": { - "type": "object" - }, - "description": "The raw data used to calculate the aggregated scores. Each company is included 9 times, once for each time-frame/scope combination." - }, - "feature_distribution": { - "type": "object", - "description": "For each group that can be made using the grouping columns, the number of companies in that group." - } - } - } - } - } -} \ No newline at end of file diff --git a/app/uploads/.gitignore b/app/uploads/.gitignore deleted file mode 100644 index 894b5db..0000000 --- a/app/uploads/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Ignore everything in this directory, except for the gitignore file -files -!.gitignore \ No newline at end of file diff --git a/app/wsgi.py b/app/wsgi.py deleted file mode 100644 index 8e66839..0000000 --- a/app/wsgi.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Run server. -""" -from app.app_server import app - -if __name__ == "__main__": - app.run() diff --git a/config/api-nginx.conf b/config/api-nginx.conf deleted file mode 100644 index 2b4afa2..0000000 --- a/config/api-nginx.conf +++ /dev/null @@ -1,33 +0,0 @@ -upstream app_server { - # fail_timeout=0 means we always retry an upstream even if it failed - # to return a good HTTP response - - # for UNIX domain socket setups - server unix:/tmp/uvicorn.sock fail_timeout=0; - - # for a TCP configuration - # server 192.168.0.7:8000 fail_timeout=0; -} - -server { - server_name _; - listen 8080; - listen [::]:8080; - - access_log /vol/log/nginx/access.log main; - error_log /vol/log/nginx/error.log warn; - - location / { - try_files $uri @sbtiapi; - } - - location @sbtiapi { - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $http_host; - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://app_server; - } -} diff --git a/config/nginx.conf b/config/nginx.conf deleted file mode 100644 index c8d85db..0000000 --- a/config/nginx.conf +++ /dev/null @@ -1,73 +0,0 @@ -# based on default config of nginx 1.12.1 -# Define the user that will own and run the Nginx server -# user dock_sbtiapi; -# Define the number of worker processes; recommended value is the number of -# cores that are being used by your server -# auto will default to number of vcpus/cores -worker_processes auto; - -# altering default pid file location -pid /vol/nginx.pid; - -# turn off daemon mode to be watched by supervisord -daemon off; - -# Enables the use of JIT for regular expressions to speed-up their processing. -pcre_jit on; - -# Define the location on the file system of the error log, plus the minimum -# severity to log messages for -error_log /vol/log/nginx/error.log warn; - -# events block defines the parameters that affect connection processing. -events { - # Define the maximum number of simultaneous connections that can be opened by a worker process - worker_connections 1024; -} - -# http block defines the parameters for how NGINX should handle HTTP web traffic -http { - proxy_temp_path /vol/tmp/nginx/proxy; - client_body_temp_path /vol/tmp/nginx/client; - fastcgi_temp_path /vol/tmp/nginx/fastcgi; - uwsgi_temp_path /vol/tmp/nginx/uwsgi; - scgi_temp_path /vol/tmp/nginx/scgi; - - # Include the file defining the list of file types that are supported by NGINX - include /etc/nginx/mime.types; - # Define the default file type that is returned to the user - default_type text/html; - - # Don't tell nginx version to clients. - server_tokens off; - - # Specifies the maximum accepted body size of a client request, as - # indicated by the request header Content-Length. If the stated content - # length is greater than this size, then the client receives the HTTP - # error code 413. Set to 0 to disable. - client_max_body_size 0; - - # Define the format of log messages. - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - # Define the location of the log of access attempts to NGINX - access_log /vol/log/nginx/access.log main; - error_log /vol/log/nginx/error.log warn; - - # Define the parameters to optimize the delivery of static content - sendfile on; - tcp_nopush on; - tcp_nodelay on; - - # Define the timeout value for keep-alive connections with the client - keepalive_timeout 65; - - # Define the usage of the gzip compression algorithm to reduce the amount of data to transmit - #gzip on; - - # Include additional parameters for virtual host(s)/server(s) - include /etc/nginx/conf.d/*.conf; - include /etc/nginx/sites-enabled/*; -} diff --git a/config/supervisord.conf b/config/supervisord.conf deleted file mode 100644 index f3edaf8..0000000 --- a/config/supervisord.conf +++ /dev/null @@ -1,17 +0,0 @@ -[supervisord] -nodaemon=true -user=dock_sbtiapi -logfile=/vol/log/supervisord.log -pidfile=/vol/tmp/supervisord.pid - -[program:nginx] -command=/usr/sbin/nginx -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:uvicorn] -command=uvicorn app.main:app --uds /tmp/uvicorn.sock -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 diff --git a/config/uwsgi.ini b/config/uwsgi.ini deleted file mode 100644 index 6b3c8c1..0000000 --- a/config/uwsgi.ini +++ /dev/null @@ -1,13 +0,0 @@ -[uwsgi] -module = app.wsgi -callable = app - -uid = dock_sbtiapi -gid = dock_sbtiapi - -socket = /vol/tmp/uwsgi/uwsgi.sock -chown-socket = dock_sbtiapi:dock_sbtiapi -chmod-socket = 664 - -cheaper = 1 -processes = %(%k + 1) diff --git a/docker-compose-ui-dev.yml b/docker-compose-ui-dev.yml deleted file mode 100644 index 98a6329..0000000 --- a/docker-compose-ui-dev.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: '3' -services: - ui: - image: sbti/ui:latest - ports: - - "5000:8080" - - "5001:8081" - api: - depends_on: - - ui - build: . - restart: always diff --git a/docker-compose-ui.yml b/docker-compose-ui.yml deleted file mode 100644 index 00a7dfc..0000000 --- a/docker-compose-ui.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: '3' -services: - ui: - image: sbti/ui:latest - ports: - - "5000:8080" - - "5001:8081" - api: - depends_on: - - ui - image: sbti/api:latest - restart: always diff --git a/docker-compose.yml b/docker-compose.yml index 8135219..3ff594c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ -version: '3' services: - app: + api: build: . ports: - - "5000:8080" - + - "8000:8000" + volumes: + - ./app/data:/project/app/data + environment: + - SBTI_CONFIG_PATH=/project/app/config.json + restart: unless-stopped diff --git a/docker-compose_aws_example.yml b/docker-compose_aws_example.yml deleted file mode 100644 index f281116..0000000 --- a/docker-compose_aws_example.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: '3' -services: - app: - image: docker.io/sbti/api:latest - build: . - ports: - - "80:8080" - diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 345eeb3..0000000 --- a/poetry.lock +++ /dev/null @@ -1,553 +0,0 @@ -[[package]] -name = "anyio" -version = "3.6.2" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" -optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] - -[[package]] -name = "asgiref" -version = "3.5.2" -description = "ASGI specs, helper code, and adapters" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] - -[[package]] -name = "astroid" -version = "2.12.13" -description = "An abstract syntax tree for Python with inference support." -category = "dev" -optional = false -python-versions = ">=3.7.2" - -[package.dependencies] -lazy-object-proxy = ">=1.4.0" -typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} -wrapt = [ - {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, - {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, -] - -[[package]] -name = "black" -version = "21.10b0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -click = ">=7.1.2" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0,<1" -platformdirs = ">=2" -regex = ">=2020.1.8" -tomli = ">=0.2.6,<2.0.0" -typing-extensions = [ - {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, - {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, -] - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -python2 = ["typed-ast (>=1.4.3)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2022.9.24" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "charset-normalizer" -version = "2.1.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" -optional = false -python-versions = ">=3.6.0" - -[package.extras] -unicode_backport = ["unicodedata2"] - -[[package]] -name = "click" -version = "8.1.3" -description = "Composable command line interface toolkit" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" - -[[package]] -name = "dill" -version = "0.3.6" -description = "serialize all of python" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -graph = ["objgraph (>=1.7.2)"] - -[[package]] -name = "et-xmlfile" -version = "1.1.0" -description = "An implementation of lxml.xmlfile for the standard library" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "fastapi" -version = "0.70.0" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" -starlette = "0.16.0" - -[package.extras] -all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] -dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] -doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] -test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "idna" -version = "3.4" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "isort" -version = "5.10.1" -description = "A Python utility / library to sort Python imports." -category = "dev" -optional = false -python-versions = ">=3.6.1,<4.0" - -[package.extras] -pipfile_deprecated_finder = ["pipreqs", "requirementslib"] -requirements_deprecated_finder = ["pipreqs", "pip-api"] -colors = ["colorama (>=0.4.3,<0.5.0)"] -plugins = ["setuptools"] - -[[package]] -name = "lazy-object-proxy" -version = "1.8.0" -description = "A fast and thorough lazy object proxy." -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "numpy" -version = "1.23.5" -description = "NumPy is the fundamental package for array computing with Python." -category = "main" -optional = false -python-versions = ">=3.8" - -[[package]] -name = "openpyxl" -version = "3.0.9" -description = "A Python library to read/write Excel 2010 xlsx/xlsm files" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -et-xmlfile = "*" - -[[package]] -name = "pandas" -version = "1.3.4" -description = "Powerful data structures for data analysis, time series, and statistics" -category = "main" -optional = false -python-versions = ">=3.7.1" - -[package.dependencies] -numpy = [ - {version = ">=1.17.3", markers = "platform_machine != \"aarch64\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, - {version = ">=1.19.2", markers = "platform_machine == \"aarch64\" and python_version < \"3.10\""}, - {version = ">=1.20.0", markers = "platform_machine == \"arm64\" and python_version < \"3.10\""}, - {version = ">=1.21.0", markers = "python_version >= \"3.10\""}, -] -python-dateutil = ">=2.7.3" -pytz = ">=2017.3" - -[package.extras] -test = ["hypothesis (>=3.58)", "pytest (>=6.0)", "pytest-xdist"] - -[[package]] -name = "pathspec" -version = "0.10.2" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "platformdirs" -version = "2.5.4" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.4)", "sphinx (>=5.3)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"] - -[[package]] -name = "pydantic" -version = "1.8.2" -description = "Data validation and settings management using python 3.6 type hinting" -category = "main" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -typing-extensions = ">=3.7.4.3" - -[package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] - -[[package]] -name = "pylint" -version = "2.15.6" -description = "python code static checker" -category = "dev" -optional = false -python-versions = ">=3.7.2" - -[package.dependencies] -astroid = ">=2.12.12,<=2.14.0-dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = ">=0.2" -isort = ">=4.2.5,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-multipart" -version = "0.0.5" -description = "A streaming multipart parser for Python" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -six = ">=1.4.0" - -[[package]] -name = "pytz" -version = "2022.6" -description = "World timezone definitions, modern and historical" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "regex" -version = "2022.10.31" -description = "Alternative regular expression module, to replace re." -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "requests" -version = "2.28.1" -description = "Python HTTP for Humans." -category = "main" -optional = false -python-versions = ">=3.7, <4" - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "sbti-finance-tool" -version = "1.0.4" -description = "This package helps companies and financial institutions to assess the temperature alignment of current targets, commitments, and investment and lending portfolios, and to use this information to develop targets for official validation by the SBTi.'" -category = "main" -optional = false -python-versions = ">=3.7.1,<4" - -[package.dependencies] -openpyxl = "3.0.9" -pandas = "1.3.4" -pydantic = "1.8.2" -requests = "2.28.1" -six = "1.15.0" -xlrd = "1.2.0" - -[[package]] -name = "six" -version = "1.15.0" -description = "Python 2 and 3 compatibility utilities" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "starlette" -version = "0.16.0" -description = "The little ASGI library that shines." -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -anyio = ">=3.0.0,<4" - -[package.extras] -full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"] - -[[package]] -name = "tomli" -version = "1.2.3" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "tomlkit" -version = "0.11.6" -description = "Style preserving TOML library" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "typing-extensions" -version = "4.4.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "urllib3" -version = "1.26.13" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" - -[package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - -[[package]] -name = "uvicorn" -version = "0.15.0" -description = "The lightning-fast ASGI server." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -asgiref = ">=3.4.0" -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] - -[[package]] -name = "wrapt" -version = "1.14.1" -description = "Module for decorators, wrappers and monkey patching." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[[package]] -name = "xlrd" -version = "1.2.0" -description = "Library for developers to extract data from Microsoft Excel (tm) spreadsheet files" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[metadata] -lock-version = "1.1" -python-versions = "^3.8" -content-hash = "5cbc783c1f9fb90d40ddc3413bac54a3f781536da9c1615ea274d0fc9e7c4175" - -[metadata.files] -anyio = [] -asgiref = [] -astroid = [] -black = [ - {file = "black-21.10b0-py3-none-any.whl", hash = "sha256:6eb7448da9143ee65b856a5f3676b7dda98ad9abe0f87fce8c59291f15e82a5b"}, - {file = "black-21.10b0.tar.gz", hash = "sha256:a9952229092e325fe5f3dae56d81f639b23f7131eb840781947e4b2886030f33"}, -] -certifi = [] -charset-normalizer = [] -click = [] -colorama = [] -dill = [] -et-xmlfile = [] -fastapi = [] -h11 = [] -idna = [] -isort = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, -] -lazy-object-proxy = [] -mccabe = [] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -numpy = [] -openpyxl = [] -pandas = [] -pathspec = [] -platformdirs = [] -pydantic = [ - {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, - {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, - {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, - {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, - {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, - {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, - {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, - {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, - {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, - {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, -] -pylint = [] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] -python-multipart = [] -pytz = [] -regex = [] -requests = [] -sbti-finance-tool = [] -six = [] -sniffio = [] -starlette = [] -tomli = [ - {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, - {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, -] -tomlkit = [] -typing-extensions = [] -urllib3 = [] -uvicorn = [] -wrapt = [] -xlrd = [] diff --git a/pyproject.toml b/pyproject.toml index 89845f6..9da05ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sbti-finance-tool-api" -version = "1.0.0" -description = "This package helps companies and financial institutions to assess the temperature alignment of current targets, commitments, and investment and lending portfolios, and to use this information to develop targets for official validation by the SBTi.'" +version = "2.0.0" +description = "REST API for the SBTi Finance Temperature Alignment tool — temperature scoring, portfolio coverage, and what-if scenario analysis." authors = ["sbti "] license = "MIT" readme = "README.md" @@ -15,36 +15,36 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development", "Topic :: Scientific/Engineering", - ] -keywords = ['Climate', 'SBTi', 'Finance'] -packages = [{ include = "SBTi" }] +keywords = ['Climate', 'SBTi', 'Finance', 'API'] [tool.poetry.urls] "Bug Tracker" = "https://github.com/ScienceBasedTargets/SBTi-finance-tool-api/issues" "Source Code" = "https://github.com/ScienceBasedTargets/SBTi-finance-tool-api" - [tool.poetry.dependencies] -python = "^3.8" -pydantic = "1.8.2" -six = "1.15.0" -pandas = "1.3.4" -xlrd = "1.2.0" -requests = "2.28.1" -openpyxl = "3.0.9" -fastapi = "0.70.0" -uvicorn = "0.15.0" -python-multipart = "0.0.5" -sbti-finance-tool = "^1.0.5" +python = ">=3.11,<4" +fastapi = ">=0.115,<1" +uvicorn = {extras = ["standard"], version = ">=0.32,<1"} +python-multipart = ">=0.0.12" +sbti-finance-tool = ">=1.2.5" +pandas = ">=1.5.3,<4.0.0" +openpyxl = ">=3.1" +numpy = ">=1.21,<3" -[tool.poetry.dev-dependencies] -black = "21.10b0" -pylint = "^2.10.2" +[tool.poetry.group.dev.dependencies] +pytest = ">=8" +httpx = ">=0.27" +ruff = ">=0.8" [tool.pyright] venv = ".venv" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c880747..0000000 --- a/requirements.txt +++ /dev/null @@ -1,26 +0,0 @@ -anyio==3.6.2; python_version >= "3.6" and python_full_version >= "3.6.2" -asgiref==3.5.2; python_version >= "3.7" -certifi==2022.9.24; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.7.1" -charset-normalizer==2.1.1; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.7.1" -click==8.1.3; python_version >= "3.7" -colorama==0.4.6; python_version >= "3.7" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.7" and python_full_version >= "3.7.0" -et-xmlfile==1.1.0; python_full_version >= "3.7.1" and python_version < "4" and python_version >= "3.6" -fastapi==0.70.0; python_full_version >= "3.6.1" -h11==0.14.0; python_version >= "3.7" -idna==3.4; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.7.1" -numpy==1.23.5 -openpyxl==3.0.9; python_version >= "3.6" -pandas==1.3.4; python_full_version >= "3.7.1" -pydantic==1.8.2; python_full_version >= "3.6.1" -python-dateutil==2.8.2; python_full_version >= "3.7.1" and python_version < "4" -python-multipart==0.0.5 -pytz==2022.6; python_full_version >= "3.7.1" and python_version < "4" -requests==2.28.1; python_version >= "3.7" and python_version < "4" -sbti-finance-tool==1.0.4; python_full_version >= "3.7.1" and python_version < "4" -six==1.15.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.3.0") -sniffio==1.3.0; python_version >= "3.7" and python_full_version >= "3.6.2" -starlette==0.16.0; python_version >= "3.6" and python_full_version >= "3.6.1" -typing-extensions==4.4.0; python_version >= "3.7" and python_full_version >= "3.7.1" and python_version < "4" -urllib3==1.26.13; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.7.1" -uvicorn==0.15.0 -xlrd==1.2.0; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b574b42 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def sample_portfolio(): + return [ + { + "company_name": "Company A", + "company_id": "1123", + "investment_value": 1000000, + "engagement_target": False, + }, + { + "company_name": "Company B", + "company_id": "1124", + "investment_value": 500000, + "engagement_target": False, + }, + ] diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..35b54f6 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,18 @@ +def test_coverage_requires_companies(client): + response = client.post("/v1/coverage", json={}) + assert response.status_code == 422 + + +def test_coverage_with_portfolio(client, sample_portfolio): + response = client.post( + "/v1/coverage", + json={ + "companies": sample_portfolio, + "data_providers": ["CSV"], + }, + ) + assert response.status_code in (200, 400, 500) + if response.status_code == 200: + data = response.json() + assert "coverage" in data + assert isinstance(data["coverage"], float) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..0cb43d3 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,12 @@ +def test_health(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_health_ready(client): + response = client.get("/health/ready") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "version" in data diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..f3735f0 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,8 @@ +def test_list_data_providers(client): + response = client.get("/v1/data-providers") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + assert "name" in data[0] + assert "type" in data[0] diff --git a/tests/test_temperature.py b/tests/test_temperature.py new file mode 100644 index 0000000..892869c --- /dev/null +++ b/tests/test_temperature.py @@ -0,0 +1,19 @@ +def test_temperature_score_requires_companies(client): + response = client.post("/v1/temperature/score", json={}) + assert response.status_code == 422 + + +def test_temperature_score_with_portfolio(client, sample_portfolio): + response = client.post( + "/v1/temperature/score", + json={ + "companies": sample_portfolio, + "data_providers": ["CSV"], + }, + ) + # May return 200 or 400/500 depending on data availability + assert response.status_code in (200, 400, 500) + if response.status_code == 200: + data = response.json() + assert "scores" in data + assert "companies" in data diff --git a/tests/test_upload.py b/tests/test_upload.py new file mode 100644 index 0000000..1646cf2 --- /dev/null +++ b/tests/test_upload.py @@ -0,0 +1,28 @@ +import io + + +def test_upload_csv_rejects_non_csv(client): + file = io.BytesIO(b"test") + response = client.post( + "/v1/upload/csv", + files={"file": ("test.txt", file, "text/plain")}, + ) + assert response.status_code == 400 + + +def test_upload_excel_rejects_non_excel(client): + file = io.BytesIO(b"test") + response = client.post( + "/v1/upload/excel", + files={"file": ("test.csv", file, "text/csv")}, + ) + assert response.status_code == 400 + + +def test_parse_rejects_non_excel(client): + file = io.BytesIO(b"test") + response = client.post( + "/v1/upload/parse", + files={"file": ("test.csv", file, "text/csv")}, + ) + assert response.status_code == 400 diff --git a/tests/test_whatif.py b/tests/test_whatif.py new file mode 100644 index 0000000..bdc5a02 --- /dev/null +++ b/tests/test_whatif.py @@ -0,0 +1,22 @@ +def test_whatif_requires_scenario(client, sample_portfolio): + response = client.post( + "/v1/temperature/whatif", + json={"companies": sample_portfolio}, + ) + assert response.status_code == 422 + + +def test_whatif_with_scenario(client, sample_portfolio): + response = client.post( + "/v1/temperature/whatif", + json={ + "companies": sample_portfolio, + "scenario": {"number": 1}, + "data_providers": ["CSV"], + }, + ) + assert response.status_code in (200, 400, 500) + if response.status_code == 200: + data = response.json() + assert "scores" in data + assert "coverage" in data