keepup-modules 0.5.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,281 @@
1
+ """What the deployment decided about each plugin, and the surface that shows it.
2
+
3
+ Task keepup-113. The decision belongs to the modules capability: the kernel
4
+ reads it while it composes the runtime -- a plugin an administrator switched off
5
+ is not loaded at all -- and the panel shows it and changes it. The reading is
6
+ what the kernel keeps (through `keepup.plugins.registry`, which asks this
7
+ distribution and falls back to the file when it is not installed); the overrides
8
+ themselves, the endpoints and the trail an administrator's change leaves live
9
+ here.
10
+
11
+ Three sources decide whether a declared plugin runs: its own flag in the
12
+ configuration file, an administrator's override kept in this deployment's
13
+ database, and the environment of this stand. The rule itself is
14
+ ``keepup.plugins.enablement``, which is pure; what lives here is everything
15
+ around it that is not -- reading the file, reading and writing the overrides,
16
+ and the administrative endpoints that report the decision and change it.
17
+
18
+ The endpoints are the reason any of this is worth reporting at all. Once the
19
+ server is up, a plugin that was switched off and a plugin that fell over look
20
+ exactly the same from outside: the routes are absent and the server is
21
+ healthy. The difference is kept by the manager and published here.
22
+ """
23
+
24
+ import asyncio
25
+ import json
26
+ import logging
27
+ import os
28
+ from typing import Any, Dict
29
+
30
+ from fastapi import Depends, HTTPException
31
+
32
+ from keepup import clock
33
+ from keepup_auth import user_roles
34
+ from keepup_auth.dependencies import get_current_admin, get_current_user
35
+ from keepup_audit import trail as admin_trail
36
+ from keepup.plugins import enablement
37
+ from keepup.plugins import route_mask
38
+
39
+ #: What an application may import from this module. Everything else is
40
+ #: internal and may change without notice -- see doc/keepup.md.
41
+ __all__ = [
42
+ "clear_plugin_override",
43
+ "get_plugins",
44
+ "get_plugins_status",
45
+ "read_plugin_overrides",
46
+ "register_plugin_admin_routes",
47
+ "write_plugin_override",
48
+ ]
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ #: Where this deployment declares its plugins. The application names the file
53
+ #: in KeepupSettings.plugins_config_path, and configure() is handed that value
54
+ #: when the application is built; this is what a caller that never does reads.
55
+ MODULES_CONFIG_PATH = 'config/modules.json'
56
+
57
+
58
+ def configure(path: str = None) -> None:
59
+ """Where the administrative endpoints read the declared plugins from.
60
+
61
+ initialize_plugins() is handed the application's own path, and until this
62
+ existed the endpoints around it read the default instead: an application
63
+ that keeps its catalogue anywhere else answered 500 on the panel's plugin
64
+ report, and said so only in the log (keepup-93's integration test, run from
65
+ a directory without config/modules.json, is where it showed).
66
+ """
67
+ global MODULES_CONFIG_PATH
68
+ if path:
69
+ MODULES_CONFIG_PATH = path
70
+
71
+
72
+ def read_plugin_overrides():
73
+ """What an administrator decided from the panel, per plugin (task 65).
74
+
75
+ Never fatal: a database that cannot answer must not keep the server from
76
+ starting. Without the overrides the file and the environment still decide,
77
+ which is the behaviour that existed before the panel could.
78
+ """
79
+ from keepup_db import DatabaseManagerV2
80
+ try:
81
+ rows = DatabaseManagerV2.execute(
82
+ "SELECT plugin_id, enabled FROM plugin_overrides", {})
83
+ except Exception as e:
84
+ logger.warning(f"Could not read plugin overrides, falling back to file and environment: {e}")
85
+ return {}
86
+ return {row["plugin_id"]: bool(row["enabled"]) for row in rows}
87
+
88
+
89
+ def write_plugin_override(plugin_id: str, enabled: bool, changed_by=None) -> None:
90
+ """Record the panel's decision about one plugin, replacing any previous."""
91
+ from keepup_db import DatabaseManagerV2
92
+ updated = DatabaseManagerV2.execute_commit(
93
+ "UPDATE plugin_overrides SET enabled = :enabled, changed_by = :who, "
94
+ "changed_at = :now WHERE plugin_id = :pid",
95
+ {"enabled": enabled, "who": changed_by, "now": clock.utcnow(), "pid": plugin_id})
96
+ if not updated:
97
+ DatabaseManagerV2.execute_commit(
98
+ "INSERT INTO plugin_overrides (plugin_id, enabled, changed_by, changed_at) "
99
+ "VALUES (:pid, :enabled, :who, :now)",
100
+ {"pid": plugin_id, "enabled": enabled, "who": changed_by, "now": clock.utcnow()})
101
+
102
+
103
+ def clear_plugin_override(plugin_id: str) -> None:
104
+ """Give the decision back to the file and the environment."""
105
+ from keepup_db import DatabaseManagerV2
106
+ DatabaseManagerV2.execute_commit(
107
+ "DELETE FROM plugin_overrides WHERE plugin_id = :pid", {"pid": plugin_id})
108
+
109
+
110
+ def _declared_plugin_ids():
111
+ with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
112
+ config = json.load(f)
113
+ return {p["id"] for p in enablement.declared_plugins(config)}
114
+
115
+
116
+ def _refuse_if_the_deployment_decides(plugin_id: str):
117
+ """A plugin named in the environment is not the panel's to switch.
118
+
119
+ Storing an override the environment would overrule is a trap: the
120
+ administrator switches it, restarts, and nothing changes. The refusal
121
+ names the reason instead.
122
+ """
123
+ if plugin_id not in _declared_plugin_ids():
124
+ raise HTTPException(status_code=404, detail="config/modules.json does not declare this plugin")
125
+ if enablement.decided_by_environment(plugin_id, os.environ):
126
+ raise HTTPException(
127
+ status_code=409,
128
+ detail=(f"This plugin is decided by the deployment: "
129
+ f"{enablement.ENABLE_ENV}/{enablement.DISABLE_ENV} name it. "
130
+ f"Change the environment of this stand and restart."))
131
+
132
+
133
+ async def get_plugins(manager, current_user):
134
+ """The plugins available to this user: granted to a role of theirs and running.
135
+
136
+ A module-level function rather than only a closure of the route, because
137
+ this is the decision worth checking and a closure can be reached only
138
+ through the HTTP layer.
139
+
140
+ A user holds a set of roles, and the lists of all of them are glued together
141
+ -- each plugin once, however many of the roles name it (keepup-51).
142
+ """
143
+ try:
144
+ with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
145
+ config = json.load(f)
146
+
147
+ held = set(user_roles.held_by(current_user))
148
+ allowed_plugin_names = set()
149
+ for role in config.get('roles', []):
150
+ if role.get('name') in held:
151
+ allowed_plugin_names.update(role.get('plugins', []))
152
+
153
+ if not allowed_plugin_names:
154
+ return {"plugins": []}
155
+
156
+ # The role's list is visibility, not enablement: a plugin the role is
157
+ # shown but that did not come up has no routes to advertise.
158
+ available_plugins = []
159
+ for plugin_config in config.get('plugins', []):
160
+ if plugin_config.get('id') in allowed_plugin_names \
161
+ and manager.get_plugin(plugin_config.get('id')) is not None:
162
+ available_plugins.append({
163
+ "id": plugin_config.get('id'),
164
+ "name": plugin_config.get('name'),
165
+ "config": plugin_config.get('config', {})
166
+ })
167
+
168
+ return {"plugins": available_plugins}
169
+
170
+ except Exception as e:
171
+ logger.error(f"Error getting plugins: {str(e)}")
172
+ return {"plugins": []}
173
+
174
+
175
+ async def get_plugins_status(manager, current_user=None):
176
+ """The state of every declared plugin.
177
+
178
+ Enabled or not and from where, loaded or not, initialised or not, and why.
179
+ An enabled plugin that did not come up is otherwise visible only as a line
180
+ in the start-up log.
181
+ """
182
+ try:
183
+ with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
184
+ config = json.load(f)
185
+ # Two resolutions: the snapshot taken at start-up is what is actually
186
+ # running, and the one computed now carries the panel's latest
187
+ # decisions. Where they differ, the row awaits a restart.
188
+ desired = enablement.resolve(
189
+ config, os.environ, overrides=await asyncio.to_thread(read_plugin_overrides))
190
+ return {"plugins": enablement.status_report(
191
+ config, manager.resolution, manager,
192
+ desired=desired, environ=os.environ)}
193
+ except Exception as e:
194
+ logger.error(f"Error reading plugin status: {str(e)}")
195
+ raise HTTPException(status_code=500, detail="Internal server error")
196
+
197
+
198
+ async def set_plugin_enabled(plugin_id: str, request: Dict[str, Any], admin: dict):
199
+ """Switch a backend plugin on or off from the panel.
200
+
201
+ Takes effect at the next start: the plugin's routes are built once, at
202
+ start-up, and are not removed from a running application.
203
+ """
204
+ _refuse_if_the_deployment_decides(plugin_id)
205
+ enabled = (request or {}).get("enabled")
206
+ if not isinstance(enabled, bool):
207
+ raise HTTPException(status_code=400, detail="Field 'enabled' must be true or false")
208
+ try:
209
+ await asyncio.to_thread(write_plugin_override, plugin_id, enabled,
210
+ changed_by=admin.get("id"))
211
+ except Exception as e:
212
+ logger.error(f"Could not store the plugin override for {plugin_id}: {e}")
213
+ raise HTTPException(status_code=500, detail="Internal server error")
214
+ await admin_trail.record(admin_trail.PLUGIN_DECISION_CHANGED, admin,
215
+ f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} "
216
+ f"from the panel", plugin_id=plugin_id, enabled=enabled)
217
+ return {"success": True, "plugin_id": plugin_id, "enabled": enabled,
218
+ "pending_restart": True}
219
+
220
+
221
+ async def clear_plugin_enabled(plugin_id: str, admin: dict = None):
222
+ """Give the decision about this plugin back to the file and the environment."""
223
+ _refuse_if_the_deployment_decides(plugin_id)
224
+ try:
225
+ await asyncio.to_thread(clear_plugin_override, plugin_id)
226
+ except Exception as e:
227
+ logger.error(f"Could not clear the plugin override for {plugin_id}: {e}")
228
+ raise HTTPException(status_code=500, detail="Internal server error")
229
+ await admin_trail.record(admin_trail.PLUGIN_DECISION_CHANGED, admin,
230
+ f"Decision about plugin {plugin_id} given back to the file",
231
+ plugin_id=plugin_id, enabled=None)
232
+ return {"success": True, "plugin_id": plugin_id}
233
+
234
+
235
+ def register_plugin_admin_routes(app, manager):
236
+ """Register the plugin endpoints on the application."""
237
+
238
+ @app.get("/api/plugins")
239
+ async def get_plugins_endpoint(current_user: dict = Depends(get_current_user)):
240
+ """Return the plugins available to the current user."""
241
+ return await get_plugins(manager, current_user)
242
+
243
+ @app.get("/api/admin/plugins")
244
+ async def get_plugins_status_endpoint(admin: dict = Depends(get_current_admin)):
245
+ """The state of every declared plugin (administrators only)."""
246
+ return await get_plugins_status(manager, admin)
247
+
248
+ @app.get("/api/admin/route-masks")
249
+ async def get_route_masks_endpoint(admin: dict = Depends(get_current_admin)):
250
+ """The declared masks of every plugin route (administrators only).
251
+
252
+ Machine-readable on purpose: the point of declaring what a route
253
+ accepts is that a wrong request can be refused before the application,
254
+ at a gateway or a proxy, and neither of those can read a signature.
255
+
256
+ Behind the administrator's role because it is a map of the
257
+ application's surface -- not a secret, but not something to hand to
258
+ everybody who signed in either.
259
+ """
260
+ return {"routes": route_mask.describe_routes(manager.get_all_api_routes())}
261
+
262
+ @app.post("/api/admin/plugins/{plugin_id}/enabled")
263
+ async def set_plugin_enabled_endpoint(plugin_id: str, request: Dict[str, Any],
264
+ admin: dict = Depends(get_current_admin)):
265
+ """Switch a backend plugin on or off from the panel (administrators only)."""
266
+ return await set_plugin_enabled(plugin_id, request, admin)
267
+
268
+ @app.delete("/api/admin/plugins/{plugin_id}/enabled")
269
+ async def clear_plugin_enabled_endpoint(plugin_id: str,
270
+ admin: dict = Depends(get_current_admin)):
271
+ """Give the decision about this plugin back to the file (administrators only)."""
272
+ return await clear_plugin_enabled(plugin_id, admin)
273
+
274
+ # There is deliberately no route that calls a named handler by name.
275
+ # get_handlers() is how one plugin calls another inside the process, which
276
+ # is a trusted surface: a handler is written for a caller that has already
277
+ # decided who may do this, so many of them check nothing themselves.
278
+ # Published over HTTP it turned every such handler into an endpoint open to
279
+ # anybody signed in, and the difference between "no such plugin" and "no
280
+ # such handler" listed them for free (task keepup-10). A plugin publishes
281
+ # what it declares in get_api_routes(), and nothing besides.
@@ -0,0 +1,120 @@
1
+ """The catalogue of panel sections as a capability (keepup-113).
2
+
3
+ `modules.py` held the model -- which section exists, who may see it, where its
4
+ files are -- in the kernel's package, together with the ten routes that manage
5
+ it; `schema.py` created its two tables for every deployment, panel or not. The
6
+ capability owns both now:
7
+
8
+ * the model is the `catalogue` service, read on every request for a person's
9
+ roles (through the replica cache, which is why a section's change is published
10
+ to the other replicas);
11
+ * the declaration of a section is *data*: the navigation fields are columns of
12
+ `frontend_modules` (`keepup_modules/tables.py`), and the file the framework
13
+ ships is imported into the database at start instead of being read by the
14
+ panel;
15
+ * the import runs on the freeze (``post_construct``), because it is the union of
16
+ what the running plugins declare, the framework's own `sections.json` and the
17
+ application's `config/modules.json` -- the optional set is only known then
18
+ (doc/plugin_constructor.md, 9.1);
19
+ * the decision about which backend plugins run is this capability's too
20
+ (`plugin_decisions`), because the kernel reads it without a panel.
21
+
22
+ The ten routes are declared in `keepup_modules.catalogue` and registered by the
23
+ application until the transport that mounts what a plugin declares exists
24
+ (`keepup-http`, keepup-121).
25
+ """
26
+
27
+ import asyncio
28
+ import logging
29
+ from typing import Any, Dict, List
30
+
31
+ from keepup.kernel.datasource import SERVICE_DATASOURCE
32
+ from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
33
+ from keepup.plugins.base import BasePlugin
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __all__ = ["ModulesPlugin", "SERVICE_CATALOGUE", "SERVICE_PLUGIN_DECISIONS"]
38
+
39
+ #: What the panel asks for to know which sections a person may see.
40
+ SERVICE_CATALOGUE = "catalogue"
41
+
42
+ #: What the runtime reads to know what an administrator decided about a plugin.
43
+ SERVICE_PLUGIN_DECISIONS = "plugin_decisions"
44
+
45
+
46
+ class ModulesPlugin(BasePlugin):
47
+ """The catalogue of sections, and the decision about the plugins."""
48
+
49
+ descriptor = PluginDescriptor(
50
+ id="modules",
51
+ name="Module management",
52
+ version="0.5.0",
53
+ distribution="keepup-modules",
54
+ kind=KIND_OPTIONAL,
55
+ priority=70,
56
+ provides=(f"{SERVICE_CATALOGUE}>=1", f"{SERVICE_PLUGIN_DECISIONS}>=1"),
57
+ requires=("datasource>=1",),
58
+ contributions=("routes", "sections", "tables"),
59
+ )
60
+
61
+ def __init__(self, config=None):
62
+ super().__init__("modules", "Module management", config)
63
+
64
+ def register(self, services):
65
+ """Publish the catalogue model where there is a database to read it from."""
66
+ if services.has(SERVICE_DATASOURCE):
67
+ from keepup_modules import catalogue, decisions
68
+
69
+ services.provide(SERVICE_CATALOGUE, catalogue, version=1, plugin_id="modules")
70
+ services.provide(SERVICE_PLUGIN_DECISIONS, decisions, version=1,
71
+ plugin_id="modules")
72
+
73
+ async def initialize(self):
74
+ """Nothing to build: the tables are the data source's, the import is the freeze's."""
75
+ return True
76
+
77
+ async def post_construct(self):
78
+ """Import what the sections declare, now that every plugin is known.
79
+
80
+ Idempotent, and the same rule for both files: the fields that are code
81
+ are the declaring plugin's and are re-pointed, while the name, the
82
+ description, `is_active` and the grants are the administrator's and are
83
+ only ever inserted (doc/plugin_constructor.md, 9.1).
84
+ """
85
+ settings = getattr(self.plugin_manager, "settings", None)
86
+ path = getattr(settings, "plugins_config_path", None)
87
+ from keepup_modules.catalogue import (import_declared_sections,
88
+ set_running_plugins,
89
+ sync_framework_sections,
90
+ sync_new_modules_from_json)
91
+
92
+ added = await asyncio.to_thread(sync_framework_sections)
93
+ declared = await asyncio.to_thread(sync_new_modules_from_json, path)
94
+ # What the running plugins declare themselves, each attributed to the
95
+ # plugin that declared it: a section of a plugin that does not run is
96
+ # not in the panel, and the row waits for it to come back (keepup-113).
97
+ plugins = getattr(self.plugin_manager, "loaded_plugins", {}) or {}
98
+ for plugin_id, plugin in sorted(plugins.items()):
99
+ sections = plugin.get_panel_sections() if hasattr(plugin, "get_panel_sections") else []
100
+ if sections:
101
+ await asyncio.to_thread(import_declared_sections,
102
+ {"modules": list(sections)}, plugin_id)
103
+ set_running_plugins(plugins)
104
+ logger.info("Catalogue imported: %s framework section(s), %s declared; %s plugin(s) running",
105
+ added, declared, len(plugins))
106
+
107
+ def get_declared_tables(self) -> List[Any]:
108
+ """The catalogue's tables, declared by the capability that owns them."""
109
+ from keepup_modules.tables import FRONTEND_MODULES, PLUGIN_OVERRIDES, ROLE_MODULES
110
+
111
+ return [FRONTEND_MODULES, ROLE_MODULES, PLUGIN_OVERRIDES]
112
+
113
+ def get_api_routes(self) -> List[Dict[str, Any]]:
114
+ """No routes yet: the application registers the ten through the bridge."""
115
+ return []
116
+
117
+ def get_handlers(self) -> Dict[str, Any]:
118
+ from keepup_modules import catalogue
119
+
120
+ return {"catalogue": catalogue}
@@ -0,0 +1,81 @@
1
+ """The catalogue's tables, declared where the capability lives.
2
+
3
+ Task keepup-113. `frontend_modules` was declared by the panel's distribution
4
+ because the panel drew the navigation from it; the model -- which section exists,
5
+ who may see it, where its files are -- is the catalogue's, so the declaration
6
+ travels with the catalogue. `keepup_db.schema`, which re-exported all three for
7
+ one
8
+ release, which is what a path written before the split still imports.
9
+
10
+ The navigation fields are columns now and not a JSON blob: the collapsed
11
+ navigation is rendered from `icon`, the order from `nav_order`, and
12
+ `declared_by` is what lets a section be filtered by the plugin that declared it
13
+ (doc/plugin_constructor.md, section 9.1).
14
+ """
15
+
16
+ from sqlalchemy import (Boolean, Column, DateTime, Index, Integer, Text,
17
+ UniqueConstraint)
18
+ from sqlalchemy import text as sql_text
19
+
20
+ from keepup_db import tables
21
+
22
+ #: The names the declarations came with, kept beside them.
23
+ MODULES_TABLE = "frontend_modules"
24
+ ROLE_MODULES_TABLE = "role_modules"
25
+ PLUGIN_OVERRIDES_TABLE = "plugin_overrides"
26
+
27
+ __all__ = ["FRONTEND_MODULES", "PLUGIN_OVERRIDES", "ROLE_MODULES"]
28
+
29
+ FRONTEND_MODULES = tables.table(
30
+ MODULES_TABLE,
31
+ tables.auto_id(),
32
+ Column("module_id", Text, unique=True, nullable=False),
33
+ Column("name", Text, nullable=False),
34
+ Column("description", Text),
35
+ Column("js_path", Text),
36
+ Column("css_path", Text),
37
+ Column("init_function", Text),
38
+ # What the catalogue entry says about how the panel shows the section
39
+ # (keepup-113): they lived only in sections.json, so a section added from
40
+ # the panel could not carry a handler, an address or an order.
41
+ Column("icon", Text),
42
+ Column("handler_function", Text),
43
+ Column("route_path", Text),
44
+ Column("section_id", Text),
45
+ Column("admin_only", Boolean, server_default=sql_text("FALSE")),
46
+ Column("nav_order", Integer),
47
+ # Which plugin -- or which file -- declared this section. What makes
48
+ # removal and the read-time filter possible.
49
+ Column("declared_by", Text),
50
+ Column("version", Text, server_default="1.0.0"),
51
+ Column("is_active", Boolean, server_default=sql_text("TRUE")),
52
+ Column("config", Text),
53
+ Column("created_at", DateTime, server_default=tables.NOW),
54
+ Column("updated_at", DateTime, server_default=tables.NOW),
55
+ Index("idx_frontend_modules_active", "is_active"),
56
+ )
57
+
58
+ ROLE_MODULES = tables.table(
59
+ ROLE_MODULES_TABLE,
60
+ tables.auto_id(),
61
+ Column("role_name", Text, nullable=False),
62
+ Column("module_id", Text, nullable=False),
63
+ Column("is_active", Boolean, server_default=sql_text("TRUE")),
64
+ Column("created_at", DateTime, server_default=tables.NOW),
65
+ UniqueConstraint("role_name", "module_id"),
66
+ Index("idx_role_modules_role", "role_name"),
67
+ Index("idx_role_modules_active", "is_active"),
68
+ )
69
+
70
+ #: Which backend plugins an administrator switched on or off, by stand (task 65).
71
+ #: The file `config/modules.json` stays the seed: it is committed and shared
72
+ #: between deployments, so a decision about one stand belongs in that stand's
73
+ #: own database. Read at start-up by `keepup.plugins.enablement`; the
74
+ #: environment still overrules it.
75
+ PLUGIN_OVERRIDES = tables.table(
76
+ PLUGIN_OVERRIDES_TABLE,
77
+ Column("plugin_id", Text, primary_key=True, nullable=True),
78
+ Column("enabled", Boolean, nullable=False),
79
+ Column("changed_by", Integer),
80
+ Column("changed_at", DateTime, server_default=tables.NOW),
81
+ )
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-modules
3
+ Version: 0.5.0
4
+ Summary: The catalogue of panel sections, and the decision about backend plugins.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-users>=0.4.0
10
+ Requires-Dist: keepup-auth>=0.4.0
11
+ Requires-Dist: fastapi<1,>=0.128
12
+ Requires-Dist: pydantic<3,>=2.11
13
+
14
+ # keepup-modules
15
+
16
+ The catalogue of panel sections, and the decision about backend plugins.
17
+
18
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
19
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
20
+ this distribution carries one part of it. It is installed on its own
21
+ (`pip install keepup-modules`) or together with the base (`pip install "keepup-admin[panel]"`),
22
+ and it declares itself to the kernel through its
23
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ keepup_modules/__init__.py,sha256=jP8WvPP5oi07OMscW4NK0PJhbbYmweVH_1v6OV1TBtA,1460
2
+ keepup_modules/catalogue.py,sha256=qKcqD3gqXTFkKXY3tSeREwJvzxMSV_WHByTLPm7jRqg,34909
3
+ keepup_modules/decisions.py,sha256=POGGDt2oPWUVHG94F2zmuKY0CpgS-oSlHSjjUU-XvRs,12996
4
+ keepup_modules/plugin.py,sha256=sGYwEKCOE_F7JGRcyaPaSjfcNKniX8nuN1zkzAuOEbY,5508
5
+ keepup_modules/tables.py,sha256=V0_fT4On5k9XAL7gpTev3P2wS6YAncCHA-ZSQhFZud8,3459
6
+ keepup_modules-0.5.0.dist-info/METADATA,sha256=YC6l66gvnST46J6uEbpCQQAlT0GSCKk5XkQf6w2rZJA,957
7
+ keepup_modules-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ keepup_modules-0.5.0.dist-info/entry_points.txt,sha256=Q9G8GKQgRE_On0zqk_oEpYYrxTSaMWUuX_2nWzeOXKE,56
9
+ keepup_modules-0.5.0.dist-info/top_level.txt,sha256=rVSSTZQLLqP_XNO0I8k-Ymmgc-82B0gkJ64mvgR1iDk,15
10
+ keepup_modules-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [keepup.plugins]
2
+ modules = keepup_modules:ModulesPlugin
@@ -0,0 +1 @@
1
+ keepup_modules