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.
- EmbeddedProto/EmbeddedProto.py +122 -0
- EmbeddedProto/Features.py +255 -0
- EmbeddedProto/Field.py +1295 -0
- EmbeddedProto/Oneof.py +74 -0
- EmbeddedProto/ProtoFile.py +210 -0
- EmbeddedProto/TypeDefinitions.py +367 -0
- EmbeddedProto/__init__.py +0 -0
- EmbeddedProto/__main__.py +28 -0
- EmbeddedProto/config.py +173 -0
- EmbeddedProto/custom_header.py +513 -0
- EmbeddedProto/embedded_proto_options.proto +62 -0
- EmbeddedProto/embedded_proto_options_pb2.py +37 -0
- EmbeddedProto/field_options.py +344 -0
- EmbeddedProto/main.py +316 -0
- EmbeddedProto/src/EmbeddedProto/BytesStringCallback.h +476 -0
- EmbeddedProto/src/EmbeddedProto/Defines.h +118 -0
- EmbeddedProto/src/EmbeddedProto/EmptyArray.h +81 -0
- EmbeddedProto/src/EmbeddedProto/Errors.h +53 -0
- EmbeddedProto/src/EmbeddedProto/FieldStringBytes.h +793 -0
- EmbeddedProto/src/EmbeddedProto/Fields.h +832 -0
- EmbeddedProto/src/EmbeddedProto/Functional.h +412 -0
- EmbeddedProto/src/EmbeddedProto/MessageCallback.h +688 -0
- EmbeddedProto/src/EmbeddedProto/MessageInterface.h +792 -0
- EmbeddedProto/src/EmbeddedProto/MessageSizeCalculator.h +109 -0
- EmbeddedProto/src/EmbeddedProto/MessageState.h +207 -0
- EmbeddedProto/src/EmbeddedProto/ReadBufferFixedSize.h +204 -0
- EmbeddedProto/src/EmbeddedProto/ReadBufferInterface.h +181 -0
- EmbeddedProto/src/EmbeddedProto/ReadBufferSection.h +232 -0
- EmbeddedProto/src/EmbeddedProto/RepeatedField.h +1053 -0
- EmbeddedProto/src/EmbeddedProto/RepeatedFieldCallback.h +432 -0
- EmbeddedProto/src/EmbeddedProto/RepeatedFieldFixedSize.h +367 -0
- EmbeddedProto/src/EmbeddedProto/Version.h +39 -0
- EmbeddedProto/src/EmbeddedProto/WireFormatter.h +852 -0
- EmbeddedProto/src/EmbeddedProto/WriteBufferFixedSize.h +114 -0
- EmbeddedProto/src/EmbeddedProto/WriteBufferInterface.h +134 -0
- EmbeddedProto/src/EmbeddedProto.h +55 -0
- EmbeddedProto/templates/FieldBasic_Deserialize.h.jinja2 +26 -0
- EmbeddedProto/templates/FieldBasic_GetSet.h.jinja2 +89 -0
- EmbeddedProto/templates/FieldBytes_GetSet.h.jinja2 +86 -0
- EmbeddedProto/templates/FieldEnum_Deserialize.h.jinja2 +50 -0
- EmbeddedProto/templates/FieldEnum_GetSet.h.jinja2 +93 -0
- EmbeddedProto/templates/FieldErrorRecursive_GetSet.h.jinja2 +29 -0
- EmbeddedProto/templates/FieldMap_GetSet.h.jinja2 +203 -0
- EmbeddedProto/templates/FieldMsg_Deserialize.h.jinja2 +44 -0
- EmbeddedProto/templates/FieldMsg_GetSet.h.jinja2 +93 -0
- EmbeddedProto/templates/FieldRepeated_GetSet.h.jinja2 +94 -0
- EmbeddedProto/templates/FieldString_GetSet.h.jinja2 +86 -0
- EmbeddedProto/templates/Field_DeserializePartial.h.jinja2 +33 -0
- EmbeddedProto/templates/Field_Serialize.h.jinja2 +112 -0
- EmbeddedProto/templates/Field_SerializePartial.h.jinja2 +119 -0
- EmbeddedProto/templates/Header.h.jinja2 +108 -0
- EmbeddedProto/templates/TypeDefEnum.h.jinja2 +45 -0
- EmbeddedProto/templates/TypeDefMsg.h.jinja2 +791 -0
- EmbeddedProto/templates/TypeOneof.h.jinja2 +178 -0
- EmbeddedProto/version.json +3 -0
- embeddedproto-4.0.0b1.dist-info/METADATA +141 -0
- embeddedproto-4.0.0b1.dist-info/RECORD +63 -0
- embeddedproto-4.0.0b1.dist-info/WHEEL +5 -0
- embeddedproto-4.0.0b1.dist-info/entry_points.txt +3 -0
- embeddedproto-4.0.0b1.dist-info/licenses/LICENSE +30 -0
- embeddedproto-4.0.0b1.dist-info/licenses/LICENSES/GPL-3.0-only.txt +674 -0
- embeddedproto-4.0.0b1.dist-info/licenses/LICENSES/LicenseRef-EmbeddedProto-Commercial.txt +12 -0
- embeddedproto-4.0.0b1.dist-info/top_level.txt +1 -0
EmbeddedProto/config.py
ADDED
|
@@ -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
|