pyapbase 0.0.10__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.
pyapbase/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Docstring"""
2
+ """Docstring"""
3
+ from .store import DBCORE, DBCRUD, CACHE, OBJSTORE, REDSTORE
4
+ from .crypt import KEY, JWT
5
+ from .auth import VERIFY, CONTEXT
6
+ from .apps import APPS, SAPS, KAFKA, DRAMATIQ
7
+
8
+ from .utils import Logging, ConfigUtil, RelDirs, ClientInfo, APIResponse
9
+
10
+ __all__ = ["DBMS","CAUTH", "APPS", "SAPS", "KAFKA", "DRAMATIQ", "UTILS"]
11
+
12
+ class DBMS:
13
+ # Store
14
+ DBCORE = DBCORE
15
+ DBCRUD = DBCRUD
16
+ CACHE = CACHE
17
+ OBJSTORE = OBJSTORE
18
+ REDSTORE = REDSTORE
19
+
20
+ class CAUTH:
21
+ KEY = KEY
22
+ JWT = JWT
23
+ CONTEXT = CONTEXT
24
+ VERIFY = VERIFY
25
+
26
+ class UTILS:
27
+ Logging = Logging
28
+ ConfigUtil = ConfigUtil
29
+ ClientInfo = ClientInfo
30
+ RelDirs = RelDirs
31
+ APIResponse = APIResponse
@@ -0,0 +1,8 @@
1
+ """Docstring"""
2
+
3
+ from .hubapp import APPS
4
+ from .subapp import SAPS
5
+ from .kafka import KAFKA
6
+ from .runner import DRAMATIQ
7
+
8
+ __all__ = ["APPS", "SAPS", "KAFKA", "DRAMATIQ"]
@@ -0,0 +1,136 @@
1
+ """Docstring"""
2
+ from sqlmodel import MetaData, Session
3
+ from pydantic_settings import BaseSettings
4
+ from fastapi import FastAPI, Request, Depends, WebSocket
5
+ from contextlib import asynccontextmanager, AsyncExitStack
6
+ from typing import Dict, Any, Annotated, Optional, List, Callable, Awaitable
7
+
8
+ __all__ = ["APPS"]
9
+
10
+ class DeployBase(BaseSettings):
11
+ srv_env: str = ".env.server"
12
+ dbc_env: str = ".env.dbapi"
13
+ oau_env: str = ".env.oauth"
14
+ key_env: str = ".env.secret"
15
+ sap_env: str = ".env.sapps"
16
+ env_secrets: Optional[str] = None
17
+
18
+ class ServerBase(BaseSettings):
19
+ host: str = "0.0.0.0"
20
+ port: int = 8000
21
+ reload: bool = False
22
+ workers: int = 1
23
+ https_redir: bool = False
24
+ session_secret: Optional[str] = None
25
+ allowed_hosts: Optional[List[str]] = None
26
+ allowed_origins: Optional[List[str]] = None
27
+ deploy_env: Optional[str] = None
28
+ eager_dispatch: bool = False
29
+ secure_cookie: Optional[bool] = None
30
+ global_cookie: Optional[bool] = None
31
+ cookie_methods: Optional[List[str]] = None
32
+ kongopa_schema: Optional[str] = None
33
+ supvcfg_dir: Optional[str] = None
34
+
35
+ class AppMeta:
36
+ def __init__(self, metas: Dict[str, MetaData]):
37
+ self.metalist = list(metas.values())
38
+ self.schmlist = list(metas.keys())
39
+ self.metadict = metas
40
+
41
+ class AppSpan:
42
+ def __init__(
43
+ self,
44
+ amigs: Optional[List[Any]] = None,
45
+ spans: Optional[Dict[FastAPI, Any]] = None,
46
+ startup: Optional[List[Callable[[], Awaitable[None]]]] = None,
47
+ shutdown: Optional[List[Callable[[], Awaitable[None]]]] = None,
48
+ ):
49
+ self.amig_list = amigs or []
50
+ self.span_dict = spans or {}
51
+ self.startup = startup or []
52
+ self.shutdown = shutdown or []
53
+
54
+ @asynccontextmanager
55
+ async def lifespan(self, app: FastAPI):
56
+ async with AsyncExitStack() as stack:
57
+ # Manage the lifecycle of sub_app
58
+ for subamig in self.amig_list:
59
+ await stack.enter_async_context(subamig())
60
+ for subapp, subspan in self.span_dict.items():
61
+ await stack.enter_async_context(subspan(subapp))
62
+ for fn in self.startup:
63
+ await fn()
64
+ yield
65
+ for fn in self.shutdown:
66
+ await fn()
67
+
68
+ class AppMsvc:
69
+ def __init__(
70
+ self,
71
+ apps: Dict[str, FastAPI],
72
+ lifespan,
73
+ sessdep: Optional[Any] = None,
74
+ deploy_env: Optional[str] = None,
75
+ ):
76
+ depends = [
77
+ Depends(self.session_http(sessdep)),
78
+ Depends(self.session_ws(sessdep))
79
+ ] if sessdep is not None else None
80
+ deploy_env = deploy_env or "production"
81
+ is_production = deploy_env == "production"
82
+ if is_production:
83
+ self.app = FastAPI(
84
+ dependencies= depends,
85
+ redirect_slashes=False,
86
+ lifespan=lifespan,
87
+ docs_url=None,
88
+ redoc_url=None,
89
+ openapi_url=None
90
+ )
91
+ else:
92
+ self.app = FastAPI(
93
+ dependencies= depends,
94
+ redirect_slashes=False,
95
+ lifespan=lifespan
96
+ )
97
+ app_msg = {"message": "Hello Multi Micro Services API!"}
98
+ for key, subapp in apps.items():
99
+ self.app.mount(f"/{key}", subapp)
100
+ app_msg[key] = f"{key.capitalize()} Service at '/{key}'"
101
+
102
+ @self.app.get("/welcome")
103
+ async def welcome():
104
+ return app_msg
105
+
106
+ @self.app.get("/health", tags=["Health"])
107
+ async def health():
108
+ return {"status": "ok"}
109
+
110
+
111
+ @staticmethod
112
+ def session_ws(sessdep):
113
+ def _session(
114
+ websocket: WebSocket,
115
+ sess_pgs: Annotated[Session, Depends(sessdep)]
116
+ ):
117
+ """Injects session into request scope."""
118
+ websocket.scope["db"] = {"pgsql": sess_pgs}
119
+ return _session
120
+
121
+ @staticmethod
122
+ def session_http(sessdep):
123
+ def _session(
124
+ request: Request,
125
+ sess_pgs: Annotated[Session, Depends(sessdep)]
126
+ ):
127
+ """Injects session into request scope."""
128
+ request.scope["db"] = {"pgsql": sess_pgs}
129
+ return _session
130
+
131
+ class APPS:
132
+ AppMeta = AppMeta
133
+ AppSpan = AppSpan
134
+ AppMsvc = AppMsvc
135
+ DeployBase = DeployBase
136
+ ServerBase = ServerBase
pyapbase/apps/kafka.py ADDED
@@ -0,0 +1,166 @@
1
+
2
+ import json, ssl, hashlib, logging, asyncio
3
+ from typing import Dict, Optional, Union, List, Callable
4
+
5
+ from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
6
+ from pydantic_settings import BaseSettings
7
+ from ..store import DBCORE, DBCRUD
8
+
9
+ __all__ = ["GlobalConsumer", ]
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ PgsCore = DBCORE.PgsCore
14
+ OdbMsvc = DBCRUD.OdbMsvc
15
+ DbsMsvc = DBCRUD.DbsMsvc
16
+
17
+ class ConfigBase(BaseSettings):
18
+ kafka_bootstrap_servers: str = "kafka-service:9092"
19
+ security_protocol: str = "PLAINTEXT"
20
+ sasl_plain_username: Optional[str] = None
21
+ sasl_plain_password: Optional[str] = None
22
+ kafka_topic: str = "audit_logs"
23
+ consumer_group: str = "audit_logger"
24
+
25
+ class GlobalConsumer:
26
+ def __init__(
27
+ self,
28
+ config:ConfigBase,
29
+ kafka_topics: Optional[Union[str, List[str]]] = None
30
+ ):
31
+ kafka_topics = kafka_topics or config.kafka_topic
32
+ self.config = config
33
+ self.topics = [kafka_topics] if isinstance(kafka_topics, str) else kafka_topics
34
+ self.handlers: Dict[str, Callable] = {}
35
+ self.default_handler: Optional[Callable] = None
36
+
37
+ # ---------------- DECORATOR API ----------------
38
+
39
+ def register(self, topic: Optional[str] = None):
40
+ def decorator(fn) -> Callable:
41
+ if topic:
42
+ self.handlers[topic] = fn
43
+ else:
44
+ self.default_handler = fn
45
+ return fn
46
+ return decorator
47
+
48
+ # ---------------- EXECUTION ----------------
49
+
50
+ async def _execute(self, handler, data):
51
+ try:
52
+ result = handler(data) # keep simple contract
53
+ if asyncio.iscoroutine(result):
54
+ await result
55
+ except Exception:
56
+ log.exception("Handler execution failed")
57
+
58
+ async def _process(self, msg):
59
+
60
+ if not msg.key or not msg.value:
61
+ return
62
+
63
+ if msg.key != hashlib.sha256(msg.value).hexdigest().encode():
64
+ return
65
+
66
+ handler = self.handlers.get(msg.topic, self.default_handler)
67
+ if not handler:
68
+ return
69
+
70
+ try:
71
+ data = json.loads(msg.value)
72
+ await self._execute(handler, data)
73
+ except Exception:
74
+ log.exception("Message processing failed offset=%s", msg.offset)
75
+
76
+ # ---------------- RUN ----------------
77
+
78
+ async def run(self):
79
+ bootstrap_servers=self.config.kafka_bootstrap_servers
80
+ ssl_context = None
81
+ sasl_mechanism= "PLAIN"
82
+ security_protocol = "PLAINTEXT"
83
+ sasl_plain_username = None
84
+ sasl_plain_password = None
85
+ if self.config.security_protocol == "SASL_SSL":
86
+ ssl_context = ssl.create_default_context()
87
+ security_protocol = self.config.security_protocol
88
+ sasl_plain_username=self.config.sasl_plain_username
89
+ sasl_plain_password=self.config.sasl_plain_password
90
+
91
+ consumer = AIOKafkaConsumer(
92
+ *self.topics,
93
+ bootstrap_servers=bootstrap_servers,
94
+ ssl_context=ssl_context,
95
+ sasl_mechanism=sasl_mechanism,
96
+ security_protocol=security_protocol,
97
+ sasl_plain_username=sasl_plain_username,
98
+ sasl_plain_password=sasl_plain_password,
99
+ group_id=self.config.consumer_group,
100
+ )
101
+
102
+ await consumer.start()
103
+ log.info("Global consumer started")
104
+
105
+ try:
106
+ async for msg in consumer:
107
+ await self._process(msg)
108
+ finally:
109
+ await consumer.stop()
110
+ log.info("Kafka consumer stopped")
111
+
112
+ class GlobalProducer:
113
+ def __init__(self, config:ConfigBase):
114
+ self.config = config
115
+ self.producer: AIOKafkaProducer | None = None
116
+
117
+ async def start(self):
118
+ bootstrap_servers = self.config.kafka_bootstrap_servers
119
+ ssl_context = None
120
+ security_protocol = "PLAINTEXT"
121
+ sasl_mechanism= "PLAIN"
122
+ sasl_plain_username = None
123
+ sasl_plain_password = None
124
+ if self.config.security_protocol == "SASL_SSL":
125
+ ssl_context = ssl.create_default_context()
126
+ security_protocol = self.config.security_protocol
127
+ sasl_plain_username=self.config.sasl_plain_username
128
+ sasl_plain_password=self.config.sasl_plain_password
129
+ try:
130
+ self.producer = AIOKafkaProducer(
131
+ bootstrap_servers=bootstrap_servers,
132
+ ssl_context=ssl_context,
133
+ sasl_mechanism=sasl_mechanism,
134
+ security_protocol=security_protocol,
135
+ sasl_plain_username=sasl_plain_username,
136
+ sasl_plain_password=sasl_plain_password,
137
+ )
138
+ await self.producer.start()
139
+ log.info("Kafka producer started")
140
+ except Exception:
141
+ log.exception("Kafka producer start failed")
142
+ raise RuntimeError("Producer initialization failed")
143
+
144
+ async def stop(self):
145
+ if self.producer:
146
+ await self.producer.stop()
147
+ log.info("Kafka producer stopped")
148
+
149
+ async def send_event(self, event: dict, topic: Optional[str] = None):
150
+ if not self.producer:
151
+ raise Exception("GlobalProducer: producer not started")
152
+ try:
153
+ topic = topic or self.config.kafka_topic
154
+ payload = json.dumps(event).encode("utf-8")
155
+ await self.producer.send_and_wait(
156
+ topic = topic,
157
+ value = payload,
158
+ key = hashlib.sha256(payload).hexdigest().encode("utf-8")
159
+ )
160
+ except Exception:
161
+ raise Exception("GlobalProducer: Kafka send failed")
162
+
163
+ class KAFKA:
164
+ ConfigBase = ConfigBase
165
+ GlobalConsumer = GlobalConsumer
166
+ GlobalProducer = GlobalProducer
@@ -0,0 +1,69 @@
1
+ """
2
+ broker.py
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import platform, sys, logging
8
+ from typing import Optional
9
+ import dramatiq
10
+ import dramatiq.cli as cli
11
+
12
+ __all__ = ["setup_broker"]
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ _broker_configured = False
17
+
18
+ def setup_broker(
19
+ redis_url: Optional[str] = None
20
+ ) -> dramatiq.Broker:
21
+ global _broker_configured
22
+ if _broker_configured:
23
+ return dramatiq.get_broker()
24
+
25
+ if redis_url:
26
+ from dramatiq.brokers.redis import RedisBroker
27
+ broker = RedisBroker(url=redis_url)
28
+ log.info("Dramatiq broker: Redis at %s", redis_url)
29
+ else:
30
+ from dramatiq.brokers.stub import StubBroker
31
+ broker = StubBroker()
32
+ broker.emit_after("process_boot")
33
+ log.info("Dramatiq broker: StubBroker (in-memory, no redis_url)")
34
+
35
+ old_broker = dramatiq.get_broker()
36
+ for actor in old_broker.actors.values():
37
+ actor.broker = broker
38
+ broker.declare_actor(actor)
39
+
40
+ dramatiq.set_broker(broker)
41
+ _broker_configured = True
42
+ return broker
43
+
44
+ class DramatiqRunner:
45
+ """Launches a Dramatiq CLI worker process for a configurable set of actor modules."""
46
+
47
+ def __init__(self, *modules, redis_url: Optional[str] = None,):
48
+ self.modules = []
49
+ for module in modules:
50
+ self.add_module(module)
51
+ setup_broker(redis_url)
52
+
53
+ def add_module(self, module):
54
+ self.modules.append(module.__name__ if hasattr(module, "__name__") else module)
55
+ return self
56
+
57
+ def run(self, concurrency=1, threads=8):
58
+ processes = 1 if platform.system() == "Windows" else concurrency
59
+ argv = [
60
+ *self.modules,
61
+ "--threads", str(threads),
62
+ "--processes", str(processes),
63
+ ]
64
+ args = cli.make_argument_parser().parse_args(argv)
65
+ sys.exit(cli.main(args))
66
+
67
+ class DRAMATIQ:
68
+ DramatiqRunner = DramatiqRunner
69
+ setup_broker = staticmethod(setup_broker)