diff --git a/.aider.conf.yml b/.aider.conf.yml new file mode 100644 index 0000000..a34ab62 --- /dev/null +++ b/.aider.conf.yml @@ -0,0 +1,7 @@ +lint-cmd: + - "python: scripts/lint.sh" +auto-lint: true +test-cmd: poetry run pytest +auto-test: true +read: + - README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..642db7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: Python CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + + - name: Ruff lint check + run: | + poetry run ruff check . + + - name: Ruff format check + run: | + poetry run ruff format --check . + + - name: Type check + run: | + poetry run mypy --strict . + + - name: Run tests + run: | + poetry run pytest diff --git a/.gitignore b/.gitignore index 0a19790..17abd6d 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ cython_debug/ # PyPI configuration file .pypirc +.aider* diff --git a/README.md b/README.md index 34b2329..2e5413d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,148 @@ -# cvec-python -CVector SDK for Python +# CVec Client Library + +The "cvec" package is the Python SDK for CVector Energy. + +# Getting Started + +## Installation + +Assuming that you have a supported version of Python installed, you can first create a venv with: + +``` +python -m venv .venv +``` + +Then, activate the venv: + +``` +. .venv/bin/activate +``` + +Then, you can install cvec from PyPI with: + +``` +pip install cvec +``` + +## Using cvec + +Import the cvec package. We will also use the datetime module. + +``` +import cvec +from datetime import datetime +``` + +Construct the CVec client. The host, tenant, and api_key can be given through parameters to the constructor or from the environment variables CVEC_HOST, CVEC_TENANT, and CVEC_API_KEY: + +``` +cvec = cvec.CVec() +``` + +### Spans + +A span is a period of interest, such as an experiment, a baseline recording session, or an alarm. The initial state of a Span is implicitly defined by a period where a given metric has a constant value. + +The newest span for a metric does not have an end time, since it has not ended yet (or has not ended by the finish of the queried period). + +To get the spans on `my_tag_name` since 2025-05-14 10am, run: + +``` +for span in cvec.get_spans("mygroup/myedge/mode", start_at=datetime(2025, 5, 14, 10, 0, 0)): + print("%s\t%s" % (span.value, span.raw_start_at)) +``` + +The output will be like: + +``` +offline 2025-05-19 16:28:02.130000+00:00 +starting 2025-05-19 16:28:01.107000+00:00 +running 2025-05-19 15:29:28.795000+00:00 +stopping 2025-05-19 15:29:27.788000+00:00 +offline 2025-05-19 14:14:43.752000+00:00 +``` + +### Metrics + +A metric is a named set of time-series data points pertaining to a particular resource (for example, the value reported by a sensor). Metrics can have numeric or string values. Boolean values are mapped to 0 and 1. The get_metrics function returns a list of metric metadata. + +To get all of the metrics that changed value at 10am on 2025-05-14, run: + +``` +for item in cvec.get_metrics(start_at=datetime(2025, 5, 14, 10, 0, 0), end_at=datetime(2025, 5, 14, 11, 0, 0)): + print(item.name) +``` + +Example output: + +``` +mygroup/myedge/compressor01/status +mygroup/myedge/compressor01/interlocks/emergency_stop +mygroup/myedge/compressor01/stage1/pressure_out/psig +mygroup/myedge/compressor01/stage1/temp_out/c +mygroup/myedge/compressor01/stage2/pressure_out/psig +mygroup/myedge/compressor01/stage2/temp_out/c +mygroup/myedge/compressor01/motor/current/a +mygroup/myedge/compressor01/motor/power_kw +``` + +### Metric Data + +The main content for a metric is a set of points where the metric value changed. These are returned as a Pandas Dataframe with columns for name, time, value_double, value_string. + +To get all of the value changes for all metrics at 10am on 2025-05-14, run: + +``` +cvec.get_metric_data(start_at=datetime(2025, 5, 14, 10, 0, 0), end_at=datetime(2025, 5, 14, 11, 0, 0)) +``` + +Example output: + +``` + name time value_double value_string +0 mygroup/myedge/mode 2025-05-14 10:10:41.949000+00:00 24.900000 starting +1 mygroup/myedge/compressor01/interlocks/emergency_stop 2025-05-14 10:27:24.899000+00:00 0.0000000 None +2 mygroup/myedge/compressor01/stage1/pressure_out/psig 2025-05-14 10:43:38.282000+00:00 123.50000 None +3 mygroup/myedge/compressor01/stage1/temp_out/c 2025-05-14 10:10:41.948000+00:00 24.900000 None +4 mygroup/myedge/compressor01/motor/current/a 2025-05-14 10:27:24.897000+00:00 12.000000 None +... ... ... ... ... +46253 mygroup/myedge/compressor01/stage1/temp_out/c 2025-05-14 10:59:55.725000+00:00 25.300000 None +46254 mygroup/myedge/compressor01/stage2/pressure_out/psig 2025-05-14 10:59:56.736000+00:00 250.00000 None +46255 mygroup/myedge/compressor01/stage2/temp_out/c 2025-05-14 10:59:57.746000+00:00 12.700000 None +46256 mygroup/myedge/compressor01/motor/current/a 2025-05-14 10:59:58.752000+00:00 11.300000 None +46257 mygroup/myedge/compressor01/motor/power_kw 2025-05-14 10:59:59.760000+00:00 523.40000 None + +[46257 rows x 4 columns] +``` + +# CVec Class + +The SDK provides an API client class named `CVec` with the following functions. + +## `__init__(?host, ?tenant, ?api_key, ?default_start_at, ?default_end_at)` + +Setup the SDK with the given host and API Key. The host and API key are loaded from environment variables CVEC_HOST, CVEC_TENANT, CVEC_API_KEY, if they are not given as arguments to the constructor. The `default_start_at` and `default_end_at` can provide a default query time interval for API methods. + +## `get_spans(name, ?start_at, ?end_at, ?limit)` + +Return time spans for a metric. Spans are generated from value changes that occur after `start_at` (if specified) and before `end_at` (if specified). +If `start_at` is `None` (e.g., not provided as an argument and no class default `default_start_at` is set), the query for value changes is unbounded at the start. Similarly, if `end_at` is `None`, the query is unbounded at the end. + +Each `Span` object in the returned list represents a period where the metric's value is constant and has the following attributes: +- `value`: The metric's value during the span. +- `name`: The name of the metric. +- `raw_start_at`: The timestamp of the value change that initiated this span's value. This will be greater than or equal to the query's `start_at` if one was specified. +- `raw_end_at`: The timestamp marking the end of this span's constant value. For the newest span, the value is `None`. For other spans, it's the raw_start_at of the immediately newer data point, which is next span in the list. +- `id`: Currently `None`. In a future version of the SDK, this will be the span's unique identifier. +- `metadata`: Currently `None`. In a future version, this can be used to store annotations or other metadata related to the span. + +Returns a list of `Span` objects, sorted in descending chronological order (newest span first). +If no relevant value changes are found, an empty list is returned. + +## `get_metric_data(?names, ?start_at, ?end_at)` + +Return all data-points within a given [`start_at`, `end_at`) interval, optionally selecting a given list of metric names. The return value is a Pandas DataFrame with four columns: name, time, value_double, value_string. One row is returned for each metric value transition. + +## `get_metrics(?start_at, ?end_at)` + +Return a list of metrics that had at least one transition in the given [`start_at`, `end_at`) interval. All metrics are returned if no `start_at` and `end_at` are given. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2cd0855 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,506 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "mypy" +version = "1.15.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "numpy" +version = "2.2.5" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "numpy-2.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f4a922da1729f4c40932b2af4fe84909c7a6e167e6e99f71838ce3a29f3fe26"}, + {file = "numpy-2.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6f91524d31b34f4a5fee24f5bc16dcd1491b668798b6d85585d836c1e633a6a"}, + {file = "numpy-2.2.5-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19f4718c9012e3baea91a7dba661dcab2451cda2550678dc30d53acb91a7290f"}, + {file = "numpy-2.2.5-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:eb7fd5b184e5d277afa9ec0ad5e4eb562ecff541e7f60e69ee69c8d59e9aeaba"}, + {file = "numpy-2.2.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6413d48a9be53e183eb06495d8e3b006ef8f87c324af68241bbe7a39e8ff54c3"}, + {file = "numpy-2.2.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7451f92eddf8503c9b8aa4fe6aa7e87fd51a29c2cfc5f7dbd72efde6c65acf57"}, + {file = "numpy-2.2.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0bcb1d057b7571334139129b7f941588f69ce7c4ed15a9d6162b2ea54ded700c"}, + {file = "numpy-2.2.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36ab5b23915887543441efd0417e6a3baa08634308894316f446027611b53bf1"}, + {file = "numpy-2.2.5-cp310-cp310-win32.whl", hash = "sha256:422cc684f17bc963da5f59a31530b3936f57c95a29743056ef7a7903a5dbdf88"}, + {file = "numpy-2.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:e4f0b035d9d0ed519c813ee23e0a733db81ec37d2e9503afbb6e54ccfdee0fa7"}, + {file = "numpy-2.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c42365005c7a6c42436a54d28c43fe0e01ca11eb2ac3cefe796c25a5f98e5e9b"}, + {file = "numpy-2.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:498815b96f67dc347e03b719ef49c772589fb74b8ee9ea2c37feae915ad6ebda"}, + {file = "numpy-2.2.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6411f744f7f20081b1b4e7112e0f4c9c5b08f94b9f086e6f0adf3645f85d3a4d"}, + {file = "numpy-2.2.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9de6832228f617c9ef45d948ec1cd8949c482238d68b2477e6f642c33a7b0a54"}, + {file = "numpy-2.2.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:369e0d4647c17c9363244f3468f2227d557a74b6781cb62ce57cf3ef5cc7c610"}, + {file = "numpy-2.2.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:262d23f383170f99cd9191a7c85b9a50970fe9069b2f8ab5d786eca8a675d60b"}, + {file = "numpy-2.2.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa70fdbdc3b169d69e8c59e65c07a1c9351ceb438e627f0fdcd471015cd956be"}, + {file = "numpy-2.2.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e32e985f03c06206582a7323ef926b4e78bdaa6915095ef08070471865b906"}, + {file = "numpy-2.2.5-cp311-cp311-win32.whl", hash = "sha256:f5045039100ed58fa817a6227a356240ea1b9a1bc141018864c306c1a16d4175"}, + {file = "numpy-2.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:b13f04968b46ad705f7c8a80122a42ae8f620536ea38cf4bdd374302926424dd"}, + {file = "numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051"}, + {file = "numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc"}, + {file = "numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e"}, + {file = "numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa"}, + {file = "numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571"}, + {file = "numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073"}, + {file = "numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8"}, + {file = "numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae"}, + {file = "numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb"}, + {file = "numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282"}, + {file = "numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4"}, + {file = "numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f"}, + {file = "numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9"}, + {file = "numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191"}, + {file = "numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372"}, + {file = "numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d"}, + {file = "numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7"}, + {file = "numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73"}, + {file = "numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b"}, + {file = "numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471"}, + {file = "numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6"}, + {file = "numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba"}, + {file = "numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133"}, + {file = "numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376"}, + {file = "numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19"}, + {file = "numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0"}, + {file = "numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a"}, + {file = "numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066"}, + {file = "numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e"}, + {file = "numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8"}, + {file = "numpy-2.2.5-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4ea7e1cff6784e58fe281ce7e7f05036b3e1c89c6f922a6bfbc0a7e8768adbe"}, + {file = "numpy-2.2.5-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d7543263084a85fbc09c704b515395398d31d6395518446237eac219eab9e55e"}, + {file = "numpy-2.2.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0255732338c4fdd00996c0421884ea8a3651eea555c3a56b84892b66f696eb70"}, + {file = "numpy-2.2.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d2e3bdadaba0e040d1e7ab39db73e0afe2c74ae277f5614dad53eadbecbbb169"}, + {file = "numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pandas" +version = "2.2.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, + {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, + {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, + {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, + {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, + {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, + {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, + {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pandas-stubs" +version = "2.2.3.250308" +description = "Type annotations for pandas" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pandas_stubs-2.2.3.250308-py3-none-any.whl", hash = "sha256:a377edff3b61f8b268c82499fdbe7c00fdeed13235b8b71d6a1dc347aeddc74d"}, + {file = "pandas_stubs-2.2.3.250308.tar.gz", hash = "sha256:3a6e9daf161f00b85c83772ed3d5cff9522028f07a94817472c07b91f46710fd"}, +] + +[package.dependencies] +numpy = ">=1.23.5" +types-pytz = ">=2022.1.1" + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg" +version = "3.2.9" +description = "PostgreSQL database adapter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6"}, + {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.2.9) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.2.9) ; implementation_name != \"pypy\""] +dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.14)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] +docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] +pool = ["psycopg-pool"] +test = ["anyio (>=4.0)", "mypy (>=1.14)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + +[[package]] +name = "ruff" +version = "0.11.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58"}, + {file = "ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed"}, + {file = "ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f"}, + {file = "ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b"}, + {file = "ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2"}, + {file = "ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523"}, + {file = "ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125"}, + {file = "ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad"}, + {file = "ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19"}, + {file = "ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224"}, + {file = "ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1"}, + {file = "ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "types-pytz" +version = "2025.2.0.20250326" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "types_pytz-2025.2.0.20250326-py3-none-any.whl", hash = "sha256:3c397fd1b845cd2b3adc9398607764ced9e578a98a5d1fbb4a9bc9253edfb162"}, + {file = "types_pytz-2025.2.0.20250326.tar.gz", hash = "sha256:deda02de24f527066fc8d6a19e284ab3f3ae716a42b4adb6b40e75e408c08d36"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] +markers = {main = "python_version < \"3.13\""} + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "88c626506f796301947928a0229fda5f5656dffdd6d3e79a8426a85643e65002" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a34276 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "cvec" +version = "0.1.0" +description = "SDK for CVector Energy" +authors = [ + {name = "CVector",email = "support@cvector.energy"} +] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "pandas (>=2.2.3,<3.0.0)", + "psycopg (>=3.1.0,<4.0.0)" # Assuming a recent version of psycopg3 +] + +[tool.poetry] +packages = [{include = "cvec", from = "src"}] + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.5" +mypy = "^1.15.0" +pandas-stubs = "^2.2.3.250308" +ruff = "^0.11.10" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..ae674b4 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# This script runs ruff and mypy on the specified file or directory. + +# Exit immediately if a command exits with a non-zero status. +set -e + +TARGET=${1:-.} + +poetry run ruff check --fix "$TARGET" +poetry run ruff format "$TARGET" +poetry run mypy --strict "$TARGET" diff --git a/src/cvec/__init__.py b/src/cvec/__init__.py new file mode 100644 index 0000000..30c8e17 --- /dev/null +++ b/src/cvec/__init__.py @@ -0,0 +1,5 @@ +from .cvec import CVec +from .span import Span +from .metric import Metric + +__all__ = ["CVec", "Span", "Metric"] diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py new file mode 100644 index 0000000..062a143 --- /dev/null +++ b/src/cvec/cvec.py @@ -0,0 +1,242 @@ +import os +from datetime import datetime +from typing import Any, List, Optional + +import pandas as pd +import psycopg + +from .span import Span +from .metric import Metric + + +class CVec: + """ + CVec API Client + """ + + host: Optional[str] + tenant: Optional[str] + api_key: Optional[str] + default_start_at: Optional[datetime] + default_end_at: Optional[datetime] + + def __init__( + self, + host: Optional[str] = None, + tenant: Optional[str] = None, + api_key: Optional[str] = None, + default_start_at: Optional[datetime] = None, + default_end_at: Optional[datetime] = None, + ) -> None: + """ + Setup the SDK with the given host and API Key. + The host and API key are loaded from environment variables CVEC_HOST, + CVEC_TENANT, CVEC_API_KEY, if they are not given as arguments to the constructor. + The default_start_at and default_end_at can provide a default query time interval for API methods. + """ + self.host = host or os.environ.get("CVEC_HOST") + self.tenant = tenant or os.environ.get("CVEC_TENANT") + self.api_key = api_key or os.environ.get("CVEC_API_KEY") + self.default_start_at = default_start_at + self.default_end_at = default_end_at + + if not self.host: + raise ValueError( + "CVEC_HOST must be set either as an argument or environment variable" + ) + if not self.tenant: + raise ValueError( + "CVEC_TENANT must be set either as an argument or environment variable" + ) + if not self.api_key: + raise ValueError( + "CVEC_API_KEY must be set either as an argument or environment variable" + ) + + def _get_db_connection(self) -> psycopg.Connection: + """Helper method to establish a database connection.""" + return psycopg.connect( + user=self.tenant, + password=self.api_key, + host=self.host, + dbname=self.tenant, + ) + + def get_spans( + self, + name: str, + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, + limit: Optional[int] = None, + ) -> List[Span]: + """ + Return time spans for a metric. Spans are generated from value changes + that occur after `start_at` (if specified) and before `end_at` (if specified). + If `start_at` is `None` (e.g., not provided via argument or class default), + the query is unbounded at the start. If `end_at` is `None`, it's unbounded at the end. + + Each span represents a period where the metric's value is constant. + - `value`: The metric's value during the span. + - `name`: The name of the metric. + - `raw_start_at`: The timestamp of the value change that initiated this span's value. + This will be >= `_start_at` if `_start_at` was specified. + - `raw_end_at`: The timestamp marking the end of this span's constant value. + For the newest span, the value is `None`. For other spans, it's the raw_start_at of the immediately newer data point, which is next span in the list. + - `id`: Currently `None`. + - `metadata`: Currently `None`. + + Returns a list of Span objects, sorted in descending chronological order (newest span first). + Each Span object has attributes corresponding to the fields listed above. + If no relevant value changes are found, an empty list is returned. + The `limit` parameter restricts the number of spans returned. + """ + _start_at = start_at or self.default_start_at + _end_at = end_at or self.default_end_at + + with self._get_db_connection() as conn: + with conn.cursor() as cur: + query_params = { + "metric": name, + "start_at": _start_at, + "end_at": _end_at, + # Fetch up to 'limit' points. If limit is None, then the `LIMIT NULL` clause + # has no effect (in PostgreSQL). + "limit": limit, + } + + combined_query = """ + SELECT + time, + value_double, + value_string + FROM metric_data + WHERE metric = %(metric)s + AND (time >= %(start_at)s OR %(start_at)s IS NULL) + AND (time < %(end_at)s OR %(end_at)s IS NULL) + ORDER BY time DESC + LIMIT %(limit)s + """ + cur.execute(combined_query, query_params) + db_rows = cur.fetchall() + spans = [] + + # None indicates that the end time is not known; the span extends beyond + # the query period. + raw_end_at = None + for time, value_double, value_string in db_rows: + raw_start_at = time + value = value_double if value_double is not None else value_string + spans.append( + Span( + id=None, + name=name, + value=value, + raw_start_at=raw_start_at, + raw_end_at=raw_end_at, + metadata=None, + ) + ) + raw_end_at = raw_start_at + + return spans + + def get_metric_data( + self, + names: Optional[List[str]] = None, + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, + ) -> pd.DataFrame: + """ + Return all data-points within a given [start_at, end_at) interval, + optionally selecting a given list of metric names. + The return value is a Pandas DataFrame with four columns: name, time, value_double, value_string. + One row is returned for each metric value transition. + """ + _start_at = start_at or self.default_start_at + _end_at = end_at or self.default_end_at + + params = { + "start_at": _start_at, + "end_at": _end_at, + "tag_names_is_null": names is None, + # Pass an empty tuple if names is None or empty, otherwise the tuple of names. + # ANY(%(empty_tuple)s) will correctly result in no matches if names is empty. + # If names is None, the tag_names_is_null condition handles it. + "tag_names_list": names if names else [], + } + + sql_query = """ + SELECT metric AS name, time, value_double, value_string + FROM metric_data + WHERE (time >= %(start_at)s OR %(start_at)s IS NULL) + AND (time < %(end_at)s OR %(end_at)s IS NULL) + AND (%(tag_names_is_null)s IS TRUE OR metric = ANY(%(tag_names_list)s)) + ORDER BY name, time ASC + """ + + with self._get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute(sql_query, params) + rows = cur.fetchall() + + if not rows: + return pd.DataFrame( + columns=["name", "time", "value_double", "value_string"] + ) + + # Create DataFrame from fetched rows + df = pd.DataFrame( + rows, columns=["name", "time", "value_double", "value_string"] + ) + + # Return the DataFrame with the required columns + return df[["name", "time", "value_double", "value_string"]] + + def get_metrics( + self, start_at: Optional[datetime] = None, end_at: Optional[datetime] = None + ) -> List[Metric]: + """ + Return a list of metrics that had at least one transition in the given [start_at, end_at) interval. + All metrics are returned if no start_at and end_at are given. + """ + sql_query: str + params: Optional[dict[str, Any]] + + if start_at is None and end_at is None: + # No time interval specified by arguments, return all tags + sql_query = """ + SELECT id, normalized_name AS name, birth_at, death_at + FROM tag_names + ORDER BY name ASC; + """ + params = None + else: + # Time interval specified, find tags with transitions in the interval + _start_at = start_at or self.default_start_at + _end_at = end_at or self.default_end_at + + params = {"start_at_param": _start_at, "end_at_param": _end_at} + sql_query = f""" + SELECT DISTINCT metric_id AS id, metric AS name, birth_at, death_at + FROM {self.tenant}.metric_data + WHERE (time >= %(start_at_param)s OR %(start_at_param)s IS NULL) + AND (time < %(end_at_param)s OR %(end_at_param)s IS NULL) + ORDER BY name ASC; + """ + + with self._get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute(sql_query, params) + rows = cur.fetchall() + + # Format rows into list of Metric objects + metrics_list = [ + Metric( + id=row[0], + name=row[1], + birth_at=row[2], + death_at=row[3], + ) + for row in rows + ] + return metrics_list diff --git a/src/cvec/metric.py b/src/cvec/metric.py new file mode 100644 index 0000000..5a392b8 --- /dev/null +++ b/src/cvec/metric.py @@ -0,0 +1,31 @@ +from datetime import datetime +from typing import Optional + + +class Metric: + """ + Represents metadata for a metric. + """ + + id: int + name: str + birth_at: Optional[datetime] + death_at: Optional[datetime] + + def __init__( + self, + id: int, + name: str, + birth_at: Optional[datetime], + death_at: Optional[datetime], + ): + self.id = id + self.name = name + self.birth_at = birth_at + self.death_at = death_at + + def __repr__(self) -> str: + return ( + f"Metric(id={self.id!r}, name={self.name!r}, " + f"birth_at={self.birth_at!r}, death_at={self.death_at!r})" + ) diff --git a/src/cvec/py.typed b/src/cvec/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/src/cvec/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/src/cvec/span.py b/src/cvec/span.py new file mode 100644 index 0000000..55078bc --- /dev/null +++ b/src/cvec/span.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Any, Optional, Union + + +class Span: + """ + Represents a time span where a metric has a constant value. + """ + + id: Optional[Any] + name: str + value: Optional[Union[float, str]] + raw_start_at: datetime + raw_end_at: Optional[datetime] + metadata: Optional[Any] + + def __init__( + self, + id: Optional[Any], + name: str, + value: Optional[Union[float, str]], + raw_start_at: datetime, + raw_end_at: Optional[datetime], + metadata: Optional[Any], + ): + self.id = id + self.name = name + self.value = value + self.raw_start_at = raw_start_at + self.raw_end_at = raw_end_at + self.metadata = metadata + + def __repr__(self) -> str: + raw_start_at_repr = ( + self.raw_start_at.isoformat() if self.raw_start_at else "None" + ) + raw_end_at_repr = self.raw_end_at.isoformat() if self.raw_end_at else "None" + return ( + f"Span(id={self.id!r}, name={self.name!r}, value={self.value!r}, " + f"raw_start_at={raw_start_at_repr}, raw_end_at={raw_end_at_repr}, " + f"metadata={self.metadata!r})" + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cvec.py b/tests/test_cvec.py new file mode 100644 index 0000000..eb31985 --- /dev/null +++ b/tests/test_cvec.py @@ -0,0 +1,464 @@ +import pytest +import os +from unittest.mock import patch, MagicMock +from datetime import datetime +import pandas as pd +import numpy as np +from pandas.testing import assert_frame_equal +from cvec import CVec, Metric + + +class TestCVecConstructor: + def test_constructor_with_arguments(self) -> None: + """Test CVec constructor with all arguments provided.""" + client = CVec( + host="test_host", + tenant="test_tenant", + api_key="test_api_key", + default_start_at=datetime(2023, 1, 1, 0, 0, 0), + default_end_at=datetime(2023, 1, 2, 0, 0, 0), + ) + assert client.host == "test_host" + assert client.tenant == "test_tenant" + assert client.api_key == "test_api_key" + assert client.default_start_at == datetime(2023, 1, 1, 0, 0, 0) + assert client.default_end_at == datetime(2023, 1, 2, 0, 0, 0) + + @patch.dict( + os.environ, + { + "CVEC_HOST": "env_host", + "CVEC_TENANT": "env_tenant", + "CVEC_API_KEY": "env_api_key", + }, + clear=True, + ) + def test_constructor_with_env_vars(self) -> None: + """Test CVec constructor with environment variables.""" + client = CVec( + default_start_at=datetime(2023, 2, 1, 0, 0, 0), + default_end_at=datetime(2023, 2, 2, 0, 0, 0), + ) + assert client.host == "env_host" + assert client.tenant == "env_tenant" + assert client.api_key == "env_api_key" + assert client.default_start_at == datetime(2023, 2, 1, 0, 0, 0) + assert client.default_end_at == datetime(2023, 2, 2, 0, 0, 0) + + @patch.dict(os.environ, {}, clear=True) + def test_constructor_missing_host_raises_value_error(self) -> None: + """Test CVec constructor raises ValueError if host is missing.""" + with pytest.raises( + ValueError, + match="CVEC_HOST must be set either as an argument or environment variable", + ): + CVec(tenant="test_tenant", api_key="test_api_key") + + @patch.dict(os.environ, {}, clear=True) + def test_constructor_missing_tenant_raises_value_error(self) -> None: + """Test CVec constructor raises ValueError if tenant is missing.""" + with pytest.raises( + ValueError, + match="CVEC_TENANT must be set either as an argument or environment variable", + ): + CVec(host="test_host", api_key="test_api_key") + + @patch.dict(os.environ, {}, clear=True) + def test_constructor_missing_api_key_raises_value_error(self) -> None: + """Test CVec constructor raises ValueError if api_key is missing.""" + with pytest.raises( + ValueError, + match="CVEC_API_KEY must be set either as an argument or environment variable", + ): + CVec(host="test_host", tenant="test_tenant") + + @patch.dict( + os.environ, + { + "CVEC_HOST": "env_host", + # CVEC_TENANT is missing + "CVEC_API_KEY": "env_api_key", + }, + clear=True, + ) + def test_constructor_missing_tenant_env_var_raises_value_error(self) -> None: + """Test CVec constructor raises ValueError if CVEC_TENANT env var is missing.""" + with pytest.raises( + ValueError, + match="CVEC_TENANT must be set either as an argument or environment variable", + ): + CVec() + + def test_constructor_args_override_env_vars(self) -> None: + """Test CVec constructor arguments override environment variables.""" + with patch.dict( + os.environ, + { + "CVEC_HOST": "env_host", + "CVEC_TENANT": "env_tenant", + "CVEC_API_KEY": "env_api_key", + }, + clear=True, + ): + client = CVec( + host="arg_host", + tenant="arg_tenant", + api_key="arg_api_key", + default_start_at=datetime(2023, 3, 1, 0, 0, 0), + default_end_at=datetime(2023, 3, 2, 0, 0, 0), + ) + assert client.host == "arg_host" + assert client.tenant == "arg_tenant" + assert client.api_key == "arg_api_key" + assert client.default_start_at == datetime(2023, 3, 1, 0, 0, 0) + assert client.default_end_at == datetime(2023, 3, 2, 0, 0, 0) + + +class TestCVecGetSpans: + @patch("cvec.cvec.psycopg.connect") + def test_get_spans_basic_case(self, mock_connect: MagicMock) -> None: + """Test get_spans with a few data points.""" + # Setup mock connection and cursor + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + # Sample data (time, value_double, value_string) - newest first + time1 = datetime(2023, 1, 1, 10, 0, 0) + time2 = datetime(2023, 1, 1, 11, 0, 0) + time3 = datetime(2023, 1, 1, 12, 0, 0) + mock_db_rows = [ + (time3, 30.0, None), # Newest + (time2, None, "val2"), + (time1, 10.0, None), # Oldest + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + tag_name = "test_tag" + spans = client.get_spans(name=tag_name) + + assert len(spans) == 3 + mock_cur.execute.assert_called_once() + + # Verify psycopg query parameters + (_sql, params), _kwargs = mock_cur.execute.call_args + assert params["metric"] == tag_name + assert params["end_at"] is None # Default end_at + assert params["limit"] is None # Default limit + + # Span 1 (from newest data point: time3) + # The raw_end_at is None for the newest span, because the span is still open. + assert spans[0].name == tag_name + assert spans[0].value == 30.0 + assert spans[0].raw_start_at == time3 + assert spans[0].raw_end_at is None + + # Span 2 (from data point: time2) + assert spans[1].name == tag_name + assert spans[1].value == "val2" + assert spans[1].raw_start_at == time2 + assert spans[1].raw_end_at == time3 + + # Span 3 (from oldest data point: time1) + assert spans[2].name == tag_name + assert spans[2].value == 10.0 + assert spans[2].raw_start_at == time1 + assert spans[2].raw_end_at == time2 + + +class TestCVecGetMetrics: + @patch("cvec.cvec.psycopg.connect") + def test_get_metrics_no_interval(self, mock_connect: MagicMock) -> None: + """Test get_metrics when no start_at or end_at is provided (fetches all metrics).""" + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + time_birth1 = datetime(2023, 1, 1, 0, 0, 0) + time_death1 = datetime(2023, 1, 10, 0, 0, 0) + time_birth2 = datetime(2023, 2, 1, 0, 0, 0) + mock_db_rows = [ + (1, "metric1", time_birth1, time_death1), + (2, "metric2", time_birth2, None), + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + metrics = client.get_metrics() + + mock_cur.execute.assert_called_once() + sql_query, params = mock_cur.execute.call_args.args + assert "SELECT id, normalized_name AS name, birth_at, death_at" in sql_query + assert "FROM tag_names" in sql_query + assert "ORDER BY name ASC" in sql_query + assert params is None # No params when fetching all + + assert len(metrics) == 2 + assert isinstance(metrics[0], Metric) + assert metrics[0].id == 1 + assert metrics[0].name == "metric1" + assert metrics[0].birth_at == time_birth1 + assert metrics[0].death_at == time_death1 + + assert isinstance(metrics[1], Metric) + assert metrics[1].id == 2 + assert metrics[1].name == "metric2" + assert metrics[1].birth_at == time_birth2 + assert metrics[1].death_at is None + + @patch("cvec.cvec.psycopg.connect") + def test_get_metrics_with_interval(self, mock_connect: MagicMock) -> None: + """Test get_metrics when a start_at and end_at interval is provided.""" + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + time_birth1 = datetime(2023, 1, 1, 0, 0, 0) + mock_db_rows = [ + (1, "metric_in_interval", time_birth1, None), + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + start_query = datetime(2023, 1, 5, 0, 0, 0) + end_query = datetime(2023, 1, 15, 0, 0, 0) + metrics = client.get_metrics(start_at=start_query, end_at=end_query) + + mock_cur.execute.assert_called_once() + sql_query, params = mock_cur.execute.call_args.args + assert ( + "SELECT DISTINCT metric_id AS id, metric AS name, birth_at, death_at" + in sql_query + ) + assert f"FROM {client.tenant}.metric_data" in sql_query + assert ( + "WHERE (time >= %(start_at_param)s OR %(start_at_param)s IS NULL)" + in sql_query + ) + assert "AND (time < %(end_at_param)s OR %(end_at_param)s IS NULL)" in sql_query + assert params is not None + assert params["start_at_param"] == start_query + assert params["end_at_param"] == end_query + + assert len(metrics) == 1 + assert isinstance(metrics[0], Metric) + assert metrics[0].id == 1 + assert metrics[0].name == "metric_in_interval" + assert metrics[0].birth_at == time_birth1 + assert metrics[0].death_at is None + + @patch("cvec.cvec.psycopg.connect") + def test_get_metrics_no_data_found(self, mock_connect: MagicMock) -> None: + """Test get_metrics when no metrics are found for the given criteria.""" + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + mock_cur.fetchall.return_value = [] # No rows returned + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + metrics = client.get_metrics( + start_at=datetime(2024, 1, 1), end_at=datetime(2024, 1, 2) + ) + + mock_cur.execute.assert_called_once() + assert len(metrics) == 0 + + +class TestCVecGetMetricData: + @patch("cvec.cvec.psycopg.connect") + def test_get_metric_data_basic_case(self, mock_connect: MagicMock) -> None: + """Test get_metric_data with a few data points for multiple tags.""" + # Setup mock connection and cursor + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + # Sample data (metric, time, value_double, value_string) + time1 = datetime(2023, 1, 1, 10, 0, 0) + time2 = datetime(2023, 1, 1, 11, 0, 0) + time3 = datetime(2023, 1, 1, 12, 0, 0) + mock_db_rows = [ + ("tag1", time1, 10.0, None), + ("tag1", time2, 20.0, None), + ("tag2", time3, None, "val_str"), + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + names_to_query = ["tag1", "tag2"] + df = client.get_metric_data(names=names_to_query) + + mock_cur.execute.assert_called_once() + (_sql, params), _kwargs = mock_cur.execute.call_args + assert params["tag_names_is_null"] is False + assert params["tag_names_list"] == names_to_query + assert params["start_at"] is None # Default start_at + assert params["end_at"] is None # Default end_at + + expected_data = { + "name": ["tag1", "tag1", "tag2"], + "time": [time1, time2, time3], + "value_double": [10.0, 20.0, np.nan], # Use np.nan for missing float + "value_string": [None, None, "val_str"], # Use None for missing string + } + expected_df = pd.DataFrame(expected_data) + + # Ensure correct dtypes for comparison, especially for NA handling + expected_df = expected_df.astype( + {"value_double": "float64", "value_string": "object"} + ) + df = df.astype({"value_double": "float64", "value_string": "object"}) + + assert_frame_equal(df, expected_df, check_dtype=True) + + @patch("cvec.cvec.psycopg.connect") + def test_get_metric_data_no_data_points(self, mock_connect: MagicMock) -> None: + """Test get_metric_data when no data points are returned.""" + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + mock_cur.fetchall.return_value = [] + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + df = client.get_metric_data(names=["non_existent_tag"]) + + mock_cur.execute.assert_called_once() + expected_df = pd.DataFrame( + columns=["name", "time", "value_double", "value_string"] + ) + # Ensure correct dtypes for empty DataFrame comparison + expected_df = expected_df.astype( + { + "name": "object", + "time": "datetime64[ns]", + "value_double": "float64", + "value_string": "object", + } + ) + df = df.astype( + { + "name": "object", + "time": "datetime64[ns]", + "value_double": "float64", + "value_string": "object", + } + ) + assert_frame_equal(df, expected_df, check_dtype=True) + + @patch("cvec.cvec.psycopg.connect") + def test_get_spans_no_data_points(self, mock_connect: MagicMock) -> None: + """Test get_spans when no data points are returned from the database.""" + # Setup mock connection and cursor + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + mock_cur.fetchall.return_value = [] # No data points + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + tag_name = "test_tag_no_data" + spans = client.get_spans(name=tag_name) + + assert len(spans) == 0 + mock_cur.execute.assert_called_once() + + # Verify psycopg query parameters + (_sql, params) = mock_cur.execute.call_args.args + assert params["metric"] == tag_name + assert params["end_at"] is None # Default end_at + assert params["limit"] is None # Default limit + + @patch("cvec.cvec.psycopg.connect") + def test_get_spans_with_limit_parameter(self, mock_connect: MagicMock) -> None: + """Test get_spans when a limit parameter is provided.""" + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + # Sample data (time, value_double, value_string) - newest first + time1 = datetime(2023, 1, 1, 10, 0, 0) + time2 = datetime(2023, 1, 1, 11, 0, 0) + mock_db_rows = [ + (time2, None, "val2"), # Newest + (time1, 10.0, None), # Oldest + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + tag_name = "test_tag_limited" + query_limit = 2 + spans = client.get_spans(name=tag_name, limit=query_limit) + + mock_cur.execute.assert_called_once() + + # Verify psycopg query parameters + (_sql, params), _kwargs = mock_cur.execute.call_args + assert params["metric"] == tag_name + assert params["limit"] == query_limit + + assert len(spans) == 2 + + @patch("cvec.cvec.psycopg.connect") + def test_get_spans_with_end_at_parameter(self, mock_connect: MagicMock) -> None: + """Test get_spans when an end_at parameter is provided.""" + # Setup mock connection and cursor + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + + # Sample data (time, value_double, value_string) - newest first + time1 = datetime(2023, 1, 1, 10, 0, 0) + time2 = datetime(2023, 1, 1, 11, 0, 0) + time3 = datetime(2023, 1, 1, 12, 0, 0) + mock_db_rows = [ + (time3, 30.0, None), # Newest + (time2, None, "val2"), + (time1, 10.0, None), # Oldest + ] + mock_cur.fetchall.return_value = mock_db_rows + + client = CVec(host="test_host", tenant="test_tenant", api_key="test_api_key") + tag_name = "test_tag" + # Provide an end_at time that is after all sample data points + query_end_at = datetime(2023, 1, 1, 13, 0, 0) + spans = client.get_spans(name=tag_name, end_at=query_end_at) + + assert len(spans) == 3 + mock_cur.execute.assert_called_once() + + # Verify psycopg query parameters + (_sql, params), _kwargs = mock_cur.execute.call_args + assert params["metric"] == tag_name + assert params["end_at"] == query_end_at + assert params["limit"] is None # Default limit + + # Span 1 (from newest data point: time3) + # The raw_end_at is None for the newest span, regardless of the _end_at query parameter. + assert spans[0].name == tag_name + assert spans[0].value == 30.0 + assert spans[0].raw_start_at == time3 + assert spans[0].raw_end_at is None + + # Span 2 (from data point: time2) + assert spans[1].name == tag_name + assert spans[1].value == "val2" + assert spans[1].raw_start_at == time2 + assert spans[1].raw_end_at == time3 + + # Span 3 (from oldest data point: time1) + assert spans[2].name == tag_name + assert spans[2].value == 10.0 + assert spans[2].raw_start_at == time1 + assert spans[2].raw_end_at == time2