EmbeddedProto 4.0.0b1__py3-none-any.whl

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.
Files changed (63) hide show
  1. EmbeddedProto/EmbeddedProto.py +122 -0
  2. EmbeddedProto/Features.py +255 -0
  3. EmbeddedProto/Field.py +1295 -0
  4. EmbeddedProto/Oneof.py +74 -0
  5. EmbeddedProto/ProtoFile.py +210 -0
  6. EmbeddedProto/TypeDefinitions.py +367 -0
  7. EmbeddedProto/__init__.py +0 -0
  8. EmbeddedProto/__main__.py +28 -0
  9. EmbeddedProto/config.py +173 -0
  10. EmbeddedProto/custom_header.py +513 -0
  11. EmbeddedProto/embedded_proto_options.proto +62 -0
  12. EmbeddedProto/embedded_proto_options_pb2.py +37 -0
  13. EmbeddedProto/field_options.py +344 -0
  14. EmbeddedProto/main.py +316 -0
  15. EmbeddedProto/src/EmbeddedProto/BytesStringCallback.h +476 -0
  16. EmbeddedProto/src/EmbeddedProto/Defines.h +118 -0
  17. EmbeddedProto/src/EmbeddedProto/EmptyArray.h +81 -0
  18. EmbeddedProto/src/EmbeddedProto/Errors.h +53 -0
  19. EmbeddedProto/src/EmbeddedProto/FieldStringBytes.h +793 -0
  20. EmbeddedProto/src/EmbeddedProto/Fields.h +832 -0
  21. EmbeddedProto/src/EmbeddedProto/Functional.h +412 -0
  22. EmbeddedProto/src/EmbeddedProto/MessageCallback.h +688 -0
  23. EmbeddedProto/src/EmbeddedProto/MessageInterface.h +792 -0
  24. EmbeddedProto/src/EmbeddedProto/MessageSizeCalculator.h +109 -0
  25. EmbeddedProto/src/EmbeddedProto/MessageState.h +207 -0
  26. EmbeddedProto/src/EmbeddedProto/ReadBufferFixedSize.h +204 -0
  27. EmbeddedProto/src/EmbeddedProto/ReadBufferInterface.h +181 -0
  28. EmbeddedProto/src/EmbeddedProto/ReadBufferSection.h +232 -0
  29. EmbeddedProto/src/EmbeddedProto/RepeatedField.h +1053 -0
  30. EmbeddedProto/src/EmbeddedProto/RepeatedFieldCallback.h +432 -0
  31. EmbeddedProto/src/EmbeddedProto/RepeatedFieldFixedSize.h +367 -0
  32. EmbeddedProto/src/EmbeddedProto/Version.h +39 -0
  33. EmbeddedProto/src/EmbeddedProto/WireFormatter.h +852 -0
  34. EmbeddedProto/src/EmbeddedProto/WriteBufferFixedSize.h +114 -0
  35. EmbeddedProto/src/EmbeddedProto/WriteBufferInterface.h +134 -0
  36. EmbeddedProto/src/EmbeddedProto.h +55 -0
  37. EmbeddedProto/templates/FieldBasic_Deserialize.h.jinja2 +26 -0
  38. EmbeddedProto/templates/FieldBasic_GetSet.h.jinja2 +89 -0
  39. EmbeddedProto/templates/FieldBytes_GetSet.h.jinja2 +86 -0
  40. EmbeddedProto/templates/FieldEnum_Deserialize.h.jinja2 +50 -0
  41. EmbeddedProto/templates/FieldEnum_GetSet.h.jinja2 +93 -0
  42. EmbeddedProto/templates/FieldErrorRecursive_GetSet.h.jinja2 +29 -0
  43. EmbeddedProto/templates/FieldMap_GetSet.h.jinja2 +203 -0
  44. EmbeddedProto/templates/FieldMsg_Deserialize.h.jinja2 +44 -0
  45. EmbeddedProto/templates/FieldMsg_GetSet.h.jinja2 +93 -0
  46. EmbeddedProto/templates/FieldRepeated_GetSet.h.jinja2 +94 -0
  47. EmbeddedProto/templates/FieldString_GetSet.h.jinja2 +86 -0
  48. EmbeddedProto/templates/Field_DeserializePartial.h.jinja2 +33 -0
  49. EmbeddedProto/templates/Field_Serialize.h.jinja2 +112 -0
  50. EmbeddedProto/templates/Field_SerializePartial.h.jinja2 +119 -0
  51. EmbeddedProto/templates/Header.h.jinja2 +108 -0
  52. EmbeddedProto/templates/TypeDefEnum.h.jinja2 +45 -0
  53. EmbeddedProto/templates/TypeDefMsg.h.jinja2 +791 -0
  54. EmbeddedProto/templates/TypeOneof.h.jinja2 +178 -0
  55. EmbeddedProto/version.json +3 -0
  56. embeddedproto-4.0.0b1.dist-info/METADATA +141 -0
  57. embeddedproto-4.0.0b1.dist-info/RECORD +63 -0
  58. embeddedproto-4.0.0b1.dist-info/WHEEL +5 -0
  59. embeddedproto-4.0.0b1.dist-info/entry_points.txt +3 -0
  60. embeddedproto-4.0.0b1.dist-info/licenses/LICENSE +30 -0
  61. embeddedproto-4.0.0b1.dist-info/licenses/LICENSES/GPL-3.0-only.txt +674 -0
  62. embeddedproto-4.0.0b1.dist-info/licenses/LICENSES/LicenseRef-EmbeddedProto-Commercial.txt +12 -0
  63. embeddedproto-4.0.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,173 @@
