influxdata-plugin-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ .claude
2
+ .context
3
+ .worktrees/
4
+ *.pyc
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-08
11
+
12
+ ### Added
13
+
14
+ - `config` — dynaconf-backed config loading (`load_plugin_config`), plugin
15
+ directory resolution (`resolve_plugin_dir`, `resolve_path`), re-exported
16
+ `Validator`.
17
+ - `introspection` — schema helpers (`get_table_names`, `get_tag_names`,
18
+ `get_field_names`) and `query_window`, with optional TTL caching.
19
+ - `parsing` — `parse_timedelta`, `parse_timestamp_ns`, `parse_int`,
20
+ `parse_bool`, `parse_delimited_list`, `parse_key_value`.
21
+ - `cache` — `cached` TTL wrapper over `influxdb3_local.cache`.
22
+ - `write` — `build_line`, `build_line_typed`, `add_field_with_type`,
23
+ `write_data` (batching + retry), `BatchLines`.
24
+
25
+ [Unreleased]: https://github.com/influxdata/influxdb3_plugins/compare/utils-v0.1.0...HEAD
26
+ [0.1.0]: https://github.com/influxdata/influxdb3_plugins/releases/tag/utils-v0.1.0
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,25 @@
1
+ Copyright (c) 2020 InfluxData
2
+
3
+ Permission is hereby granted, free of charge, to any
4
+ person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the
6
+ Software without restriction, including without
7
+ limitation the rights to use, copy, modify, merge,
8
+ publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software
10
+ is furnished to do so, subject to the following
11
+ conditions:
12
+
13
+ The above copyright notice and this permission notice
14
+ shall be included in all copies or substantial portions
15
+ of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
18
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
19
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
20
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
21
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
22
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
24
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25
+ DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: influxdata-plugin-utils
3
+ Version: 0.1.0
4
+ Summary: Shared helpers for InfluxDB 3 plugins.
5
+ Project-URL: Homepage, https://github.com/influxdata/influxdb3_plugins
6
+ Project-URL: Repository, https://github.com/influxdata/influxdb3_plugins
7
+ Author: InfluxData
8
+ License-Expression: MIT OR Apache-2.0
9
+ License-File: LICENSE-APACHE
10
+ License-File: LICENSE-MIT
11
+ Keywords: influxdb,influxdb3,plugins
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: dynaconf>=3.2
16
+ Description-Content-Type: text/markdown
17
+
18
+ # influxdata-plugin-utils
19
+
20
+ Shared helpers for InfluxDB 3 plugins.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install influxdata-plugin-utils
26
+ ```
27
+
28
+ Editable, for local development:
29
+
30
+ ```bash
31
+ pip install -e influxdata-plugin-utils
32
+ ```
33
+
34
+ ## Modules
35
+
36
+ | Module | What it provides |
37
+ |-----------------|-----------------------------------------------------------------------------------------------------------------------------|
38
+ | `config` | `load_plugin_config(args, validators)` (dynaconf-backed), `resolve_plugin_dir()`, `resolve_path()`, re-exported `Validator` |
39
+ | `introspection` | `get_table_names()`, `get_tag_names()`, `get_field_names()`, `query_window()` |
40
+ | `parsing` | `parse_timedelta()`, `parse_timestamp_ns()`, `parse_int()`, `parse_bool()`, `parse_delimited_list()`, `parse_key_value()` |
41
+ | `cache` | `cached(influxdb3_local, key, producer, ttl_seconds=3600)` |
42
+ | `write` | `build_line()`, `build_line_typed()`, `add_field_with_type()`, `write_data()`, `BatchLines` |
43
+
44
+ ## Config: precedence
45
+
46
+ `load_plugin_config` merges sources low → high: **env vars → engine `args` → TOML file**. A provided TOML config file overrides everything. Environment variables are read only when their exact names are passed via `env_keys=[...]`; nothing is read from the environment by default.
47
+
48
+ ```python
49
+ from influxdata_plugin_utils.config import load_plugin_config, Validator
50
+ from influxdata_plugin_utils.parsing import parse_timedelta
51
+
52
+ def process_scheduled_call(influxdb3_local, call_time, args):
53
+ cfg = load_plugin_config(
54
+ args,
55
+ validators=[
56
+ Validator("source_table", must_exist=True),
57
+ Validator("batch_size", default=1000, gte=1, lte=10000, cast=int),
58
+ Validator("window", default="5min", cast=parse_timedelta),
59
+ ],
60
+ )
61
+ influxdb3_local.info(f"{cfg.source_table} window={cfg.window}")
62
+ ```
63
+
64
+ TOML becomes native — no manual string parsing:
65
+
66
+ ```toml
67
+ source_table = "cpu"
68
+ batch_size = 2000
69
+ excluded_fields = ["usage_idle", "usage_guest"]
70
+ ```
71
+
72
+ ## Write helpers
73
+
74
+ `LineBuilder` is a runtime global injected into the plugin, so builders take the
75
+ class as their first argument:
76
+
77
+ ```python
78
+ from influxdata_plugin_utils.write import build_line, write_data
79
+
80
+ lines = [
81
+ build_line(LineBuilder, "cpu", tags={"host": "a"}, fields={"usage": 12.5}, time_ns=ts)
82
+ ]
83
+ write_data(influxdb3_local, lines) # batched + retried by default
84
+ # write_data(influxdb3_local, lines, batch=False, retries=0) # opt out
85
+ ```
86
+
87
+ ## License
88
+
89
+ Licensed under either of [Apache License 2.0](LICENSE-APACHE) or
90
+ [MIT license](LICENSE-MIT) at your option.
@@ -0,0 +1,73 @@
1
+ # influxdata-plugin-utils
2
+
3
+ Shared helpers for InfluxDB 3 plugins.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install influxdata-plugin-utils
9
+ ```
10
+
11
+ Editable, for local development:
12
+
13
+ ```bash
14
+ pip install -e influxdata-plugin-utils
15
+ ```
16
+
17
+ ## Modules
18
+
19
+ | Module | What it provides |
20
+ |-----------------|-----------------------------------------------------------------------------------------------------------------------------|
21
+ | `config` | `load_plugin_config(args, validators)` (dynaconf-backed), `resolve_plugin_dir()`, `resolve_path()`, re-exported `Validator` |
22
+ | `introspection` | `get_table_names()`, `get_tag_names()`, `get_field_names()`, `query_window()` |
23
+ | `parsing` | `parse_timedelta()`, `parse_timestamp_ns()`, `parse_int()`, `parse_bool()`, `parse_delimited_list()`, `parse_key_value()` |
24
+ | `cache` | `cached(influxdb3_local, key, producer, ttl_seconds=3600)` |
25
+ | `write` | `build_line()`, `build_line_typed()`, `add_field_with_type()`, `write_data()`, `BatchLines` |
26
+
27
+ ## Config: precedence
28
+
29
+ `load_plugin_config` merges sources low → high: **env vars → engine `args` → TOML file**. A provided TOML config file overrides everything. Environment variables are read only when their exact names are passed via `env_keys=[...]`; nothing is read from the environment by default.
30
+
31
+ ```python
32
+ from influxdata_plugin_utils.config import load_plugin_config, Validator
33
+ from influxdata_plugin_utils.parsing import parse_timedelta
34
+
35
+ def process_scheduled_call(influxdb3_local, call_time, args):
36
+ cfg = load_plugin_config(
37
+ args,
38
+ validators=[
39
+ Validator("source_table", must_exist=True),
40
+ Validator("batch_size", default=1000, gte=1, lte=10000, cast=int),
41
+ Validator("window", default="5min", cast=parse_timedelta),
42
+ ],
43
+ )
44
+ influxdb3_local.info(f"{cfg.source_table} window={cfg.window}")
45
+ ```
46
+
47
+ TOML becomes native — no manual string parsing:
48
+
49
+ ```toml
50
+ source_table = "cpu"
51
+ batch_size = 2000
52
+ excluded_fields = ["usage_idle", "usage_guest"]
53
+ ```
54
+
55
+ ## Write helpers
56
+
57
+ `LineBuilder` is a runtime global injected into the plugin, so builders take the
58
+ class as their first argument:
59
+
60
+ ```python
61
+ from influxdata_plugin_utils.write import build_line, write_data
62
+
63
+ lines = [
64
+ build_line(LineBuilder, "cpu", tags={"host": "a"}, fields={"usage": 12.5}, time_ns=ts)
65
+ ]
66
+ write_data(influxdb3_local, lines) # batched + retried by default
67
+ # write_data(influxdb3_local, lines, batch=False, retries=0) # opt out
68
+ ```
69
+
70
+ ## License
71
+
72
+ Licensed under either of [Apache License 2.0](LICENSE-APACHE) or
73
+ [MIT license](LICENSE-MIT) at your option.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "influxdata-plugin-utils"
7
+ dynamic = ["version"]
8
+ description = "Shared helpers for InfluxDB 3 plugins."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT OR Apache-2.0"
12
+ license-files = ["LICENSE-MIT", "LICENSE-APACHE"]
13
+ authors = [{ name = "InfluxData" }]
14
+ keywords = ["influxdb", "influxdb3", "plugins"]
15
+ dependencies = ["dynaconf>=3.2"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3.11",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/influxdata/influxdb3_plugins"
23
+ Repository = "https://github.com/influxdata/influxdb3_plugins"
24
+
25
+ [tool.hatch.version]
26
+ path = "src/influxdata_plugin_utils/__init__.py"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/influxdata_plugin_utils"]
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ include = ["src", "README.md", "CHANGELOG.md", "LICENSE-MIT", "LICENSE-APACHE"]
@@ -0,0 +1,64 @@
1
+ """Shared helpers for InfluxDB 3 plugins.
2
+
3
+ Modules:
4
+ config - dynaconf-backed config loading, path resolution, validation
5
+ introspection - schema introspection and minimal time-window queries
6
+ parsing - duration / timestamp / int / bool / list / key=value parsers
7
+ cache - TTL cache over influxdb3_local.cache
8
+ write - LineBuilder builders and resilient write_data
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from . import cache, config, introspection, parsing, write
14
+ from .cache import cached
15
+ from .config import Validator, load_plugin_config, resolve_path, resolve_plugin_dir
16
+ from .introspection import (
17
+ get_field_names,
18
+ get_table_names,
19
+ get_tag_names,
20
+ query_window,
21
+ )
22
+ from .parsing import (
23
+ parse_bool,
24
+ parse_delimited_list,
25
+ parse_int,
26
+ parse_key_value,
27
+ parse_timedelta,
28
+ parse_timestamp_ns,
29
+ )
30
+ from .write import (
31
+ BatchLines,
32
+ add_field_with_type,
33
+ build_line,
34
+ build_line_typed,
35
+ write_data,
36
+ )
37
+
38
+ __all__ = [
39
+ "cache",
40
+ "config",
41
+ "introspection",
42
+ "parsing",
43
+ "write",
44
+ "cached",
45
+ "Validator",
46
+ "load_plugin_config",
47
+ "resolve_path",
48
+ "resolve_plugin_dir",
49
+ "get_field_names",
50
+ "get_table_names",
51
+ "get_tag_names",
52
+ "query_window",
53
+ "parse_bool",
54
+ "parse_delimited_list",
55
+ "parse_int",
56
+ "parse_key_value",
57
+ "parse_timedelta",
58
+ "parse_timestamp_ns",
59
+ "BatchLines",
60
+ "add_field_with_type",
61
+ "build_line",
62
+ "build_line_typed",
63
+ "write_data",
64
+ ]
@@ -0,0 +1,25 @@
1
+ """TTL caching over the plugin runtime cache (``influxdb3_local.cache``)."""
2
+
3
+ from typing import Callable
4
+
5
+ __all__ = ["cached"]
6
+
7
+
8
+ def cached(
9
+ influxdb3_local, key: str, producer: Callable[[], object], *, ttl_seconds: int = 3600
10
+ ):
11
+ """Return a cached value, or produce it, store it with a TTL, and return it.
12
+
13
+ Args:
14
+ influxdb3_local: InfluxDB client instance (exposes ``.cache``).
15
+ key: Cache key.
16
+ producer: Zero-arg callable that computes the value on a cache miss.
17
+ ttl_seconds: Time-to-live for the stored value (default 1 hour).
18
+ """
19
+ cache = influxdb3_local.cache
20
+ value = cache.get(key)
21
+ if value is not None:
22
+ return value
23
+ value = producer()
24
+ cache.put(key, value, ttl_seconds)
25
+ return value
@@ -0,0 +1,113 @@
1
+ """Plugin configuration loading backed by dynaconf.
2
+
3
+ Loads a plugin's TOML config (resolved via the plugin directory), merges
4
+ environment variables and engine-supplied ``args``, and validates the result.
5
+ dynaconf is an implementation detail and must not leak into plugin code beyond
6
+ the re-exported ``Validator``.
7
+ """
8
+
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ from dynaconf import Dynaconf, Validator
14
+
15
+ __all__ = ["resolve_plugin_dir", "resolve_path", "load_plugin_config", "Validator"]
16
+
17
+
18
+ def resolve_plugin_dir() -> Path:
19
+ """Resolve the plugin directory from the environment.
20
+
21
+ Order: ``PLUGIN_DIR`` -> ``INFLUXDB3_PLUGIN_DIR`` -> parent of ``VIRTUAL_ENV``.
22
+ """
23
+ for env_var in ("PLUGIN_DIR", "INFLUXDB3_PLUGIN_DIR"):
24
+ value = os.environ.get(env_var)
25
+ if value:
26
+ return Path(value)
27
+ virtual_env = os.environ.get("VIRTUAL_ENV")
28
+ if virtual_env:
29
+ return Path(virtual_env).parent
30
+ raise ValueError(
31
+ "Cannot resolve plugin directory: set PLUGIN_DIR, INFLUXDB3_PLUGIN_DIR, "
32
+ "or run inside the processing engine venv (VIRTUAL_ENV)."
33
+ )
34
+
35
+
36
+ def resolve_path(path: str) -> Path:
37
+ """Resolve a possibly relative path against the plugin directory.
38
+
39
+ Absolute paths are returned unchanged.
40
+ """
41
+ candidate = Path(path)
42
+ if candidate.is_absolute():
43
+ return candidate
44
+ return resolve_plugin_dir() / candidate
45
+
46
+
47
+ def load_plugin_config(
48
+ args: dict,
49
+ validators: list[Validator] | None = None,
50
+ *,
51
+ env_keys: list[str] | None = None,
52
+ config_file_path_arg: str = "config_file_path",
53
+ source: str = "merge",
54
+ ) -> Dynaconf:
55
+ """Load and validate plugin configuration.
56
+
57
+ Layers are merged per key (low -> high): env vars -> ``args`` -> TOML file.
58
+ Within a layer, later keys override earlier ones; layers do not replace each
59
+ other wholesale. dynaconf is used for casting and validation only.
60
+
61
+ Args:
62
+ args: The dict passed to the plugin entry point (``None`` is treated as
63
+ empty). The TOML file path is read from ``args[config_file_path_arg]``
64
+ when present.
65
+ validators: Optional dynaconf ``Validator`` objects for required keys,
66
+ type casting, and bounds.
67
+ env_keys: Explicit environment variable names to read. Nothing is read
68
+ from the environment when omitted; each name is lowercased to form
69
+ the config key.
70
+ config_file_path_arg: Name of the ``args`` key holding the TOML path.
71
+ source: Which non-env layers to apply. ``"merge"`` uses both ``args``
72
+ and TOML (TOML highest); ``"args"`` uses only ``args``; ``"toml"``
73
+ uses only the TOML file. The env layer always applies underneath.
74
+
75
+ Returns:
76
+ A ``Dynaconf`` settings object; access values as attributes or items.
77
+ """
78
+ if source not in ("merge", "args", "toml"):
79
+ raise ValueError(
80
+ f"Invalid source {source!r}. Supported: merge, args, toml"
81
+ )
82
+
83
+ # the engine passes None when a trigger has no arguments
84
+ args = args or {}
85
+
86
+ layers: dict = {}
87
+
88
+ # 1. env vars (lowest): only the explicitly requested names
89
+ for env_var in env_keys or []:
90
+ value = os.environ.get(env_var)
91
+ if value is not None:
92
+ layers[env_var.lower()] = value
93
+
94
+ # 2. engine args (middle)
95
+ if source in ("merge", "args"):
96
+ for key, value in args.items():
97
+ if key != config_file_path_arg:
98
+ layers[key] = value
99
+
100
+ # 3. TOML file (highest)
101
+ if source in ("merge", "toml"):
102
+ config_file_path = args.get(config_file_path_arg)
103
+ if config_file_path:
104
+ with open(resolve_path(config_file_path), "rb") as config_file:
105
+ layers.update(tomllib.load(config_file))
106
+
107
+ # loaders=[] disables the DYNACONF_* env loader; env is read only via env_keys
108
+ settings = Dynaconf(loaders=[])
109
+ settings.update(layers)
110
+ if validators:
111
+ settings.validators.register(*validators)
112
+ settings.validators.validate()
113
+ return settings
@@ -0,0 +1,122 @@
1
+ """Schema introspection and minimal query helpers.
2
+
3
+ Each helper takes ``influxdb3_local`` explicitly and may optionally use the
4
+ TTL cache. Queries mirror the patterns already used across the plugins.
5
+ """
6
+
7
+ from .cache import cached
8
+
9
+ __all__ = [
10
+ "get_table_names",
11
+ "get_tag_names",
12
+ "get_field_names",
13
+ "query_window",
14
+ ]
15
+
16
+ _TAG_DATA_TYPE = "Dictionary(Int32, Utf8)"
17
+ _NUMERIC_TYPES = {"Int64", "UInt64", "Float64", "Int32", "Float32"}
18
+
19
+
20
+ def _quote_identifier(identifier: str) -> str:
21
+ """Quote a SQL identifier, escaping embedded double quotes."""
22
+ return '"' + identifier.replace('"', '""') + '"'
23
+
24
+
25
+ def get_table_names(
26
+ influxdb3_local, *, use_cache: bool = True, ttl_seconds: int = 3600
27
+ ) -> list[str]:
28
+ """Return base table names via ``SHOW TABLES``."""
29
+
30
+ def producer() -> list[str]:
31
+ rows = influxdb3_local.query("SHOW TABLES")
32
+ return [
33
+ row["table_name"]
34
+ for row in rows
35
+ if row.get("table_type") == "BASE TABLE"
36
+ ]
37
+
38
+ if use_cache:
39
+ return cached(influxdb3_local, "shared:tables", producer, ttl_seconds=ttl_seconds)
40
+ return producer()
41
+
42
+
43
+ def get_tag_names(
44
+ influxdb3_local, table: str, *, use_cache: bool = True, ttl_seconds: int = 3600
45
+ ) -> list[str]:
46
+ """Return tag column names of a table (``Dictionary(Int32, Utf8)`` columns)."""
47
+
48
+ def producer() -> list[str]:
49
+ query = (
50
+ "SELECT column_name FROM information_schema.columns "
51
+ "WHERE table_name = $table AND data_type = $data_type"
52
+ )
53
+ rows = influxdb3_local.query(
54
+ query, {"table": table, "data_type": _TAG_DATA_TYPE}
55
+ )
56
+ return [row["column_name"] for row in rows]
57
+
58
+ if use_cache:
59
+ return cached(
60
+ influxdb3_local, f"shared:tags:{table}", producer, ttl_seconds=ttl_seconds
61
+ )
62
+ return producer()
63
+
64
+
65
+ def get_field_names(
66
+ influxdb3_local,
67
+ table: str,
68
+ *,
69
+ numeric_only: bool = False,
70
+ use_cache: bool = True,
71
+ ttl_seconds: int = 3600,
72
+ ) -> list[str]:
73
+ """Return field column names of a table (excludes tags and ``time``).
74
+
75
+ With ``numeric_only=True`` only integer/float columns are returned.
76
+ """
77
+
78
+ def producer() -> list[str]:
79
+ query = (
80
+ "SELECT column_name, data_type FROM information_schema.columns "
81
+ "WHERE table_name = $table"
82
+ )
83
+ rows = influxdb3_local.query(query, {"table": table})
84
+ names: list[str] = []
85
+ for row in rows:
86
+ name = row["column_name"]
87
+ data_type = row.get("data_type", "")
88
+ if name == "time" or data_type == _TAG_DATA_TYPE:
89
+ continue
90
+ if numeric_only and data_type not in _NUMERIC_TYPES:
91
+ continue
92
+ names.append(name)
93
+ return names
94
+
95
+ if use_cache:
96
+ key = f"shared:fields:{table}:{int(numeric_only)}"
97
+ return cached(influxdb3_local, key, producer, ttl_seconds=ttl_seconds)
98
+ return producer()
99
+
100
+
101
+ def query_window(
102
+ influxdb3_local,
103
+ table: str,
104
+ *,
105
+ start,
106
+ end,
107
+ columns: list[str] | None = None,
108
+ ) -> list[dict]:
109
+ """Run a basic ``time``-window query and return rows (``[]`` if none).
110
+
111
+ ``start``/``end`` are passed as query parameters; provide values the engine
112
+ accepts for ``time`` comparisons (e.g. RFC3339 strings).
113
+ """
114
+ selected = (
115
+ ", ".join(_quote_identifier(column) for column in columns) if columns else "*"
116
+ )
117
+ query = (
118
+ f"SELECT {selected} FROM {_quote_identifier(table)} "
119
+ "WHERE time >= $start AND time < $end ORDER BY time"
120
+ )
121
+ rows = influxdb3_local.query(query, {"start": start, "end": end})
122
+ return rows or []
@@ -0,0 +1,127 @@
1
+ """Parsing helpers for plugin configuration values.
2
+
3
+ Each parser accepts a raw string (as delivered via CLI/trigger args) and can be
4
+ used directly or as a dynaconf ``cast=`` callable. Only simple formats are
5
+ supported here; plugin-specific nested formats stay in their plugins.
6
+ """
7
+
8
+ import re
9
+ from datetime import datetime, timedelta, timezone
10
+
11
+ __all__ = [
12
+ "parse_timedelta",
13
+ "parse_timestamp_ns",
14
+ "parse_int",
15
+ "parse_bool",
16
+ "parse_delimited_list",
17
+ "parse_key_value",
18
+ ]
19
+
20
+ _DURATION_RE = re.compile(r"^\s*(\d+)\s*([a-zA-Z]+)\s*$")
21
+ _DURATION_UNITS = {
22
+ "s": "seconds",
23
+ "min": "minutes",
24
+ "h": "hours",
25
+ "d": "days",
26
+ "w": "weeks",
27
+ }
28
+
29
+ _TIMESTAMP_UNIT_NS = {"ns": 1, "us": 1_000, "ms": 1_000_000, "s": 1_000_000_000}
30
+
31
+ _TRUE = {"true", "t", "1", "yes", "on"}
32
+ _FALSE = {"false", "f", "0", "no", "off"}
33
+
34
+
35
+ def parse_timedelta(raw) -> timedelta:
36
+ """Parse a duration like ``30s``, ``5min``, ``1h``, ``2d``, ``1w``."""
37
+ if isinstance(raw, timedelta):
38
+ return raw
39
+ match = _DURATION_RE.match(str(raw))
40
+ if not match:
41
+ raise ValueError(f"Invalid duration: {raw!r}")
42
+ magnitude, unit = int(match.group(1)), match.group(2).lower()
43
+ if unit not in _DURATION_UNITS:
44
+ raise ValueError(f"Unknown duration unit {unit!r} in {raw!r}")
45
+ return timedelta(**{_DURATION_UNITS[unit]: magnitude})
46
+
47
+
48
+ def parse_timestamp_ns(value, time_format: str) -> int:
49
+ """Convert a timestamp to integer nanoseconds.
50
+
51
+ Numeric epoch units: ``ns``, ``us``, ``ms``, ``s``.
52
+ ``datetime``: ISO-8601 string or ``datetime`` object; naive values are UTC.
53
+ """
54
+ if time_format in _TIMESTAMP_UNIT_NS:
55
+ unit = _TIMESTAMP_UNIT_NS[time_format]
56
+ if isinstance(value, float):
57
+ return int(value * unit)
58
+ try:
59
+ # exact integer path: no float64 rounding of ns/us epochs
60
+ return int(value) * unit
61
+ except (TypeError, ValueError):
62
+ return int(float(value) * unit)
63
+ if time_format == "datetime":
64
+ if isinstance(value, datetime):
65
+ dt = value
66
+ elif isinstance(value, str):
67
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
68
+ else:
69
+ raise ValueError(
70
+ f"datetime format requires str or datetime, got {type(value)}"
71
+ )
72
+ if dt.tzinfo is None:
73
+ dt = dt.replace(tzinfo=timezone.utc)
74
+ return int(dt.timestamp() * 1_000_000_000)
75
+ raise ValueError(
76
+ f"Unknown time format: {time_format!r}. Supported: ns, us, ms, s, datetime"
77
+ )
78
+
79
+
80
+ def parse_int(raw, *, minimum: int | None = None, maximum: int | None = None) -> int:
81
+ """Parse an integer with optional inclusive bounds."""
82
+ try:
83
+ value = int(str(raw).strip())
84
+ except (TypeError, ValueError):
85
+ raise ValueError(f"Invalid integer: {raw!r}")
86
+ if minimum is not None and value < minimum:
87
+ raise ValueError(f"Value {value} below minimum {minimum}")
88
+ if maximum is not None and value > maximum:
89
+ raise ValueError(f"Value {value} above maximum {maximum}")
90
+ return value
91
+
92
+
93
+ def parse_bool(raw) -> bool:
94
+ """Parse a boolean from common truthy/falsy strings."""
95
+ if isinstance(raw, bool):
96
+ return raw
97
+ text = str(raw).strip().lower()
98
+ if text in _TRUE:
99
+ return True
100
+ if text in _FALSE:
101
+ return False
102
+ raise ValueError(f"Invalid boolean: {raw!r}")
103
+
104
+
105
+ def parse_delimited_list(raw, *, sep: str = " ") -> list[str]:
106
+ """Split a delimited string into a list of non-empty trimmed items."""
107
+ if isinstance(raw, (list, tuple)):
108
+ return [str(item).strip() for item in raw if str(item).strip()]
109
+ return [item.strip() for item in str(raw).split(sep) if item.strip()]
110
+
111
+
112
+ def parse_key_value(
113
+ raw, *, pair_sep: str = " ", kv_sep: str = "="
114
+ ) -> dict[str, str]:
115
+ """Parse a flat ``key=value`` map from a delimited string."""
116
+ if isinstance(raw, dict):
117
+ return {str(key): str(value) for key, value in raw.items()}
118
+ result: dict[str, str] = {}
119
+ for pair in str(raw).split(pair_sep):
120
+ pair = pair.strip()
121
+ if not pair:
122
+ continue
123
+ if kv_sep not in pair:
124
+ raise ValueError(f"Invalid pair {pair!r} (missing {kv_sep!r})")
125
+ key, value = pair.split(kv_sep, 1)
126
+ result[key.strip()] = value.strip()
127
+ return result
@@ -0,0 +1,192 @@
1
+ """Write helpers: LineBuilder construction and resilient writes.
2
+
3
+ The processing engine injects ``LineBuilder`` as a runtime global inside the
4
+ plugin, so it is not importable here. Builder helpers therefore receive the
5
+ ``LineBuilder`` class as their first argument (pass the plugin's global), while
6
+ write helpers operate on already-built line objects and need no class.
7
+ """
8
+
9
+ import math
10
+ import random
11
+ import time
12
+
13
+ from .parsing import parse_bool
14
+
15
+ __all__ = [
16
+ "add_field_with_type",
17
+ "build_line",
18
+ "build_line_typed",
19
+ "write_data",
20
+ "BatchLines",
21
+ ]
22
+
23
+ _INT64_MIN = -(2**63)
24
+ _INT64_MAX = 2**63 - 1
25
+ _UINT64_MAX = 2**64 - 1
26
+
27
+
28
+ class BatchLines:
29
+ """Join multiple LineBuilder outputs into a single write payload."""
30
+
31
+ def __init__(self, line_builders):
32
+ self._line_builders = list(line_builders)
33
+ self._built: str | None = None
34
+
35
+ def build(self) -> str:
36
+ if self._built is None:
37
+ lines = [str(builder.build()) for builder in self._line_builders]
38
+ if not lines:
39
+ raise ValueError("write received no lines to build")
40
+ self._built = "\n".join(lines)
41
+ return self._built
42
+
43
+
44
+ def _infer_type(value) -> str:
45
+ if isinstance(value, bool):
46
+ return "bool"
47
+ if isinstance(value, int):
48
+ return "int"
49
+ if isinstance(value, float):
50
+ return "float"
51
+ return "string"
52
+
53
+
54
+ def add_field_with_type(line, field_key: str, value, field_type: str):
55
+ """Add a field to a LineBuilder with explicit type conversion.
56
+
57
+ Supported types: ``int``, ``uint``, ``float``, ``bool``, ``string``.
58
+ """
59
+ if field_type == "int":
60
+ ival = int(value)
61
+ if not (_INT64_MIN <= ival <= _INT64_MAX):
62
+ raise ValueError(
63
+ f"int field {field_key!r} out of int64 range: {ival}"
64
+ )
65
+ line.int64_field(field_key, ival)
66
+ elif field_type == "uint":
67
+ uval = int(value)
68
+ if not (0 <= uval <= _UINT64_MAX):
69
+ raise ValueError(
70
+ f"uint field {field_key!r} out of uint64 range: {uval}"
71
+ )
72
+ line.uint64_field(field_key, uval)
73
+ elif field_type == "float":
74
+ fval = float(value)
75
+ if not math.isfinite(fval):
76
+ raise ValueError(f"float field {field_key!r} is not finite: {value!r}")
77
+ line.float64_field(field_key, fval)
78
+ elif field_type == "bool":
79
+ # strings are parsed strictly; everything else via Python truthiness
80
+ bval = parse_bool(value) if isinstance(value, str) else bool(value)
81
+ line.bool_field(field_key, bval)
82
+ elif field_type == "string":
83
+ line.string_field(field_key, str(value))
84
+ else:
85
+ raise ValueError(
86
+ f"Unsupported field type {field_type!r} for {field_key!r}"
87
+ )
88
+ return line
89
+
90
+
91
+ def build_line(
92
+ line_builder_cls,
93
+ measurement: str,
94
+ *,
95
+ tags: dict | None = None,
96
+ fields: dict | None = None,
97
+ time_ns: int | None = None,
98
+ ):
99
+ """Build a LineBuilder, inferring each field's type from its value.
100
+
101
+ Variant for the common case where Python types map cleanly to line types.
102
+ Raises ``ValueError`` when no non-``None`` fields remain.
103
+ """
104
+ line = line_builder_cls(measurement)
105
+ for key, value in (tags or {}).items():
106
+ if value is None:
107
+ continue
108
+ line.tag(key, str(value))
109
+ field_count = 0
110
+ for key, value in (fields or {}).items():
111
+ if value is None:
112
+ continue
113
+ add_field_with_type(line, key, value, _infer_type(value))
114
+ field_count += 1
115
+ if not field_count:
116
+ # fail early: a field-less line would poison the whole batched write
117
+ raise ValueError(f"line for {measurement!r} has no fields")
118
+ if time_ns is not None:
119
+ line.time_ns(time_ns)
120
+ return line
121
+
122
+
123
+ def build_line_typed(
124
+ line_builder_cls,
125
+ measurement: str,
126
+ *,
127
+ tags: dict | None = None,
128
+ typed_fields: dict | None = None,
129
+ time_ns: int | None = None,
130
+ ):
131
+ """Build a LineBuilder with explicit field types.
132
+
133
+ Variant for when types must be forced. ``typed_fields`` maps each field to a
134
+ ``(value, type)`` tuple, where ``type`` is one of the
135
+ :func:`add_field_with_type` types. Raises ``ValueError`` when no
136
+ non-``None`` fields remain.
137
+ """
138
+ line = line_builder_cls(measurement)
139
+ for key, value in (tags or {}).items():
140
+ if value is None:
141
+ continue
142
+ line.tag(key, str(value))
143
+ field_count = 0
144
+ for key, (value, field_type) in (typed_fields or {}).items():
145
+ if value is None:
146
+ continue
147
+ add_field_with_type(line, key, value, field_type)
148
+ field_count += 1
149
+ if not field_count:
150
+ raise ValueError(f"line for {measurement!r} has no fields")
151
+ if time_ns is not None:
152
+ line.time_ns(time_ns)
153
+ return line
154
+
155
+
156
+ def write_data(
157
+ influxdb3_local,
158
+ line_builders,
159
+ *,
160
+ batch: bool = True,
161
+ retries: int = 3,
162
+ base_delay: float = 1.0,
163
+ no_sync: bool = True,
164
+ ) -> None:
165
+ """Write LineBuilder objects with optional batching and retry.
166
+
167
+ Args:
168
+ influxdb3_local: InfluxDB client instance.
169
+ line_builders: Iterable of LineBuilder objects (``None`` items skipped).
170
+ batch: Combine all lines into one payload via :class:`BatchLines`.
171
+ Set ``False`` to write each line individually.
172
+ retries: Number of extra attempts on failure (``0`` disables retry).
173
+ base_delay: Base seconds for exponential backoff with jitter.
174
+ no_sync: Passed through to ``write_sync``.
175
+ """
176
+ builders = [builder for builder in line_builders if builder is not None]
177
+ if not builders:
178
+ return
179
+
180
+ payloads = [BatchLines(builders)] if batch else builders
181
+ attempts = max(retries, 0) + 1
182
+
183
+ for payload in payloads:
184
+ for attempt in range(attempts):
185
+ try:
186
+ influxdb3_local.write_sync(payload, no_sync=no_sync)
187
+ break
188
+ except Exception:
189
+ if attempt == attempts - 1:
190
+ raise
191
+ delay = (2**attempt) * base_delay + random.uniform(0, base_delay)
192
+ time.sleep(delay)