flux-config-shared 0.1.0__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.
@@ -0,0 +1,660 @@
1
+ """User provided configuration models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from dataclasses import asdict, field, fields
9
+ from pathlib import Path
10
+ from time import time
11
+ from typing import Annotated, ClassVar
12
+
13
+ import aiofiles
14
+ import yaml
15
+ from flux_delegate_starter import decode_wif, privkey_to_pubkey
16
+ from pydantic import ConfigDict, EmailStr, Field, TypeAdapter, field_validator
17
+ from pydantic.dataclasses import dataclass as py_dataclass
18
+ from pydantic.networks import HttpUrl
19
+ from pydantic.types import StringConstraints
20
+ from pydantic_core import to_jsonable_python
21
+ from sshpubkeys import InvalidKeyError, SSHKey
22
+
23
+ from flux_config_shared.config_locations import ConfigLocations
24
+ from flux_config_shared.delegate_config import Delegate
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # File write lock registry - prevents concurrent writes to same file
30
+ _file_write_locks: dict[str, asyncio.Lock] = {}
31
+
32
+
33
+ def get_file_lock(path: Path | str) -> asyncio.Lock:
34
+ """Get or create an asyncio.Lock for the given file path.
35
+
36
+ Returns the same lock instance for the same path across all calls,
37
+ ensuring serialized writes to each config file.
38
+
39
+ Args:
40
+ path: The file path to get a lock for (Path or str).
41
+
42
+ Returns:
43
+ asyncio.Lock: The lock for the given path.
44
+ """
45
+ key = str(path)
46
+ if key not in _file_write_locks:
47
+ _file_write_locks[key] = asyncio.Lock()
48
+ return _file_write_locks[key]
49
+
50
+
51
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
52
+ class Identity:
53
+ """FluxNode identity configuration."""
54
+
55
+ flux_id: str = Field(alias="fluxId")
56
+ identity_key: str = Field(alias="identityKey")
57
+ tx_id: str = Field(alias="txId")
58
+ output_id: int = Field(alias="outputId")
59
+
60
+ asdict = asdict
61
+
62
+ @field_validator("flux_id", mode="after")
63
+ @classmethod
64
+ def validate_flux_id(cls, value: str) -> str:
65
+ """Validate FluxID length.
66
+
67
+ Args:
68
+ value: FluxID value
69
+
70
+ Returns:
71
+ Validated FluxID
72
+
73
+ Raises:
74
+ ValueError: If FluxID length is invalid
75
+ """
76
+ id_len = len(value)
77
+
78
+ if id_len > 72 or id_len < 14:
79
+ raise ValueError("FluxId must be between 14 and 72 characters")
80
+
81
+ return value
82
+
83
+ @field_validator("identity_key", mode="after")
84
+ @classmethod
85
+ def validate_identity_key(cls, value: str) -> str:
86
+ """Validate identity key length.
87
+
88
+ Args:
89
+ value: Identity key value
90
+
91
+ Returns:
92
+ Validated identity key
93
+
94
+ Raises:
95
+ ValueError: If identity key length is invalid
96
+ """
97
+ key_len = len(value)
98
+
99
+ if key_len < 51 or key_len > 52:
100
+ raise ValueError("Identity key must be 51 or 52 characters")
101
+
102
+ return value
103
+
104
+ def get_identity_pubkey(self) -> bytes:
105
+ """Get public key from identity WIF key.
106
+
107
+ Returns:
108
+ Public key bytes (33 bytes compressed)
109
+
110
+ Raises:
111
+ ValueError: If identity_key is invalid WIF format
112
+ """
113
+ privkey, compressed = decode_wif(self.identity_key)
114
+ return privkey_to_pubkey(privkey, compressed=compressed)
115
+
116
+ @field_validator("tx_id", mode="after")
117
+ @classmethod
118
+ def validate_txid(cls, value: str) -> str:
119
+ """Validate transaction ID length.
120
+
121
+ Args:
122
+ value: Transaction ID value
123
+
124
+ Returns:
125
+ Validated transaction ID
126
+
127
+ Raises:
128
+ ValueError: If transaction ID length is invalid
129
+ """
130
+ if len(value) != 64:
131
+ raise ValueError("Transaction Id must be 64 characters")
132
+
133
+ return value
134
+
135
+ @field_validator("output_id", mode="before")
136
+ @classmethod
137
+ def validate_output_id(cls, value: str | int) -> int:
138
+ """Validate output ID range.
139
+
140
+ Args:
141
+ value: Output ID value
142
+
143
+ Returns:
144
+ Validated output ID as integer
145
+
146
+ Raises:
147
+ ValueError: If output ID is out of range
148
+ """
149
+ value = int(value)
150
+
151
+ if value < 0 or value > 999:
152
+ raise ValueError("OutputId must be between 0 and 999")
153
+
154
+ return value
155
+
156
+ def to_ui_dict(self) -> dict:
157
+ """Convert to UI dictionary format with camelCase keys.
158
+
159
+ Returns:
160
+ Dictionary with camelCase keys for UI
161
+ """
162
+ return to_jsonable_python(self, by_alias=True)
163
+
164
+
165
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
166
+ class DiscordNotification:
167
+ """Discord notification configuration."""
168
+
169
+ watchdog_property_map: ClassVar[dict] = {
170
+ "webhook_url": "web_hook_url",
171
+ "user_id": "ping",
172
+ }
173
+
174
+ webhook_url: str | None = Field(default=None, alias="discordWebhookUrl")
175
+ user_id: str | None = Field(default=None, alias="discordUserId")
176
+
177
+ @field_validator("webhook_url", mode="after")
178
+ @classmethod
179
+ def validate_webhook_url(cls, value: str | None) -> str | None:
180
+ """Validate Discord webhook URL.
181
+
182
+ Args:
183
+ value: Webhook URL value
184
+
185
+ Returns:
186
+ Validated webhook URL
187
+
188
+ Raises:
189
+ ValueError: If webhook URL is invalid
190
+ """
191
+ if not value:
192
+ return value
193
+
194
+ # this will raise Validation error (and be caught)
195
+ url = HttpUrl(value)
196
+ # discordapp.com is the deprecated endpoint
197
+ valid_hosts = ["discordapp.com", "discord.com"]
198
+
199
+ if url.host not in valid_hosts:
200
+ raise ValueError("Discord webhook url must have discord as the host")
201
+
202
+ if not url.scheme == "https":
203
+ raise ValueError("discord webhook url scheme must be https")
204
+
205
+ if not url.path or not url.path.startswith("/api/webhooks"):
206
+ raise ValueError("discord webhook path must start with /api/webhooks")
207
+
208
+ return value
209
+
210
+ @field_validator("user_id", mode="before")
211
+ @classmethod
212
+ def validate_user_id(cls, value: str | int | None) -> str | None:
213
+ """Validate Discord user ID.
214
+
215
+ Args:
216
+ value: User ID value
217
+
218
+ Returns:
219
+ Validated user ID as string
220
+
221
+ Raises:
222
+ ValueError: If user ID length is invalid
223
+ """
224
+ if not value:
225
+ raise ValueError("user_id cannot be empty")
226
+
227
+ as_str = str(value)
228
+
229
+ len_user_id = len(as_str)
230
+
231
+ if len_user_id < 17 or len_user_id > 19:
232
+ raise ValueError("Discord user id must be between 17 and 19 characters")
233
+
234
+ return as_str
235
+
236
+ @property
237
+ def watchdog_dict(self) -> dict:
238
+ """Get watchdog configuration dictionary.
239
+
240
+ Returns:
241
+ Dictionary with watchdog format
242
+ """
243
+ return {
244
+ self.watchdog_property_map[field.name]: getattr(self, field.name) or "0"
245
+ for field in fields(self)
246
+ }
247
+
248
+ @property
249
+ def ui_dict(self) -> dict:
250
+ """Get UI dictionary with camelCase keys.
251
+
252
+ Returns:
253
+ Dictionary with camelCase keys for UI
254
+ """
255
+ return to_jsonable_python(self, by_alias=True)
256
+
257
+
258
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
259
+ class TelegramNotification:
260
+ """Telegram notification configuration."""
261
+
262
+ watchdog_property_map: ClassVar[dict] = {
263
+ "bot_token": "telegram_bot_token",
264
+ "chat_id": "telegram_chat_id",
265
+ "telegram_alert": "telegram_alert",
266
+ }
267
+
268
+ bot_token: str | None = Field(
269
+ default=None, alias="telegramBotToken", pattern=r"^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$"
270
+ )
271
+ chat_id: str | None = Field(
272
+ default=None, alias="telegramChatId", min_length=6, max_length=1000
273
+ )
274
+
275
+ @property
276
+ def telegram_alert(self) -> str:
277
+ """Check if telegram alerts are enabled.
278
+
279
+ Returns:
280
+ "1" if enabled (both token and chat_id present), "0" otherwise
281
+ """
282
+ return "1" if self.bot_token and self.chat_id else "0"
283
+
284
+ @property
285
+ def watchdog_dict(self) -> dict:
286
+ """Get watchdog configuration dictionary.
287
+
288
+ Returns:
289
+ Dictionary with watchdog format
290
+ """
291
+ # we use the watchdog_property_map so we can include telegram_alert
292
+ return {
293
+ self.watchdog_property_map[key]: getattr(self, key) or "0"
294
+ for key in self.watchdog_property_map
295
+ }
296
+
297
+ @property
298
+ def ui_dict(self) -> dict:
299
+ """Get UI dictionary with camelCase keys.
300
+
301
+ Returns:
302
+ Dictionary with camelCase keys for UI
303
+ """
304
+ return to_jsonable_python(self, by_alias=True)
305
+
306
+
307
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
308
+ class Notifications:
309
+ """Notification configuration for all channels."""
310
+
311
+ discord: DiscordNotification | None = None
312
+ telegram: TelegramNotification | None = None
313
+ email: (
314
+ Annotated[EmailStr, StringConstraints(strip_whitespace=True, to_lower=True)] | None
315
+ ) = Field(default=None, alias="emailAddress")
316
+ webhook: str | None = Field(default=None, alias="genericWebhookUrl")
317
+ node_name: str | None = Field(default=None, alias="nodeName")
318
+
319
+ asdict = asdict
320
+
321
+ @field_validator("webhook", mode="after")
322
+ @classmethod
323
+ def validate_webhook(cls, value: str | None) -> str | None:
324
+ """Validate generic webhook URL.
325
+
326
+ Args:
327
+ value: Webhook URL value
328
+
329
+ Returns:
330
+ Validated webhook URL
331
+
332
+ Raises:
333
+ ValidationError: If webhook URL is invalid
334
+ """
335
+ if not value:
336
+ return value
337
+
338
+ # this will raise ValidationError for us
339
+ HttpUrl(value)
340
+
341
+ return value
342
+
343
+ def to_ui_dict(self) -> dict:
344
+ """Convert to UI dictionary format with camelCase keys and nested structure.
345
+
346
+ Returns:
347
+ Dictionary with camelCase keys for UI, with nested discord and telegram objects
348
+ """
349
+ return to_jsonable_python(self, by_alias=True)
350
+
351
+
352
+ @py_dataclass(config=ConfigDict(populate_by_name=True))
353
+ class Miscellaneous:
354
+ """Miscellaneous configuration settings."""
355
+
356
+ ssh_pubkey: str | None = Field(default=None, alias="sshPubKey")
357
+ debug: bool = False
358
+ development: bool = False
359
+ testnet: bool = False
360
+ blocked_ports: list[int] = Field(default_factory=list, alias="blockedPorts")
361
+ blocked_repositories: list[str] = Field(default_factory=list, alias="blockedRepositories")
362
+
363
+ asdict = asdict
364
+
365
+ @field_validator("blocked_ports", mode="before")
366
+ @classmethod
367
+ def validate_blocked_ports(cls, value: list[int | str]) -> list[int]:
368
+ """Validate blocked ports.
369
+
370
+ Args:
371
+ value: List of port numbers
372
+
373
+ Returns:
374
+ List of validated port numbers as integers
375
+
376
+ Raises:
377
+ ValueError: If port is out of valid range
378
+ """
379
+ as_int = []
380
+
381
+ # Port validation regex: matches 1-65535
382
+ pattern = (
383
+ r"^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|"
384
+ r"65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$"
385
+ )
386
+
387
+ for port in value:
388
+ if not re.search(pattern, str(port)):
389
+ raise ValueError(f"Port: {port} must be in the range 0-65535")
390
+
391
+ as_int.append(int(port))
392
+
393
+ return as_int
394
+
395
+ @field_validator("blocked_repositories", mode="before")
396
+ @classmethod
397
+ def validate_blocked_repositories(cls, value: list) -> list:
398
+ """Validate blocked repositories.
399
+
400
+ Args:
401
+ value: List of repository names
402
+
403
+ Returns:
404
+ List of validated repository names
405
+
406
+ Raises:
407
+ ValueError: If repository format is invalid
408
+ """
409
+ # Docker repository validation regex
410
+ pattern = (
411
+ r"^(?:(?:(?:(?:[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)\/)?\/?(?:(?:(?:[a-z0-9]+"
412
+ r"(?:(?:[._]|__|[-]*)[a-z0-9]+)*)\/){0,2})(?:[a-z0-9-_.]+\/{0,1}[a-z0-9-_.]+)"
413
+ r"[:]?(?:[\w][\w.-]{0,127})?$"
414
+ )
415
+
416
+ for repo in value:
417
+ if not re.search(pattern, repo):
418
+ raise ValueError(f"Repository: {repo} must be a valid format")
419
+
420
+ return value
421
+
422
+ @field_validator("ssh_pubkey", mode="before")
423
+ @classmethod
424
+ def validate_ssh_pubkey(cls, value: str | None) -> str | None:
425
+ """Validate SSH public key.
426
+
427
+ Args:
428
+ value: SSH public key value
429
+
430
+ Returns:
431
+ Validated SSH public key
432
+
433
+ Raises:
434
+ ValueError: If SSH key is invalid
435
+ """
436
+ if not value:
437
+ return value
438
+
439
+ key = SSHKey(value, strict=True)
440
+
441
+ try:
442
+ key.parse()
443
+ except InvalidKeyError as e:
444
+ raise ValueError("A public key in OpenSSH format is required") from e
445
+
446
+ return value
447
+
448
+ def to_ui_dict(self) -> dict:
449
+ """Convert to UI dictionary format with camelCase keys.
450
+
451
+ Returns:
452
+ Dictionary with camelCase keys for UI
453
+ """
454
+ return to_jsonable_python(self, by_alias=True)
455
+
456
+
457
+ @py_dataclass
458
+ class InstallerProvidedConfig:
459
+ """Configuration provided by the installer."""
460
+
461
+ config_path: ClassVar[Path] = ConfigLocations().installer_config
462
+ # system
463
+ hostname: str
464
+ # network
465
+ upnp_enabled: bool
466
+ router_address: str
467
+ upnp_port: int = 16127
468
+ local_chain_sources: list[str] = field(default_factory=list)
469
+ chain_sources_timestamp: float = 0.0
470
+ private_chain_sources: list[str] = field(default_factory=list)
471
+
472
+ @classmethod
473
+ async def from_installer_file(cls) -> InstallerProvidedConfig:
474
+ """Load installer config from YAML file.
475
+
476
+ Returns:
477
+ InstallerProvidedConfig instance
478
+
479
+ Raises:
480
+ FileNotFoundError: If config file doesn't exist
481
+ yaml.YAMLError: If YAML is malformed
482
+ KeyError: If required keys are missing
483
+ """
484
+ async with aiofiles.open(cls.config_path) as f:
485
+ data = await f.read()
486
+
487
+ conf = yaml.safe_load(data)
488
+
489
+ # these will raise if not found when unpacked
490
+ # configs needs to be filtered in case of bad keywords
491
+ system_config = conf.get("system")
492
+ network_config = conf.get("network")
493
+
494
+ return cls(**system_config, **network_config)
495
+
496
+ @property
497
+ def local_chain_sources_stale(self) -> bool:
498
+ """Check if local chain sources are stale (>24h old).
499
+
500
+ Returns:
501
+ True if stale or empty
502
+ """
503
+ return self.chain_sources_timestamp + 86400 < time() or not self.local_chain_sources
504
+
505
+ @property
506
+ def has_chain_sources(self) -> bool:
507
+ """Check if any chain sources are configured.
508
+
509
+ Returns:
510
+ True if local or private chain sources exist
511
+ """
512
+ return any([self.local_chain_sources, self.private_chain_sources])
513
+
514
+ @property
515
+ def fluxos_properties(self) -> dict:
516
+ """Get FluxOS properties dictionary.
517
+
518
+ Returns:
519
+ Dictionary with FluxOS configuration
520
+ """
521
+ return {
522
+ "upnp": self.upnp_enabled,
523
+ "api_port": self.upnp_port,
524
+ "router_ip": self.router_address,
525
+ }
526
+
527
+
528
+ @py_dataclass
529
+ class UserProvidedConfig:
530
+ """User provided configuration for FluxNode."""
531
+
532
+ config_path: ClassVar[Path] = ConfigLocations().user_config
533
+
534
+ identity: Identity
535
+ notifications: Notifications = field(
536
+ default_factory=lambda: Notifications(discord=None, telegram=None)
537
+ )
538
+ miscellaneous: Miscellaneous = field(default_factory=Miscellaneous)
539
+ delegate: Delegate | None = field(default=None)
540
+
541
+ asdict = asdict
542
+
543
+ @classmethod
544
+ def from_dict(cls, params: dict) -> UserProvidedConfig:
545
+ """Create UserProvidedConfig from UI dictionary format.
546
+
547
+ Args:
548
+ params: Dictionary with nested identity, notifications, miscellaneous
549
+
550
+ Returns:
551
+ UserProvidedConfig instance
552
+
553
+ Raises:
554
+ ValidationError: If validation fails (raised by Pydantic)
555
+ """
556
+ return TypeAdapter(cls).validate_python(params)
557
+
558
+ @classmethod
559
+ async def from_config_file(cls) -> UserProvidedConfig | None:
560
+ """Load user config from YAML file.
561
+
562
+ Returns:
563
+ UserProvidedConfig instance or None if file doesn't exist
564
+
565
+ Raises:
566
+ ValidationError: If validation fails
567
+ """
568
+ try:
569
+ async with aiofiles.open(cls.config_path) as f:
570
+ data = await f.read()
571
+ except FileNotFoundError:
572
+ return None
573
+
574
+ try:
575
+ conf: dict = yaml.safe_load(data)
576
+ except yaml.YAMLError as e:
577
+ logger.warning("Error parsing YAML config: %s", e)
578
+ return None
579
+
580
+ # These will raise ValidationError if malformed
581
+ identity = Identity(**conf.get("identity"))
582
+
583
+ notifications_data = conf.get("notifications")
584
+ notifications = Notifications(**notifications_data) if notifications_data else Notifications()
585
+
586
+ miscellaneous_data = conf.get("miscellaneous")
587
+ miscellaneous = Miscellaneous(**miscellaneous_data) if miscellaneous_data else Miscellaneous()
588
+
589
+ delegate_data = conf.get("delegate")
590
+ delegate = Delegate(**delegate_data) if delegate_data else None
591
+
592
+ return cls(identity, notifications, miscellaneous, delegate)
593
+
594
+ @property
595
+ def fluxd_properties(self) -> dict:
596
+ """Get FluxD properties dictionary.
597
+
598
+ Returns:
599
+ Dictionary with FluxD configuration
600
+ """
601
+ identity = self.identity
602
+ return {
603
+ "zelnodeprivkey": identity.identity_key,
604
+ "zelnodeoutpoint": identity.tx_id,
605
+ "zelnodeindex": identity.output_id,
606
+ }
607
+
608
+ @property
609
+ def fluxos_properties(self) -> dict:
610
+ """Get FluxOS properties dictionary.
611
+
612
+ Returns:
613
+ Dictionary with FluxOS configuration
614
+ """
615
+ return {
616
+ "flux_id": self.identity.flux_id,
617
+ "debug": self.miscellaneous.debug,
618
+ "development": self.miscellaneous.development,
619
+ "testnet": self.miscellaneous.testnet,
620
+ "blocked_ports": self.miscellaneous.blocked_ports,
621
+ "blocked_repositories": self.miscellaneous.blocked_repositories,
622
+ }
623
+
624
+ async def to_config_file(self, path: Path | None = None) -> None:
625
+ """Save user config to YAML file.
626
+
627
+ Args:
628
+ path: Optional custom path (defaults to config_path)
629
+ """
630
+ conf = yaml.safe_dump(self.asdict())
631
+
632
+ write_path = path or UserProvidedConfig.config_path
633
+
634
+ lock = get_file_lock(write_path)
635
+ async with lock:
636
+ async with aiofiles.open(write_path, "w") as f:
637
+ await f.write(conf)
638
+
639
+ def purge(self, path: Path | None = None) -> None:
640
+ """Delete the config file.
641
+
642
+ Args:
643
+ path: Optional custom path (defaults to config_path)
644
+ """
645
+ file_path = path or UserProvidedConfig.config_path
646
+
647
+ file_path.unlink(missing_ok=True)
648
+
649
+ def to_ui_dict(self) -> dict:
650
+ """Convert to UI dictionary format with camelCase keys.
651
+
652
+ Returns:
653
+ Dictionary with camelCase keys for UI
654
+ """
655
+ result = {}
656
+ for f in fields(self):
657
+ value = getattr(self, f.name)
658
+ if value is not None:
659
+ result[f.name] = value.to_ui_dict()
660
+ return result
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.3
2
+ Name: flux-config-shared
3
+ Version: 0.1.0
4
+ Summary: Shared protocol and configuration definitions for Flux Config packages
5
+ Author: David White
6
+ Author-email: David White <david@runonflux.io>
7
+ License: GPL-3.0-or-later
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: System Administrators
10
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: System :: Systems Administration
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Dist: pyyaml>=6.0.2,<7
16
+ Requires-Dist: aiofiles>=25.1.0,<26
17
+ Requires-Dist: textual>=6.11.0,<7
18
+ Requires-Dist: pydantic>=2.10.6,<3
19
+ Requires-Dist: sshpubkeys>=3.3.1,<4
20
+ Requires-Dist: email-validator>=2.2.0,<2.3
21
+ Requires-Dist: pyrage>=1.3.0
22
+ Requires-Dist: flux-delegate-starter>=0.1.0
23
+ Requires-Python: >=3.13, <4
24
+ Description-Content-Type: text/markdown
25
+
26
+ # flux-config-shared
27
+
28
+ Shared protocol and configuration definitions for Flux Config packages.
29
+
30
+ This package contains:
31
+ - JSON-RPC protocol definitions
32
+ - Daemon state models
33
+ - User configuration models
34
+ - Application configuration (AppConfig)
35
+ - Delegate configuration
36
+ - Pydantic validation models
37
+
38
+ Used by:
39
+ - flux-configd (daemon)
40
+ - flux-config-tui (TUI client)
@@ -0,0 +1,12 @@
1
+ flux_config_shared/__init__.py,sha256=TmvZd2U3Jf4NX6kSUVIwIWufNqwP-zPX65fLRHn6I4E,432
2
+ flux_config_shared/app_config.py,sha256=UzG2KoAmDOC8A1HaXZ0UHhw5fWvzj4A0o3b7z6-Urpk,2917
3
+ flux_config_shared/config_locations.py,sha256=zyw71Kd8MujcRudRSIVfWaoz6zdUskIYmRB_3WoqsSY,1754
4
+ flux_config_shared/daemon_state.py,sha256=T-1D-jKqLgKAaDmLlF-HUdEfUhS7_6WDGo40UzWG6VA,4183
5
+ flux_config_shared/delegate_config.py,sha256=C1cdgmzTK5HPX13Kz7BIRkeir1CVxGk6_HvRKL4OHVs,3207
6
+ flux_config_shared/log.py,sha256=26ZXyIaYozIi_n8_rEgjN0S489wL5oA2d_Gh-5RdYRQ,3861
7
+ flux_config_shared/models.py,sha256=zfYN2d4CFErTBI4vpIwESnopCTInUz68xGSPswpyIOo,1230
8
+ flux_config_shared/protocol.py,sha256=pKZAuLBqYqpSq9_dqiwMkFJrYMI-SNztSUnTliwZc2U,13381
9
+ flux_config_shared/user_config.py,sha256=l5onT-Ojz2xxErQE4HGfMCqKbFobi2QVaFyHAYTiy74,19202
10
+ flux_config_shared-0.1.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
11
+ flux_config_shared-0.1.0.dist-info/METADATA,sha256=vu_jeGX0Ebn0EBDdpOr6obU6erx0eueD4YszPVScviY,1332
12
+ flux_config_shared-0.1.0.dist-info/RECORD,,