bazaar-contrib-config 0.3.0rc1__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.
- bazaar_contrib_config-0.3.0rc1/LICENSE +202 -0
- bazaar_contrib_config-0.3.0rc1/PKG-INFO +29 -0
- bazaar_contrib_config-0.3.0rc1/README.md +17 -0
- bazaar_contrib_config-0.3.0rc1/pyproject.toml +29 -0
- bazaar_contrib_config-0.3.0rc1/pyproject.toml.orig +26 -0
- bazaar_contrib_config-0.3.0rc1/src/bazaar_contrib_config/__init__.py +65 -0
- bazaar_contrib_config-0.3.0rc1/src/bazaar_contrib_config/contracts.py +155 -0
- bazaar_contrib_config-0.3.0rc1/src/bazaar_contrib_config/loading.py +457 -0
- bazaar_contrib_config-0.3.0rc1/src/bazaar_contrib_config/preview.py +105 -0
- bazaar_contrib_config-0.3.0rc1/src/bazaar_contrib_config/py.typed +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bazaar-contrib-config
|
|
3
|
+
Version: 0.3.0rc1
|
|
4
|
+
Summary: The Bazaar Compute Node Config Contrib
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: bazaar-core~=0.3.0rc1
|
|
8
|
+
Requires-Dist: packaging>=26.0
|
|
9
|
+
Requires-Python: >=3.15
|
|
10
|
+
Project-URL: Repository, https://github.com/yuchanns/bazaar-compute-node
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# bazaar-contrib-config
|
|
14
|
+
|
|
15
|
+
The configuration contracts are installed with a carrier. BCN loads the carrier during bootstrap.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bcn contrib add config-filesystem
|
|
19
|
+
bcn --configer config-filesystem --configer-options ./config.toml contrib list
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The corresponding entry in `config.toml`:
|
|
23
|
+
|
|
24
|
+
```toml
|
|
25
|
+
version = "5"
|
|
26
|
+
|
|
27
|
+
[[contribs]]
|
|
28
|
+
package = "bazaar-contrib-config-filesystem~=0.3.0rc1"
|
|
29
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# bazaar-contrib-config
|
|
2
|
+
|
|
3
|
+
The configuration contracts are installed with a carrier. BCN loads the carrier during bootstrap.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bcn contrib add config-filesystem
|
|
7
|
+
bcn --configer config-filesystem --configer-options ./config.toml contrib list
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The corresponding entry in `config.toml`:
|
|
11
|
+
|
|
12
|
+
```toml
|
|
13
|
+
version = "5"
|
|
14
|
+
|
|
15
|
+
[[contribs]]
|
|
16
|
+
package = "bazaar-contrib-config-filesystem~=0.3.0rc1"
|
|
17
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bazaar-contrib-config"
|
|
3
|
+
version = "0.3.0rc1"
|
|
4
|
+
description = "The Bazaar Compute Node Config Contrib"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.15"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"bazaar-core~=0.3.0rc1",
|
|
11
|
+
"packaging>=26.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/yuchanns/bazaar-compute-node"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.bcn.ci]
|
|
22
|
+
skip = true
|
|
23
|
+
|
|
24
|
+
[tool.pyright]
|
|
25
|
+
extends = "../../pyrightconfig.json"
|
|
26
|
+
include = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
extend = "../../pyproject.toml"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bazaar-contrib-config"
|
|
3
|
+
version = "0.3.0rc1"
|
|
4
|
+
description = "The Bazaar Compute Node Config Contrib"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.15"
|
|
9
|
+
dependencies = ["bazaar-core~=0.3.0rc1", "packaging>=26.0"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Repository = "https://github.com/yuchanns/bazaar-compute-node"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.13.0,<0.14.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[tool.bcn.ci]
|
|
19
|
+
skip = true
|
|
20
|
+
|
|
21
|
+
[tool.pyright]
|
|
22
|
+
extends = "../../pyrightconfig.json"
|
|
23
|
+
include = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
extend = "../../pyproject.toml"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Public configuration contracts and carrier-independent loading."""
|
|
2
|
+
|
|
3
|
+
lazy from .contracts import (
|
|
4
|
+
CONFIG_CURRENT,
|
|
5
|
+
CONFIG_PREPARE,
|
|
6
|
+
CONFIG_STORE,
|
|
7
|
+
CONFIG_V1,
|
|
8
|
+
CONFIG_V2,
|
|
9
|
+
CONFIG_V3,
|
|
10
|
+
CONFIG_V4,
|
|
11
|
+
CONFIG_V5,
|
|
12
|
+
ConfigCandidate,
|
|
13
|
+
ConfigCommitUnknown,
|
|
14
|
+
ConfigConflict,
|
|
15
|
+
ConfigConverter,
|
|
16
|
+
ConfigDependencyError,
|
|
17
|
+
ConfigDocument,
|
|
18
|
+
ConfigFormatError,
|
|
19
|
+
ConfigSnapshot,
|
|
20
|
+
ConfigStore,
|
|
21
|
+
ConfigUnavailable,
|
|
22
|
+
ContribPackage,
|
|
23
|
+
EnabledContrib,
|
|
24
|
+
FieldChange,
|
|
25
|
+
PackageRequirement,
|
|
26
|
+
)
|
|
27
|
+
lazy from .loading import (
|
|
28
|
+
CONFIGURATION,
|
|
29
|
+
Configuration,
|
|
30
|
+
ConfigurationApplication,
|
|
31
|
+
ConfigurationEnvironment,
|
|
32
|
+
ConfigurationPreview,
|
|
33
|
+
configuration,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CONFIGURATION",
|
|
38
|
+
"CONFIG_CURRENT",
|
|
39
|
+
"CONFIG_PREPARE",
|
|
40
|
+
"CONFIG_STORE",
|
|
41
|
+
"CONFIG_V1",
|
|
42
|
+
"CONFIG_V2",
|
|
43
|
+
"CONFIG_V3",
|
|
44
|
+
"CONFIG_V4",
|
|
45
|
+
"CONFIG_V5",
|
|
46
|
+
"ConfigCandidate",
|
|
47
|
+
"ConfigCommitUnknown",
|
|
48
|
+
"ConfigConflict",
|
|
49
|
+
"ConfigConverter",
|
|
50
|
+
"ConfigDependencyError",
|
|
51
|
+
"ConfigDocument",
|
|
52
|
+
"ConfigFormatError",
|
|
53
|
+
"ConfigSnapshot",
|
|
54
|
+
"ConfigStore",
|
|
55
|
+
"ConfigUnavailable",
|
|
56
|
+
"Configuration",
|
|
57
|
+
"ConfigurationApplication",
|
|
58
|
+
"ConfigurationEnvironment",
|
|
59
|
+
"ConfigurationPreview",
|
|
60
|
+
"ContribPackage",
|
|
61
|
+
"EnabledContrib",
|
|
62
|
+
"FieldChange",
|
|
63
|
+
"PackageRequirement",
|
|
64
|
+
"configuration",
|
|
65
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Configuration carriers, version converters, and the V5 document boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, Any, Literal, Protocol
|
|
7
|
+
lazy from collections.abc import Mapping
|
|
8
|
+
lazy from typing import Self
|
|
9
|
+
|
|
10
|
+
from bazaar_core import EventKey, ServiceKey
|
|
11
|
+
from pydantic import (
|
|
12
|
+
AnyUrl,
|
|
13
|
+
BaseModel,
|
|
14
|
+
ConfigDict,
|
|
15
|
+
Field,
|
|
16
|
+
field_validator,
|
|
17
|
+
model_validator,
|
|
18
|
+
)
|
|
19
|
+
lazy from packaging.requirements import Requirement
|
|
20
|
+
lazy from packaging.specifiers import SpecifierSet
|
|
21
|
+
lazy from packaging.utils import canonicalize_name
|
|
22
|
+
|
|
23
|
+
CONFIG_PREPARE = EventKey[None]("config.prepare")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigConflict(ValueError):
|
|
27
|
+
"""The source changed since the caller's snapshot."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigUnavailable(OSError):
|
|
31
|
+
"""The source cannot be accessed; its current content is unknown."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigFormatError(ValueError):
|
|
35
|
+
"""A document or candidate does not satisfy its declared format."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigDependencyError(ConfigFormatError):
|
|
39
|
+
"""New missing-service dependencies prevent a configuration commit."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigCommitUnknown(OSError):
|
|
43
|
+
"""A write may have committed; read the source before retrying."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ConfigSnapshot:
|
|
48
|
+
"""A stored document and the revision used for conditional writes."""
|
|
49
|
+
|
|
50
|
+
payload: Mapping[str, object]
|
|
51
|
+
revision: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConfigStore(Protocol):
|
|
55
|
+
async def read(self) -> ConfigSnapshot | None: ...
|
|
56
|
+
|
|
57
|
+
async def write(
|
|
58
|
+
self, payload: Mapping[str, object], *, expected_revision: str | None
|
|
59
|
+
) -> ConfigSnapshot: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FieldChange(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
64
|
+
source: str
|
|
65
|
+
target: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PackageRequirement(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
70
|
+
package: Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$")]
|
|
71
|
+
source: str = "https://pypi.org/simple/"
|
|
72
|
+
version: str = "~=0.3.0rc1"
|
|
73
|
+
|
|
74
|
+
@field_validator("version")
|
|
75
|
+
@classmethod
|
|
76
|
+
def requirement(cls, value: str) -> str:
|
|
77
|
+
SpecifierSet(value)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConfigCandidate(BaseModel):
|
|
82
|
+
"""A converted document, its field changes, and required packages."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
85
|
+
payload: dict[str, Any]
|
|
86
|
+
changes: tuple[FieldChange, ...] = ()
|
|
87
|
+
required_packages: tuple[PackageRequirement, ...] = ()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ConfigConverter(Protocol):
|
|
91
|
+
def convert(self, payload: Mapping[str, object]) -> ConfigCandidate: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EnabledContrib(BaseModel):
|
|
95
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
96
|
+
entry: str = Field(min_length=1)
|
|
97
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ContribPackage(BaseModel):
|
|
101
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
102
|
+
package: str = ""
|
|
103
|
+
source: str = ""
|
|
104
|
+
enabled: tuple[EnabledContrib, ...] = ()
|
|
105
|
+
|
|
106
|
+
@field_validator("package")
|
|
107
|
+
@classmethod
|
|
108
|
+
def dependency(cls, value: str) -> str:
|
|
109
|
+
return str(Requirement(value)) if value else value
|
|
110
|
+
|
|
111
|
+
@field_validator("source")
|
|
112
|
+
@classmethod
|
|
113
|
+
def index(cls, value: str) -> str:
|
|
114
|
+
if value and AnyUrl(value).scheme not in ("http", "https", "file"):
|
|
115
|
+
raise ValueError("package source must be an HTTP(S) or file URL")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
@model_validator(mode="after")
|
|
119
|
+
def declaration(self) -> Self:
|
|
120
|
+
if not self.enabled and not self.package:
|
|
121
|
+
raise ValueError("a contrib entry or package dependency is required")
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def distribution(self) -> str:
|
|
126
|
+
return (
|
|
127
|
+
str(canonicalize_name(Requirement(self.package).name))
|
|
128
|
+
if self.package
|
|
129
|
+
else ""
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def applies(self) -> bool:
|
|
134
|
+
requirement = Requirement(self.package) if self.package else None
|
|
135
|
+
return (
|
|
136
|
+
requirement is None
|
|
137
|
+
or requirement.marker is None
|
|
138
|
+
or requirement.marker.evaluate()
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConfigDocument(BaseModel):
|
|
143
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
144
|
+
version: Literal["5"] = "5"
|
|
145
|
+
contrib_indexes: tuple[str, ...] = ()
|
|
146
|
+
contribs: tuple[ContribPackage, ...] = ()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
CONFIG_STORE = ServiceKey[ConfigStore]("config.store")
|
|
150
|
+
CONFIG_V1 = ServiceKey[ConfigConverter]("config.version.1")
|
|
151
|
+
CONFIG_V2 = ServiceKey[ConfigConverter]("config.version.2")
|
|
152
|
+
CONFIG_V3 = ServiceKey[ConfigConverter]("config.version.3")
|
|
153
|
+
CONFIG_V4 = ServiceKey[ConfigConverter]("config.version.4")
|
|
154
|
+
CONFIG_V5 = ServiceKey[ConfigConverter]("config.version.5")
|
|
155
|
+
CONFIG_CURRENT = CONFIG_V5
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Shared loading, validation, conditional saving, and composition imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
lazy import asyncio
|
|
6
|
+
lazy import importlib
|
|
7
|
+
lazy import json
|
|
8
|
+
lazy import logging
|
|
9
|
+
lazy from collections import Counter
|
|
10
|
+
lazy from collections.abc import Callable, Mapping
|
|
11
|
+
lazy from contextlib import asynccontextmanager
|
|
12
|
+
lazy from copy import deepcopy
|
|
13
|
+
lazy from pathlib import Path
|
|
14
|
+
lazy from tempfile import TemporaryDirectory
|
|
15
|
+
lazy from typing import Any
|
|
16
|
+
|
|
17
|
+
from bazaar_core import ServiceKey
|
|
18
|
+
from pydantic import BaseModel, ConfigDict
|
|
19
|
+
lazy from bazaar_core import (
|
|
20
|
+
COMPOSITION,
|
|
21
|
+
Blocked,
|
|
22
|
+
Candidate,
|
|
23
|
+
Composition,
|
|
24
|
+
Context,
|
|
25
|
+
Contrib,
|
|
26
|
+
ContribEntry,
|
|
27
|
+
ContribHub,
|
|
28
|
+
Stopping,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
lazy from .contracts import (
|
|
32
|
+
CONFIG_CURRENT,
|
|
33
|
+
CONFIG_STORE,
|
|
34
|
+
ConfigCandidate,
|
|
35
|
+
ConfigCommitUnknown,
|
|
36
|
+
ConfigConflict,
|
|
37
|
+
ConfigConverter,
|
|
38
|
+
ConfigDependencyError,
|
|
39
|
+
ConfigDocument,
|
|
40
|
+
ConfigFormatError,
|
|
41
|
+
ConfigSnapshot,
|
|
42
|
+
ConfigStore,
|
|
43
|
+
EnabledContrib,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConfigurationEnvironment(BaseModel):
|
|
48
|
+
"""Installed application factories and the selected bootstrap declarations."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
51
|
+
hub: str
|
|
52
|
+
entries: tuple[EnabledContrib, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigurationPreview(BaseModel):
|
|
56
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
57
|
+
candidate: ConfigCandidate
|
|
58
|
+
revision: str | None
|
|
59
|
+
error: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ConfigurationApplication(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
64
|
+
saved: bool = True
|
|
65
|
+
applied: bool = False
|
|
66
|
+
pending_restart: bool = False
|
|
67
|
+
error: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Configuration:
|
|
71
|
+
"""Read, convert, validate, save, and apply configuration with the selected store."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
ctx: Context,
|
|
76
|
+
store: ConfigStore,
|
|
77
|
+
converter: ConfigConverter,
|
|
78
|
+
defaults: Mapping[str, object],
|
|
79
|
+
build: Callable[[ConfigDocument], Composition],
|
|
80
|
+
*,
|
|
81
|
+
environment: ConfigurationEnvironment | None = None,
|
|
82
|
+
start: Callable[[ConfigDocument], Composition] | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.ctx = ctx
|
|
85
|
+
self.store = store
|
|
86
|
+
self.converter = converter
|
|
87
|
+
self.defaults = deepcopy(dict(defaults))
|
|
88
|
+
self.build = build
|
|
89
|
+
self.environment = environment
|
|
90
|
+
self.start = start
|
|
91
|
+
self.snapshot: ConfigSnapshot | None = None
|
|
92
|
+
self.candidate: ConfigCandidate | None = None
|
|
93
|
+
self.preparation_error: str | None = None
|
|
94
|
+
self._composition: Candidate | None = None
|
|
95
|
+
self._startup: tuple[ContribEntry, ...] = ()
|
|
96
|
+
self._lock = asyncio.Lock()
|
|
97
|
+
|
|
98
|
+
async def read(self) -> tuple[ConfigCandidate, ConfigSnapshot | None]:
|
|
99
|
+
"""Read the selected source and convert its document without opening resources."""
|
|
100
|
+
snapshot = await self.store.read()
|
|
101
|
+
candidate = await asyncio.to_thread(
|
|
102
|
+
self.converter.convert,
|
|
103
|
+
snapshot.payload if snapshot is not None else self.defaults,
|
|
104
|
+
)
|
|
105
|
+
return candidate, snapshot
|
|
106
|
+
|
|
107
|
+
async def _build(self, document: ConfigDocument) -> Composition:
|
|
108
|
+
"""Keep startup roots available while validating or updating the full document."""
|
|
109
|
+
composition = await asyncio.to_thread(self.build, document)
|
|
110
|
+
if self._startup:
|
|
111
|
+
names = {entry.name for entry in composition.entries}
|
|
112
|
+
composition = composition.model_copy(
|
|
113
|
+
update={
|
|
114
|
+
"entries": (
|
|
115
|
+
*composition.entries,
|
|
116
|
+
*(entry for entry in self._startup if entry.name not in names),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
return composition
|
|
121
|
+
|
|
122
|
+
async def prepare(
|
|
123
|
+
self, payload: Mapping[str, object]
|
|
124
|
+
) -> tuple[ConfigCandidate, Candidate]:
|
|
125
|
+
"""Convert the document and validate its composition without starting it."""
|
|
126
|
+
candidate = await asyncio.to_thread(self.converter.convert, payload)
|
|
127
|
+
document = ConfigDocument.model_validate(candidate.payload)
|
|
128
|
+
await asyncio.to_thread(importlib.invalidate_caches)
|
|
129
|
+
composition = await self._build(document)
|
|
130
|
+
return candidate, self.ctx.validate(composition, replacing=self._composition)
|
|
131
|
+
|
|
132
|
+
async def _write(
|
|
133
|
+
self, candidate: ConfigCandidate, expected_revision: str | None
|
|
134
|
+
) -> ConfigSnapshot:
|
|
135
|
+
try:
|
|
136
|
+
return await self.store.write(
|
|
137
|
+
candidate.payload, expected_revision=expected_revision
|
|
138
|
+
)
|
|
139
|
+
except ConfigCommitUnknown as error:
|
|
140
|
+
try:
|
|
141
|
+
snapshot = await self.store.read()
|
|
142
|
+
except (OSError, ValueError) as cause:
|
|
143
|
+
raise error from cause
|
|
144
|
+
if snapshot is not None and snapshot.payload == candidate.payload:
|
|
145
|
+
return snapshot
|
|
146
|
+
raise ConfigConflict(
|
|
147
|
+
"write response was lost; the source now contains another document"
|
|
148
|
+
) from error
|
|
149
|
+
|
|
150
|
+
async def load(self) -> Candidate:
|
|
151
|
+
"""Validate startup input, retaining its snapshot during preparation."""
|
|
152
|
+
snapshot = await self.store.read()
|
|
153
|
+
payload = snapshot.payload if snapshot is not None else self.defaults
|
|
154
|
+
if self.start is not None:
|
|
155
|
+
candidate = await asyncio.to_thread(self.converter.convert, payload)
|
|
156
|
+
selected = await asyncio.to_thread(
|
|
157
|
+
self.start, ConfigDocument.model_validate(candidate.payload)
|
|
158
|
+
)
|
|
159
|
+
composition = self.ctx.validate(selected)
|
|
160
|
+
self._startup = tuple(
|
|
161
|
+
entry
|
|
162
|
+
for entry in selected.entries
|
|
163
|
+
if selected.roots is not None and entry.name in selected.roots
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
candidate, composition = await self.prepare(payload)
|
|
167
|
+
if (
|
|
168
|
+
self.start is not None
|
|
169
|
+
or snapshot is None
|
|
170
|
+
or payload.get("version") != candidate.payload["version"]
|
|
171
|
+
):
|
|
172
|
+
self.check(composition)
|
|
173
|
+
if self.start is None and (
|
|
174
|
+
snapshot is None or payload.get("version") != candidate.payload["version"]
|
|
175
|
+
):
|
|
176
|
+
snapshot = await self._write(
|
|
177
|
+
candidate, snapshot.revision if snapshot else None
|
|
178
|
+
)
|
|
179
|
+
self.snapshot, self.candidate = snapshot, candidate
|
|
180
|
+
self._composition = composition
|
|
181
|
+
return composition
|
|
182
|
+
|
|
183
|
+
async def select(
|
|
184
|
+
self, start: Callable[[ConfigDocument], Composition] | None = None
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Validate the prepared composition and save its converted configuration."""
|
|
187
|
+
async with self._lock:
|
|
188
|
+
candidate, snapshot = await self.read()
|
|
189
|
+
document = ConfigDocument.model_validate(candidate.payload)
|
|
190
|
+
composition = await asyncio.to_thread(start or self.build, document)
|
|
191
|
+
preview = self.ctx.validate(composition, replacing=self._composition)
|
|
192
|
+
save = (
|
|
193
|
+
self.snapshot is None
|
|
194
|
+
or self.snapshot.payload.get("version") != candidate.payload["version"]
|
|
195
|
+
)
|
|
196
|
+
if start is not None:
|
|
197
|
+
self.check(preview)
|
|
198
|
+
if self.preparation_error:
|
|
199
|
+
logging.getLogger(__name__).warning(
|
|
200
|
+
"Configuration preparation failed; continuing with available contribs: %s",
|
|
201
|
+
self.preparation_error,
|
|
202
|
+
)
|
|
203
|
+
if save and self.preparation_error is None:
|
|
204
|
+
whole = self.ctx.validate(
|
|
205
|
+
composition.model_copy(update={"roots": None}),
|
|
206
|
+
replacing=self._composition,
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
self.check(whole)
|
|
210
|
+
except ConfigFormatError as error:
|
|
211
|
+
logging.getLogger(__name__).warning(
|
|
212
|
+
"Keeping the original configuration because some contribs are unavailable: %s",
|
|
213
|
+
error,
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
snapshot = await self._write(
|
|
217
|
+
candidate, self.snapshot.revision if self.snapshot else None
|
|
218
|
+
)
|
|
219
|
+
await self.ctx.update(composition)
|
|
220
|
+
self.snapshot, self.candidate = snapshot, candidate
|
|
221
|
+
self._composition = preview
|
|
222
|
+
self.start = start
|
|
223
|
+
self._startup = tuple(
|
|
224
|
+
entry
|
|
225
|
+
for entry in composition.entries
|
|
226
|
+
if composition.roots is not None and entry.name in composition.roots
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def check(
|
|
231
|
+
composition: Candidate | ContribHub,
|
|
232
|
+
*,
|
|
233
|
+
force: bool = False,
|
|
234
|
+
current: Candidate | None = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
missing = Counter(
|
|
237
|
+
(record.name, record.location, record.state.missing)
|
|
238
|
+
for record in (current.snapshots if current is not None else ())
|
|
239
|
+
if isinstance(record.state, Blocked) and record.state.missing
|
|
240
|
+
)
|
|
241
|
+
errors = []
|
|
242
|
+
dependencies = True
|
|
243
|
+
for record in composition.snapshots:
|
|
244
|
+
state = record.state
|
|
245
|
+
if not isinstance(state, Stopping) or not state.error:
|
|
246
|
+
continue
|
|
247
|
+
if isinstance(state, Blocked) and state.missing:
|
|
248
|
+
key = record.name, record.location, state.missing
|
|
249
|
+
if force:
|
|
250
|
+
continue
|
|
251
|
+
if missing[key]:
|
|
252
|
+
missing[key] -= 1
|
|
253
|
+
continue
|
|
254
|
+
else:
|
|
255
|
+
dependencies = False
|
|
256
|
+
errors.append(f"{record.location}: {record.name}: {state.error}")
|
|
257
|
+
if errors:
|
|
258
|
+
error = ConfigDependencyError if dependencies else ConfigFormatError
|
|
259
|
+
raise error("; ".join(errors))
|
|
260
|
+
|
|
261
|
+
async def commit(
|
|
262
|
+
self,
|
|
263
|
+
payload: Mapping[str, object],
|
|
264
|
+
*,
|
|
265
|
+
expected_revision: str | None,
|
|
266
|
+
force: bool = False,
|
|
267
|
+
) -> ConfigSnapshot:
|
|
268
|
+
"""Validate and save a document; apply() requests its runtime update."""
|
|
269
|
+
candidate, composition = await self.prepare(payload)
|
|
270
|
+
current = await self.store.read()
|
|
271
|
+
baseline = (
|
|
272
|
+
(await self.prepare(current.payload))[1]
|
|
273
|
+
if current is not None and not force
|
|
274
|
+
else None
|
|
275
|
+
)
|
|
276
|
+
self.check(composition, force=force, current=baseline)
|
|
277
|
+
snapshot = await self._write(candidate, expected_revision)
|
|
278
|
+
self.snapshot, self.candidate = snapshot, candidate
|
|
279
|
+
return snapshot
|
|
280
|
+
|
|
281
|
+
async def apply(self, snapshot: ConfigSnapshot) -> ConfigurationApplication:
|
|
282
|
+
"""Request the saved composition through this loader's public Context."""
|
|
283
|
+
if self.start is not None:
|
|
284
|
+
return ConfigurationApplication(pending_restart=True)
|
|
285
|
+
async with self._lock:
|
|
286
|
+
current = await self.store.read()
|
|
287
|
+
if current is None or current.revision != snapshot.revision:
|
|
288
|
+
return ConfigurationApplication(
|
|
289
|
+
pending_restart=True,
|
|
290
|
+
error="configuration changed after saving; read it before applying",
|
|
291
|
+
)
|
|
292
|
+
try:
|
|
293
|
+
await asyncio.to_thread(importlib.invalidate_caches)
|
|
294
|
+
document = ConfigDocument.model_validate(snapshot.payload)
|
|
295
|
+
composition = await self._build(document)
|
|
296
|
+
await self.ctx.update(composition)
|
|
297
|
+
except Exception as error: # noqa: BLE001 - Saving and lifecycle application have separate outcomes.
|
|
298
|
+
return ConfigurationApplication(pending_restart=True, error=str(error))
|
|
299
|
+
return ConfigurationApplication(applied=True)
|
|
300
|
+
|
|
301
|
+
async def activate(self) -> None:
|
|
302
|
+
"""Start the saved full composition within this loader's existing Hub."""
|
|
303
|
+
async with self._lock:
|
|
304
|
+
assert self.candidate is not None
|
|
305
|
+
document = ConfigDocument.model_validate(self.candidate.payload)
|
|
306
|
+
composition = await self._build(document)
|
|
307
|
+
preview = self.ctx.validate(composition, replacing=self._composition)
|
|
308
|
+
if self._startup:
|
|
309
|
+
self.check(
|
|
310
|
+
self.ctx.validate(
|
|
311
|
+
composition.model_copy(
|
|
312
|
+
update={
|
|
313
|
+
"roots": tuple(entry.name for entry in self._startup)
|
|
314
|
+
}
|
|
315
|
+
),
|
|
316
|
+
replacing=self._composition,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
self.check(preview)
|
|
321
|
+
except ConfigFormatError as error:
|
|
322
|
+
logging.getLogger(__name__).warning(
|
|
323
|
+
"Some contribs are unavailable: %s", error
|
|
324
|
+
)
|
|
325
|
+
await self.ctx.update(composition)
|
|
326
|
+
self._composition = preview
|
|
327
|
+
self.start = None
|
|
328
|
+
|
|
329
|
+
async def preview(
|
|
330
|
+
self, python: Path, *, payload: Mapping[str, object] | None = None
|
|
331
|
+
) -> ConfigurationPreview:
|
|
332
|
+
"""Convert and validate with installed target code and the original carrier."""
|
|
333
|
+
if self.environment is None:
|
|
334
|
+
raise ConfigFormatError("target-environment bootstrap is not configured")
|
|
335
|
+
snapshot = await self.store.read()
|
|
336
|
+
request: dict[str, Any] = {
|
|
337
|
+
"environment": self.environment.model_dump(mode="json"),
|
|
338
|
+
"build": f"{self.build.__module__}:{self.build.__qualname__}",
|
|
339
|
+
"defaults": self.defaults,
|
|
340
|
+
"payload": dict(payload)
|
|
341
|
+
if payload is not None
|
|
342
|
+
else dict(snapshot.payload)
|
|
343
|
+
if snapshot is not None
|
|
344
|
+
else self.defaults,
|
|
345
|
+
"revision": snapshot.revision if snapshot is not None else None,
|
|
346
|
+
}
|
|
347
|
+
temporary = await asyncio.to_thread(TemporaryDirectory)
|
|
348
|
+
try:
|
|
349
|
+
path = Path(temporary.name) / "preview.json"
|
|
350
|
+
await asyncio.to_thread(path.write_text, json.dumps(request), "utf-8")
|
|
351
|
+
process = await asyncio.create_subprocess_exec(
|
|
352
|
+
str(python),
|
|
353
|
+
"-m",
|
|
354
|
+
"bazaar_contrib_config.preview",
|
|
355
|
+
str(path),
|
|
356
|
+
stdout=asyncio.subprocess.PIPE,
|
|
357
|
+
stderr=asyncio.subprocess.PIPE,
|
|
358
|
+
)
|
|
359
|
+
try:
|
|
360
|
+
output, errors = await process.communicate()
|
|
361
|
+
except BaseException:
|
|
362
|
+
if process.returncode is None:
|
|
363
|
+
process.terminate()
|
|
364
|
+
await process.wait()
|
|
365
|
+
raise
|
|
366
|
+
if process.returncode:
|
|
367
|
+
raise ConfigFormatError(
|
|
368
|
+
errors.decode("utf-8", errors="replace").strip()
|
|
369
|
+
)
|
|
370
|
+
return ConfigurationPreview.model_validate_json(output)
|
|
371
|
+
finally:
|
|
372
|
+
await asyncio.to_thread(temporary.cleanup)
|
|
373
|
+
|
|
374
|
+
async def commit_preview(self, preview: ConfigurationPreview) -> ConfigSnapshot:
|
|
375
|
+
"""Save the target-validated candidate under the source's original revision."""
|
|
376
|
+
if preview.error:
|
|
377
|
+
raise ConfigFormatError(preview.error)
|
|
378
|
+
snapshot = await self._write(preview.candidate, preview.revision)
|
|
379
|
+
self.snapshot, self.candidate = snapshot, preview.candidate
|
|
380
|
+
return snapshot
|
|
381
|
+
|
|
382
|
+
async def import_defaults(self, payload: Mapping[str, object]) -> ConfigSnapshot:
|
|
383
|
+
"""Import a chosen package's combination, keeping saved parameters and choices."""
|
|
384
|
+
document = ConfigDocument.model_validate(payload)
|
|
385
|
+
snapshot = await self.store.read()
|
|
386
|
+
if snapshot is None:
|
|
387
|
+
return await self.commit(payload, expected_revision=None)
|
|
388
|
+
current = ConfigDocument.model_validate(snapshot.payload)
|
|
389
|
+
packages = list(current.contribs)
|
|
390
|
+
selected = {entry.entry for package in packages for entry in package.enabled}
|
|
391
|
+
defaults = ConfigDocument.model_validate(self.defaults)
|
|
392
|
+
shared = {
|
|
393
|
+
entry.entry for package in defaults.contribs for entry in package.enabled
|
|
394
|
+
}
|
|
395
|
+
for package in document.contribs:
|
|
396
|
+
entries = tuple(
|
|
397
|
+
entry
|
|
398
|
+
for entry in package.enabled
|
|
399
|
+
if entry.entry not in selected and entry.entry not in shared
|
|
400
|
+
)
|
|
401
|
+
if package.enabled and not entries:
|
|
402
|
+
continue
|
|
403
|
+
index = next(
|
|
404
|
+
(
|
|
405
|
+
index
|
|
406
|
+
for index, item in enumerate(packages)
|
|
407
|
+
if package.package and item.distribution == package.distribution
|
|
408
|
+
),
|
|
409
|
+
None,
|
|
410
|
+
)
|
|
411
|
+
if index is None:
|
|
412
|
+
packages.append(package.model_copy(update={"enabled": entries}))
|
|
413
|
+
else:
|
|
414
|
+
packages[index] = packages[index].model_copy(
|
|
415
|
+
update={"enabled": (*packages[index].enabled, *entries)}
|
|
416
|
+
)
|
|
417
|
+
selected.update(entry.entry for entry in entries)
|
|
418
|
+
candidate = current.model_copy(update={"contribs": tuple(packages)})
|
|
419
|
+
return await self.commit(
|
|
420
|
+
candidate.model_dump(mode="json", exclude_none=True),
|
|
421
|
+
expected_revision=snapshot.revision,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
CONFIGURATION = ServiceKey[Configuration]("config.configuration")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def configuration(
|
|
429
|
+
defaults: Mapping[str, object],
|
|
430
|
+
build: Callable[[ConfigDocument], Composition],
|
|
431
|
+
*,
|
|
432
|
+
environment: ConfigurationEnvironment | None = None,
|
|
433
|
+
start: Callable[[ConfigDocument], Composition] | None = None,
|
|
434
|
+
) -> Contrib:
|
|
435
|
+
"""Create a loader offering CONFIGURATION and its validated COMPOSITION."""
|
|
436
|
+
|
|
437
|
+
@asynccontextmanager
|
|
438
|
+
async def lifespan(ctx: Context):
|
|
439
|
+
service = Configuration(
|
|
440
|
+
ctx,
|
|
441
|
+
ctx.accept(CONFIG_STORE),
|
|
442
|
+
ctx.accept(CONFIG_CURRENT),
|
|
443
|
+
defaults,
|
|
444
|
+
build,
|
|
445
|
+
environment=environment,
|
|
446
|
+
start=start,
|
|
447
|
+
)
|
|
448
|
+
candidate = await service.load()
|
|
449
|
+
ctx.offer(CONFIGURATION, service)
|
|
450
|
+
ctx.offer(COMPOSITION, candidate)
|
|
451
|
+
yield
|
|
452
|
+
|
|
453
|
+
return Contrib(
|
|
454
|
+
accepts=[CONFIG_STORE, CONFIG_CURRENT],
|
|
455
|
+
offers=[CONFIGURATION, COMPOSITION],
|
|
456
|
+
lifespan=lifespan,
|
|
457
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Validate a document with target wheels and the selected bootstrap services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
lazy import asyncio
|
|
7
|
+
lazy import importlib
|
|
8
|
+
lazy import json
|
|
9
|
+
lazy import sys
|
|
10
|
+
lazy from collections.abc import Callable
|
|
11
|
+
lazy from pathlib import Path
|
|
12
|
+
lazy from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from bazaar_core import Contrib
|
|
15
|
+
lazy from bazaar_core import (
|
|
16
|
+
Composition,
|
|
17
|
+
Context,
|
|
18
|
+
ContribEntry,
|
|
19
|
+
ContribSpec,
|
|
20
|
+
load,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .loading import CONFIGURATION
|
|
24
|
+
lazy from .contracts import CONFIG_CURRENT, CONFIG_STORE, ConfigConflict, ConfigDocument
|
|
25
|
+
lazy from .loading import Configuration, ConfigurationEnvironment, ConfigurationPreview
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def preview(request: dict[str, Any]) -> ConfigurationPreview:
|
|
29
|
+
environment = ConfigurationEnvironment.model_validate(request["environment"])
|
|
30
|
+
module, name = environment.hub.split(":", 1)
|
|
31
|
+
factory = await asyncio.to_thread(
|
|
32
|
+
lambda: getattr(importlib.import_module(module), name)
|
|
33
|
+
)
|
|
34
|
+
module, name = request["build"].split(":", 1)
|
|
35
|
+
build = cast(
|
|
36
|
+
Callable[[ConfigDocument], Composition],
|
|
37
|
+
await asyncio.to_thread(lambda: getattr(importlib.import_module(module), name)),
|
|
38
|
+
)
|
|
39
|
+
entries = await asyncio.to_thread(
|
|
40
|
+
load,
|
|
41
|
+
[
|
|
42
|
+
ContribSpec(contrib=entry.entry, config=entry.config)
|
|
43
|
+
for entry in environment.entries
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
results: list[ConfigurationPreview] = []
|
|
47
|
+
|
|
48
|
+
@asynccontextmanager
|
|
49
|
+
async def lifespan(ctx: Context):
|
|
50
|
+
store = ctx.accept(CONFIG_STORE)
|
|
51
|
+
snapshot = await store.read()
|
|
52
|
+
if (snapshot.revision if snapshot is not None else None) != request["revision"]:
|
|
53
|
+
raise ConfigConflict("configuration changed during environment preparation")
|
|
54
|
+
converter = ctx.accept(CONFIG_CURRENT)
|
|
55
|
+
candidate = await asyncio.to_thread(converter.convert, request["payload"])
|
|
56
|
+
service = Configuration(ctx, store, converter, request["defaults"], build)
|
|
57
|
+
try:
|
|
58
|
+
_, composition = await service.prepare(candidate.payload)
|
|
59
|
+
baseline = (
|
|
60
|
+
(await service.prepare(snapshot.payload))[1]
|
|
61
|
+
if snapshot is not None
|
|
62
|
+
else None
|
|
63
|
+
)
|
|
64
|
+
service.check(composition, current=baseline)
|
|
65
|
+
error = None
|
|
66
|
+
except Exception as cause: # noqa: BLE001 - Return target plugin diagnostics without saving the candidate.
|
|
67
|
+
error = str(cause)
|
|
68
|
+
result = ConfigurationPreview(
|
|
69
|
+
candidate=candidate, revision=request["revision"], error=error
|
|
70
|
+
)
|
|
71
|
+
results.append(result)
|
|
72
|
+
ctx.offer(CONFIGURATION, service)
|
|
73
|
+
yield
|
|
74
|
+
|
|
75
|
+
async with (
|
|
76
|
+
factory() as hub,
|
|
77
|
+
hub.enable(entries),
|
|
78
|
+
hub.enable(
|
|
79
|
+
Composition(
|
|
80
|
+
entries=[
|
|
81
|
+
ContribEntry(
|
|
82
|
+
name="configuration-preview",
|
|
83
|
+
definition=Contrib(
|
|
84
|
+
accepts=[CONFIG_STORE, CONFIG_CURRENT],
|
|
85
|
+
offers=[CONFIGURATION],
|
|
86
|
+
lifespan=lifespan,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
):
|
|
93
|
+
if not results:
|
|
94
|
+
errors = [str(record.state) for record in hub.snapshots]
|
|
95
|
+
raise RuntimeError("; ".join(errors))
|
|
96
|
+
return results[0]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
request = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
101
|
+
print(asyncio.run(preview(request)).model_dump_json())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|
|
File without changes
|