1
+ #
2
+ # Copyright (C) 2020-2026 Embedded AMS B.V. - All Rights Reserved
3
+ #
4
+ # This file is part of Embedded Proto.
5
+ #
6
+ # Embedded Proto is dual licensed. You may use it under the terms of the
7
+ # GNU General Public License version 3 (GPLv3) as published by the Free
8
+ # Software Foundation, or under a commercial license from Embedded AMS B.V.
9
+ #
10
+ # Under the GPLv3 you must release the source code of
11
+ # any product you distribute that includes Embedded Proto or code
12
+ # generated by it. A commercial license removes that obligation.
13
+ # See <https://embeddedproto.com/pricing/>.
14
+ #
15
+ # Embedded Proto is distributed WITHOUT ANY WARRANTY; without even the
16
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17
+ # See the LICENSE file in the root of the repository for details.
18
+ #
19
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-EmbeddedProto-Commercial
20
+ #
21
+ # Embedded AMS B.V., Hoorn, the Netherlands - info at EmbeddedProto dot com
22
+ #
23
+
24
+ """Read and write the EmbeddedProto user config file.
25
+
26
+ A single per-user INI file holds non-secret defaults for the license plugin:
27
+ the optional build token and the license server URL. It lives at
28
+ ``${XDG_CONFIG_HOME:-~/.config}/embeddedproto/config.ini`` (POSIX) or
29
+ ``%APPDATA%\\embeddedproto\\config.ini`` (Windows).
30
+
31
+ The file is optional in every sense: a missing file means "use the built-in
32
+ defaults", and an exported ``EMBEDDEDPROTO_*`` environment variable always wins
33
+ over the file (see ``custom_header.py``). INI is used so the whole thing stays
34
+ in the standard library (``configparser``) with no extra dependency; TOML has no
35
+ standard-library writer and ``tomllib`` only reads on Python 3.11+.
36
+ """
37
+
38
+ import configparser
39
+ import os
40
+ import sys
41
+
42
+ # The default license server. Defined here (the lowest-level module) so both
43
+ # the config template and custom_header.py can reference one source of truth.
44
+ DEFAULT_SERVER_URL = "https://license.embeddedproto.com/v1/header"
45
+
46
+ _SECTION = "license"
47
+
48
+ # The commented template written on first run / when (re)setting values. The two
49
+ # placeholders are filled with the current token and server URL. Keeping the file
50
+ # rendered from this template (rather than via configparser.write) preserves the
51
+ # explanatory comments across updates.
52
+ _TEMPLATE = """\
53
+ [{section}]
54
+ # Your build token. Leave empty to read EMBEDDEDPROTO_BUILD_TOKEN from the
55
+ # environment instead (recommended for CI / shared machines).
56
+ token = {token}
57
+
58
+ # License server endpoint (must be https://). Point at a staging server to test.
59
+ server_url = {server_url}
60
+ """
61
+
62
+
63
+ def _warn(message):
64
+ """Write a hygiene warning to stderr. Never includes the token value."""
65
+ sys.stderr.write("EmbeddedProto license config: " + message + "\n")
66
+
67
+
68
+ def _config_dir():
69
+ """Platform path to the per-user EmbeddedProto config directory."""
70
+ if os.name == "nt":
71
+ base = os.environ.get("APPDATA")
72
+ return os.path.join(base, "embeddedproto") if base else None
73
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(
74
+ os.path.expanduser("~"), ".config")
75
+ return os.path.join(base, "embeddedproto")
76
+
77
+
78
+ def config_path():
79
+ """Full path to ``config.ini`` or None when no config dir can be resolved."""
80
+ directory = _config_dir()
81
+ return os.path.join(directory, "config.ini") if directory else None
82
+
83
+
84
+ def _check_owner_only(path):
85
+ """True if ``path`` is safe to read a secret from (POSIX owner-only).
86
+
87
+ On non-POSIX systems we cannot check reliably, so we allow the read.
88
+ """
89
+ if os.name != "posix":
90
+ return True
91
+ try:
92
+ mode = os.stat(path).st_mode
93
+ except OSError:
94
+ return False
95
+ if mode & 0o077:
96
+ _warn("ignoring inline token in config (readable by group/other; "
97
+ "tighten permissions to 0600): " + path)
98
+ return False
99
+ return True
100
+
101
+
102
+ def _render(token, server_url):
103
+ return _TEMPLATE.format(section=_SECTION, token=token, server_url=server_url)
104
+
105
+
106
+ def _atomic_write(path, text):
107
+ """Write ``text`` via a temp file + rename, owner-only on POSIX."""
108
+ os.makedirs(os.path.dirname(path), exist_ok=True)
109
+ tmp = path + ".tmp"
110
+ with open(tmp, "w", encoding="utf-8") as handle:
111
+ handle.write(text)
112
+ if os.name == "posix":
113
+ os.chmod(tmp, 0o600)
114
+ os.replace(tmp, path)
115
+
116
+
117
+ def load(check_perms=True):
118
+ """Return ``{"token": str|None, "server_url": str|None}`` from the config.
119
+
120
+ A missing/unreadable file yields all-None. The ``server_url`` is non-secret
121
+ and always returned; the inline ``token`` is only returned when the file
122
+ passes the owner-only permission check (unless ``check_perms`` is False, used
123
+ internally to preserve a value while rewriting the file).
124
+ """
125
+ result = {"token": None, "server_url": None}
126
+ path = config_path()
127
+ if not path or not os.path.exists(path):
128
+ return result
129
+ parser = configparser.ConfigParser(interpolation=None)
130
+ try:
131
+ parser.read(path, encoding="utf-8")
132
+ except (configparser.Error, OSError, UnicodeDecodeError):
133
+ return result
134
+ if not parser.has_section(_SECTION):
135
+ return result
136
+ result["server_url"] = parser.get(_SECTION, "server_url", fallback="").strip() or None
137
+ token = parser.get(_SECTION, "token", fallback="").strip()
138
+ if token and (not check_perms or _check_owner_only(path)):
139
+ result["token"] = token
140
+ return result
141
+
142
+
143
+ def ensure_default():
144
+ """Best-effort: write the commented default config if none exists.
145
+
146
+ Used as a zero-touch first-run scaffold by the plugin. Never raises and never
147
+ overwrites an existing file, so it cannot disturb a configured machine or fail
148
+ a build on a read-only home.
149
+ """
150
+ path = config_path()
151
+ if not path or os.path.exists(path):
152
+ return
153
+ try:
154
+ _atomic_write(path, _render("", DEFAULT_SERVER_URL))
155
+ except OSError:
156
+ pass # scaffolding is a convenience, never fatal
157
+
158
+
159
+ def set_values(token=None, server_url=None):
160
+ """Update ``token`` and/or ``server_url`` in the config, preserving the other.
161
+
162
+ Returns the path written. Raises ``RuntimeError`` if no config path can be
163
+ resolved (so the caller can report it).
164
+ """
165
+ path = config_path()
166
+ if not path:
167
+ raise RuntimeError("cannot determine the EmbeddedProto config location")
168
+ current = load(check_perms=False)
169
+ new_token = token if token is not None else (current["token"] or "")
170
+ new_url = (server_url if server_url is not None
171
+ else (current["server_url"] or DEFAULT_SERVER_URL))
172
+ _atomic_write(path, _render(new_token, new_url))
173
+ return path
@@ -0,0 +1,513 @@
1
+ #
2
+ # Copyright (C) 2020-2026 Embedded AMS B.V. - All Rights Reserved
3
+ #
4
+ # This file is part of Embedded Proto.
5
+ #
6
+ # Embedded Proto is dual licensed. You may use it under the terms of the
7
+ # GNU General Public License version 3 (GPLv3) as published by the Free
8
+ # Software Foundation, or under a commercial license from Embedded AMS B.V.
9
+ #
10
+ # Under the GPLv3 you must release the source code of
11
+ # any product you distribute that includes Embedded Proto or code
12
+ # generated by it. A commercial license removes that obligation.
13
+ # See <https://embeddedproto.com/pricing/>.
14
+ #
15
+ # Embedded Proto is distributed WITHOUT ANY WARRANTY; without even the
16
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17
+ # See the LICENSE file in the root of the repository for details.
18
+ #
19
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-EmbeddedProto-Commercial
20
+ #
21
+ # Embedded AMS B.V., Hoorn, the Netherlands - info at EmbeddedProto dot com
22
+ #
23
+
24
+ """Resolve an optional custom header to place at the top of generated files.
25
+
26
+ The header text is fetched from a configurable server for builds that present a
27
+ build token, and cached locally so repeated builds reuse it without a network
28
+ round-trip. Everything in this module is best-effort: a missing token, an
29
+ unreachable server, a slow response, or any malformed reply all resolve to "no
30
+ custom header" and the generator falls back to its default banner. Nothing here
31
+ ever raises into, blocks, or slows a build beyond a single short timeout.
32
+ """
33
+
34
+ import contextlib
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import re
39
+ import socket
40
+ import ssl
41
+ import sys
42
+ import time
43
+ import urllib.error
44
+ import urllib.request
45
+
46
+ try:
47
+ import fcntl # POSIX advisory file locking; absent on Windows.
48
+ except ImportError: # pragma: no cover - exercised only on non-POSIX platforms
49
+ fcntl = None
50
+
51
+ from EmbeddedProto import config
52
+
53
+ # --- Contract constants (see the server-side API contract) --------------------
54
+
55
+ # Re-exported from config (the lowest-level module) so there is a single
56
+ # source of truth for the default endpoint.
57
+ DEFAULT_SERVER_URL = config.DEFAULT_SERVER_URL
58
+ TOKEN_RE = re.compile(r"^[A-Za-z0-9._:-]{16,256}$")
59
+
60
+ _ENV_TOKEN = "EMBEDDEDPROTO_BUILD_TOKEN"
61
+ _ENV_URL = "EMBEDDEDPROTO_LICENSE_URL"
62
+ _ENV_INTERVAL = "EMBEDDEDPROTO_LICENSE_CHECKIN_INTERVAL"
63
+ _ENV_DISABLE = "EMBEDDEDPROTO_LICENSE_DISABLE_CHECKIN"
64
+
65
+ _DEFAULT_INTERVAL = 300 # 5 minutes
66
+ _DEFAULT_GRACE = 86400 # offline-grace default when server omits ttl
67
+ _GRACE_MIN = 300
68
+ _GRACE_MAX = 2592000
69
+ _TIMEOUT = 5 # seconds; single attempt, no retry
70
+ _MAX_HEADER_BYTES = 8192
71
+ _FREE_MARKER = "free-install.json"
72
+
73
+
74
+ # --- Small helpers ------------------------------------------------------------
75
+
76
+ def _warn(message):
77
+ """Write a hygiene warning to stderr. Never includes the token value."""
78
+ sys.stderr.write("EmbeddedProto custom header: " + message + "\n")
79
+
80
+
81
+ def _env_truthy(value):
82
+ return bool(value) and value.strip().lower() in ("1", "true", "yes", "on")
83
+
84
+
85
+ def _cache_dir():
86
+ """Platform path to the per-token cache directory."""
87
+ if os.name == "nt":
88
+ base = os.environ.get("LOCALAPPDATA")
89
+ return os.path.join(base, "embeddedproto", "licenses") if base else None
90
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(
91
+ os.path.expanduser("~"), ".cache")
92
+ return os.path.join(base, "embeddedproto", "licenses")
93
+
94
+
95
+ def _cache_file(token):
96
+ directory = _cache_dir()
97
+ if not directory:
98
+ return None
99
+ digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
100
+ return os.path.join(directory, digest + ".json")
101
+
102
+
103
+ def _checkin_interval():
104
+ raw = os.environ.get(_ENV_INTERVAL, "").strip()
105
+ if not raw:
106
+ return _DEFAULT_INTERVAL
107
+ try:
108
+ return max(0, int(raw))
109
+ except ValueError:
110
+ return _DEFAULT_INTERVAL
111
+
112
+
113
+ def _server_url():
114
+ env_url = os.environ.get(_ENV_URL, "").strip()
115
+ if env_url:
116
+ return env_url
117
+ # check_perms=False: we only need the (non-secret) URL here, so do not run the
118
+ # token permission check (and its warning) for an inline token we won't read.
119
+ return config.load(check_perms=False).get("server_url") or DEFAULT_SERVER_URL
120
+
121
+
122
+ def _atomic_write_json(path, payload):
123
+ """Write JSON via a temp file + rename so a reader never sees a torn file."""
124
+ try:
125
+ os.makedirs(os.path.dirname(path), exist_ok=True)
126
+ tmp = path + ".tmp"
127
+ with open(tmp, "w", encoding="utf-8") as handle:
128
+ json.dump(payload, handle)
129
+ os.replace(tmp, path)
130
+ except OSError:
131
+ pass # caching is an optimisation, never fatal
132
+
133
+
134
+ @contextlib.contextmanager
135
+ def _file_lock(path):
136
+ """Best-effort exclusive lock, used to serialise concurrent check-ins.
137
+
138
+ A single build commonly runs the plugin once per .proto file, in parallel
139
+ (e.g. ``make -j``). Without coordination every one of those processes finds
140
+ an empty cache and checks in, so the per-token throttle is bypassed and the
141
+ server sees one request per file. Holding this lock around the resolve makes
142
+ the first process fetch and cache the header while the rest wait briefly and
143
+ then read the warm cache - collapsing a parallel build to a single check-in.
144
+
145
+ Best-effort and non-fatal: without a path, or if the lock cannot be taken
146
+ (no fcntl on this platform, an OSError, ...), it degrades to no locking
147
+ rather than blocking or failing a build. The lock is released automatically
148
+ if the holder dies, so a crash cannot wedge other builds.
149
+ """
150
+ if not path:
151
+ yield
152
+ return
153
+ handle = None
154
+ try:
155
+ os.makedirs(os.path.dirname(path), exist_ok=True)
156
+ handle = open(path, "a+")
157
+ except OSError:
158
+ yield
159
+ return
160
+ locked = False
161
+ try:
162
+ if fcntl is not None:
163
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
164
+ locked = True
165
+ except OSError:
166
+ pass # proceed unsynchronised rather than fail the build
167
+ try:
168
+ yield
169
+ finally:
170
+ try:
171
+ if locked:
172
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
173
+ except OSError:
174
+ pass
175
+ handle.close()
176
+
177
+
178
+ # --- Token discovery ----------------------------------------------------------
179
+
180
+ def resolve_token():
181
+ """Return a validated build token, or None when none is configured.
182
+
183
+ Priority: the EMBEDDEDPROTO_BUILD_TOKEN environment variable, then the inline
184
+ token in the user config file. A malformed value is treated as absent.
185
+ """
186
+ try:
187
+ token = os.environ.get(_ENV_TOKEN, "").strip()
188
+ if not token:
189
+ token = (config.load().get("token") or "").strip()
190
+ if not token:
191
+ return None
192
+ if not TOKEN_RE.match(token):
193
+ _warn("ignoring malformed build token")
194
+ return None
195
+ return token
196
+ except Exception: # pragma: no cover - defensive: never break a build
197
+ return None
198
+
199
+
200
+ # --- Header sanitisation ------------------------------------------------------
201
+
202
+ def _sanitize_header(text):
203
+ """Apply the contract's header rules; return None if the text is unusable."""
204
+ if not isinstance(text, str):
205
+ return None
206
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
207
+ if len(text.encode("utf-8")) > _MAX_HEADER_BYTES:
208
+ return None
209
+ for char in text:
210
+ if char in ("\n", "\t"):
211
+ continue
212
+ code = ord(char)
213
+ if code < 0x20 or code == 0x7F:
214
+ return None
215
+ return text
216
+
217
+
218
+ def _clamp_grace(ttl_seconds):
219
+ try:
220
+ ttl = int(ttl_seconds)
221
+ except (TypeError, ValueError):
222
+ return _DEFAULT_GRACE
223
+ return max(_GRACE_MIN, min(_GRACE_MAX, ttl))
224
+
225
+
226
+ # --- Cache --------------------------------------------------------------------
227
+
228
+ def _read_cache(path):
229
+ """Return the cache dict or None.
230
+
231
+ Keys: ``header`` (str or None for an attempt-only marker), ``fetched_at``,
232
+ ``expires_at`` and ``last_attempt_at`` (floats). ``last_attempt_at`` records
233
+ the time of the last check-in attempt regardless of its outcome, and is what
234
+ throttles the next attempt.
235
+ """
236
+ try:
237
+ with open(path, "r", encoding="utf-8") as handle:
238
+ data = json.load(handle)
239
+ if not isinstance(data, dict):
240
+ return None
241
+ header = data.get("header")
242
+ if header is not None and not isinstance(header, str):
243
+ header = None
244
+ return {
245
+ "header": header,
246
+ "fetched_at": float(data.get("fetched_at", 0)),
247
+ "expires_at": float(data.get("expires_at", 0)),
248
+ "last_attempt_at": float(data.get("last_attempt_at", 0)),
249
+ }
250
+ except (OSError, ValueError, TypeError):
251
+ return None
252
+
253
+
254
+ def _write_cache(path, header, fetched_at, expires_at, last_attempt_at):
255
+ """Persist the cache dict (best-effort; see _atomic_write_json)."""
256
+ if path:
257
+ _atomic_write_json(path, {
258
+ "header": header,
259
+ "fetched_at": fetched_at,
260
+ "expires_at": expires_at,
261
+ "last_attempt_at": last_attempt_at,
262
+ })
263
+
264
+
265
+ def _usable_header(cached, now):
266
+ """The cached header if still within its offline-grace window, else None."""
267
+ if cached is not None and cached["header"] is not None and now < cached["expires_at"]:
268
+ return cached["header"]
269
+ return None
270
+
271
+
272
+ # --- Network ------------------------------------------------------------------
273
+
274
+ def _post_json(url, payload):
275
+ """POST a JSON body. Return (status, parsed_dict_or_None). Never raises.
276
+
277
+ A status of None means the request did not complete (timeout, DNS, TLS,
278
+ connection error). HTTPS is mandatory; a non-HTTPS URL is refused.
279
+ """
280
+ if not url.lower().startswith("https://"):
281
+ _warn("server URL must use https; skipping request")
282
+ return None, None
283
+ body = json.dumps(payload).encode("utf-8")
284
+ request = urllib.request.Request(
285
+ url, data=body, method="POST",
286
+ headers={"Content-Type": "application/json"})
287
+ context = ssl.create_default_context()
288
+ try:
289
+ with urllib.request.urlopen(request, timeout=_TIMEOUT,
290
+ context=context) as response:
291
+ status = response.getcode()
292
+ raw = response.read()
293
+ except urllib.error.HTTPError as error:
294
+ return error.code, None
295
+ except (urllib.error.URLError, socket.timeout, OSError, ValueError):
296
+ return None, None
297
+ try:
298
+ parsed = json.loads(raw.decode("utf-8"))
299
+ except (ValueError, UnicodeDecodeError):
300
+ return status, None
301
+ return status, parsed if isinstance(parsed, dict) else None
302
+
303
+
304
+ def _payload(token, plugin_version):
305
+ payload = {}
306
+ if token is not None:
307
+ payload["token"] = token
308
+ if plugin_version:
309
+ payload["plugin_version"] = plugin_version
310
+ return payload
311
+
312
+
313
+ # --- Free-install check-in (throttled, opt-out) -------------------------------
314
+
315
+ def _free_marker_path():
316
+ directory = _cache_dir()
317
+ return os.path.join(directory, _FREE_MARKER) if directory else None
318
+
319
+
320
+ def _free_install_due(now):
321
+ """True if no token is present and the throttle window has elapsed.
322
+
323
+ Without a cache directory there is nowhere to throttle, so we decline rather
324
+ than risk a check-in on every build.
325
+ """
326
+ path = _free_marker_path()
327
+ if not path:
328
+ return False
329
+ try:
330
+ with open(path, "r", encoding="utf-8") as handle:
331
+ pinged_at = float(json.load(handle).get("pinged_at", 0))
332
+ except (OSError, ValueError, TypeError):
333
+ pinged_at = 0.0
334
+ return (now - pinged_at) >= _checkin_interval()
335
+
336
+
337
+ def _free_install_checkin(now, plugin_version):
338
+ """Send the throttled, opt-out, best-effort tokenless check-in."""
339
+ if _env_truthy(os.environ.get(_ENV_DISABLE)):
340
+ return
341
+ path = _free_marker_path()
342
+ if path is None:
343
+ return # no cache dir: nowhere to throttle, so decline rather than ping
344
+ # Serialise parallel invocations (as for the token path) so a single build
345
+ # checks in once; re-test the throttle inside the lock so the winner pings
346
+ # and the rest observe the fresh marker.
347
+ with _file_lock(path + ".lock"):
348
+ if not _free_install_due(now):
349
+ return
350
+ _post_json(_server_url(), _payload(None, plugin_version))
351
+ # Record the attempt regardless of outcome so a down server cannot turn
352
+ # the throttle off and cause a check-in on every build.
353
+ _atomic_write_json(path, {"pinged_at": now})
354
+
355
+
356
+ # --- Public entry point -------------------------------------------------------
357
+
358
+ def resolve_custom_header(token, plugin_version=""):
359
+ """Return a sanitized custom-header string, or None to use the default.
360
+
361
+ Never raises. Flow:
362
+ * no token -> throttled free-install check-in, return None
363
+ * within the check-in
364
+ interval -> return the cached header without a network call
365
+ * otherwise -> best-effort check-in; on success use/refresh the
366
+ header, on failure fall back to the cached header
367
+ within its offline-grace window, else None
368
+ """
369
+ try:
370
+ return _resolve(token, plugin_version)
371
+ except Exception: # pragma: no cover - defensive: never break a build
372
+ return None
373
+
374
+
375
+ def check_token(token, url=None, plugin_version=""):
376
+ """Probe the server for ``token`` and return ``(status, parsed_dict_or_None)``.
377
+
378
+ A one-shot POST used by the configuration command to verify a token. The
379
+ HTTPS guard and 5s timeout of ``_post_json`` apply. ``status`` is None when the
380
+ request did not complete (timeout/DNS/TLS). Never raises.
381
+ """
382
+ try:
383
+ return _post_json(url or _server_url(), _payload(token, plugin_version))
384
+ except Exception: # pragma: no cover - defensive
385
+ return None, None
386
+
387
+
388
+ # CLI color helpers (best-effort ANSI; harmless when the terminal ignores them).
389
+ _C_OK = "\033[92m"
390
+ _C_WARN = "\033[93m"
391
+ _C_ERR = "\033[91m"
392
+ _C_END = "\033[0m"
393
+
394
+
395
+ def _describe_check(status, data):
396
+ """Map a check_token result to ``(color, message)`` for display."""
397
+ if status == 200 and isinstance(data, dict) and isinstance(data.get("header"), str):
398
+ size = len(data["header"].encode("utf-8"))
399
+ return _C_OK, "License active - custom header retrieved (%d bytes)." % size
400
+ if status == 200:
401
+ return _C_WARN, "Server returned 200 but no usable header; build uses the default."
402
+ if status == 204:
403
+ return _C_WARN, ("Server reachable, but this token returned no header (unknown or "
404
+ "inactive token). Builds still work with the default header.")
405
+ if status == 400:
406
+ return _C_ERR, "Server rejected the request as malformed."
407
+ if status is None:
408
+ return _C_WARN, ("Could not reach the server (network/TLS/timeout). Settings saved; "
409
+ "builds still work and will retry later.")
410
+ return _C_WARN, "Unexpected server response (status %s)." % status
411
+
412
+
413
+ def configure(token=None, server_url=None, check_only=False, plugin_version=""):
414
+ """Save token/url to the user config and probe the server. Returns an exit code.
415
+
416
+ Shared by ``embeddedproto --set-token`` and ``install.py``. With ``check_only``
417
+ nothing is written and the already-configured token/url is probed. A malformed
418
+ token or a non-https URL is a hard error (returns 1, nothing written). A failed
419
+ connectivity check is reported but returns 0 - a build is never blocked by it.
420
+ """
421
+ if token is not None:
422
+ token = token.strip()
423
+ if not TOKEN_RE.match(token):
424
+ sys.stdout.write(_C_ERR + "Invalid token format; nothing was saved." + _C_END + "\n")
425
+ return 1
426
+ if server_url is not None:
427
+ server_url = server_url.strip()
428
+ if not server_url.lower().startswith("https://"):
429
+ sys.stdout.write(_C_ERR + "Server URL must start with https://; nothing was saved."
430
+ + _C_END + "\n")
431
+ return 1
432
+
433
+ if not check_only and (token is not None or server_url is not None):
434
+ try:
435
+ path = config.set_values(token=token, server_url=server_url)
436
+ sys.stdout.write("Saved license settings to " + path + "\n")
437
+ except (OSError, RuntimeError) as error:
438
+ sys.stdout.write(_C_ERR + "Could not write config: " + str(error) + _C_END + "\n")
439
+ return 1
440
+
441
+ probe_token = token if token is not None else resolve_token()
442
+ if not probe_token:
443
+ sys.stdout.write(_C_WARN + "No token configured; skipping server check." + _C_END + "\n")
444
+ return 0
445
+
446
+ status, data = check_token(probe_token, url=server_url, plugin_version=plugin_version)
447
+ color, message = _describe_check(status, data)
448
+ sys.stdout.write(color + message + _C_END + "\n")
449
+ return 0
450
+
451
+
452
+ def _resolve(token, plugin_version):
453
+ now = time.time()
454
+
455
+ # Zero-touch first run: drop a commented default config so the user can find
456
+ # and edit it. Skipped when a token is supplied via the environment (e.g. CI),
457
+ # and a no-op when a config already exists. Best-effort; never blocks a build.
458
+ if not os.environ.get(_ENV_TOKEN, "").strip():
459
+ config.ensure_default()
460
+
461
+ if token is None:
462
+ _free_install_checkin(now, plugin_version)
463
+ return None
464
+
465
+ cache_path = _cache_file(token)
466
+ if cache_path is None:
467
+ # No cache directory (e.g. no home): nowhere to lock or cache, so fall
468
+ # back to a single unsynchronised best-effort check-in.
469
+ return _checkin(token, None, None, plugin_version, now)
470
+
471
+ # Serialise concurrent invocations (a parallel build generating many .proto
472
+ # files at once) so they collapse to a single check-in: the lock holder
473
+ # fetches and caches the header, the rest wait briefly then read the warm
474
+ # cache. Re-read inside the lock to observe a sibling's fresh write.
475
+ with _file_lock(cache_path + ".lock"):
476
+ now = time.time()
477
+ cached = _read_cache(cache_path)
478
+
479
+ # Throttle the check-in (one network call + one usage signal) by the
480
+ # interval, gated on the last *attempt* (recorded regardless of outcome).
481
+ # This collapses rapid repeats within a single build and stops a down
482
+ # server from stalling every build. The cached header keeps serving in
483
+ # the meantime.
484
+ if cached is not None and (now - cached["last_attempt_at"]) < _checkin_interval():
485
+ return _usable_header(cached, now)
486
+ return _checkin(token, cache_path, cached, plugin_version, now)
487
+
488
+
489
+ def _checkin(token, cache_path, cached, plugin_version, now):
490
+ """Perform one server check-in and return the header (or None).
491
+
492
+ Invoked with the per-token lock held (when a cache directory exists) so only
493
+ one process per build makes the network round-trip. On success the header is
494
+ sanitised and cached. On failure (or no usable header) the cached header
495
+ keeps serving within its offline-grace window and the attempt time is
496
+ recorded, so a down server still throttles the next build; with no usable
497
+ cache a header-less marker is written to throttle the retry the same way.
498
+ """
499
+ status, data = _post_json(_server_url(), _payload(token, plugin_version))
500
+ if status == 200 and data is not None:
501
+ header = _sanitize_header(data.get("header"))
502
+ if header is not None:
503
+ grace = _clamp_grace(data.get("ttl_seconds", _DEFAULT_GRACE))
504
+ _write_cache(cache_path, header, now, now + grace, now)
505
+ return header
506
+
507
+ if cached is not None:
508
+ _write_cache(cache_path, cached["header"], cached["fetched_at"],
509
+ cached["expires_at"], now)
510
+ return _usable_header(cached, now)
511
+
512
+ _write_cache(cache_path, None, 0, 0, now)
513
+ return None