pydantic-rpc-cli 0.11.0__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.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.3
2
+ Name: pydantic-rpc-cli
3
+ Version: 0.11.0
4
+ Summary: CLI tool for pydantic-rpc with server runtime support
5
+ Author: Yasushi Itoh
6
+ Requires-Dist: pydantic-rpc>=0.10.0
7
+ Requires-Dist: hypercorn>=0.17.3
8
+ Requires-Dist: gunicorn>=20.1.0
9
+ Requires-Dist: uvloop>=0.17.0
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pydantic-rpc-cli
14
+
15
+ CLI tool for [pydantic-rpc](https://github.com/yourusername/pydantic-rpc) with built-in server runtime support.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install pydantic-rpc-cli
21
+ ```
22
+
23
+ This will install:
24
+ - `pydantic-rpc` (core library)
25
+ - `hypercorn` (ASGI server)
26
+ - `gunicorn` (WSGI server)
27
+ - `uvloop` (optional asyncio performance improvement)
28
+
29
+ ## Usage
30
+
31
+ ### Generate Protobuf Files
32
+
33
+ ```bash
34
+ # Generate .proto file from a service class
35
+ pydantic-rpc generate myapp.services.UserService --output ./proto/
36
+
37
+ # Also compile to Python code
38
+ pydantic-rpc generate myapp.services.UserService --compile
39
+ ```
40
+
41
+ ### Run Servers
42
+
43
+ #### gRPC Server (default)
44
+ ```bash
45
+ # Start a gRPC server
46
+ pydantic-rpc serve myapp.services.UserService --port 50051
47
+
48
+ # The CLI automatically detects if your service has async methods
49
+ # and uses AsyncIOServer or Server accordingly
50
+ ```
51
+
52
+ #### Connect RPC via ASGI (HTTP/2)
53
+ ```bash
54
+ # Run with Hypercorn (built-in)
55
+ pydantic-rpc serve myapp.services.UserService --asgi --port 8000
56
+
57
+ # Service will be available at:
58
+ # http://localhost:8000/UserService/
59
+ ```
60
+
61
+ #### Connect RPC via WSGI (HTTP/1.1)
62
+ ```bash
63
+ # Run with Gunicorn (built-in)
64
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000
65
+
66
+ # With multiple workers
67
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
68
+
69
+ # Service will be available at:
70
+ # http://localhost:8000/UserService/
71
+ ```
72
+
73
+ ## Example Service
74
+
75
+ ```python
76
+ # myapp/services.py
77
+ from pydantic_rpc import Message, AsyncIOServer
78
+
79
+ class HelloRequest(Message):
80
+ name: str
81
+
82
+ class HelloResponse(Message):
83
+ message: str
84
+
85
+ class GreeterService:
86
+ async def say_hello(self, request: HelloRequest) -> HelloResponse:
87
+ return HelloResponse(message=f"Hello, {request.name}!")
88
+ ```
89
+
90
+ Run it:
91
+ ```bash
92
+ # As gRPC
93
+ pydantic-rpc serve myapp.services.GreeterService
94
+
95
+ # As Connect RPC (HTTP/2)
96
+ pydantic-rpc serve myapp.services.GreeterService --asgi --port 8000
97
+
98
+ # As Connect RPC (HTTP/1.1)
99
+ pydantic-rpc serve myapp.services.GreeterService --wsgi --port 8000
100
+ ```
101
+
102
+ ## Benefits of the Separated CLI
103
+
104
+ - **Lightweight Core**: The main `pydantic-rpc` package remains lightweight without server dependencies
105
+ - **Full Server Support**: When you install `pydantic-rpc-cli`, you get everything needed to run services
106
+ - **Flexibility**: Choose between gRPC, ASGI (HTTP/2), or WSGI (HTTP/1.1) at runtime
107
+
108
+ ## License
109
+
110
+ Same as pydantic-rpc
@@ -0,0 +1,98 @@
1
+ # pydantic-rpc-cli
2
+
3
+ CLI tool for [pydantic-rpc](https://github.com/yourusername/pydantic-rpc) with built-in server runtime support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pydantic-rpc-cli
9
+ ```
10
+
11
+ This will install:
12
+ - `pydantic-rpc` (core library)
13
+ - `hypercorn` (ASGI server)
14
+ - `gunicorn` (WSGI server)
15
+ - `uvloop` (optional asyncio performance improvement)
16
+
17
+ ## Usage
18
+
19
+ ### Generate Protobuf Files
20
+
21
+ ```bash
22
+ # Generate .proto file from a service class
23
+ pydantic-rpc generate myapp.services.UserService --output ./proto/
24
+
25
+ # Also compile to Python code
26
+ pydantic-rpc generate myapp.services.UserService --compile
27
+ ```
28
+
29
+ ### Run Servers
30
+
31
+ #### gRPC Server (default)
32
+ ```bash
33
+ # Start a gRPC server
34
+ pydantic-rpc serve myapp.services.UserService --port 50051
35
+
36
+ # The CLI automatically detects if your service has async methods
37
+ # and uses AsyncIOServer or Server accordingly
38
+ ```
39
+
40
+ #### Connect RPC via ASGI (HTTP/2)
41
+ ```bash
42
+ # Run with Hypercorn (built-in)
43
+ pydantic-rpc serve myapp.services.UserService --asgi --port 8000
44
+
45
+ # Service will be available at:
46
+ # http://localhost:8000/UserService/
47
+ ```
48
+
49
+ #### Connect RPC via WSGI (HTTP/1.1)
50
+ ```bash
51
+ # Run with Gunicorn (built-in)
52
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000
53
+
54
+ # With multiple workers
55
+ pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
56
+
57
+ # Service will be available at:
58
+ # http://localhost:8000/UserService/
59
+ ```
60
+
61
+ ## Example Service
62
+
63
+ ```python
64
+ # myapp/services.py
65
+ from pydantic_rpc import Message, AsyncIOServer
66
+
67
+ class HelloRequest(Message):
68
+ name: str
69
+
70
+ class HelloResponse(Message):
71
+ message: str
72
+
73
+ class GreeterService:
74
+ async def say_hello(self, request: HelloRequest) -> HelloResponse:
75
+ return HelloResponse(message=f"Hello, {request.name}!")
76
+ ```
77
+
78
+ Run it:
79
+ ```bash
80
+ # As gRPC
81
+ pydantic-rpc serve myapp.services.GreeterService
82
+
83
+ # As Connect RPC (HTTP/2)
84
+ pydantic-rpc serve myapp.services.GreeterService --asgi --port 8000
85
+
86
+ # As Connect RPC (HTTP/1.1)
87
+ pydantic-rpc serve myapp.services.GreeterService --wsgi --port 8000
88
+ ```
89
+
90
+ ## Benefits of the Separated CLI
91
+
92
+ - **Lightweight Core**: The main `pydantic-rpc` package remains lightweight without server dependencies
93
+ - **Full Server Support**: When you install `pydantic-rpc-cli`, you get everything needed to run services
94
+ - **Flexibility**: Choose between gRPC, ASGI (HTTP/2), or WSGI (HTTP/1.1) at runtime
95
+
96
+ ## License
97
+
98
+ Same as pydantic-rpc
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "pydantic-rpc-cli"
3
+ version = "0.11.0"
4
+ description = "CLI tool for pydantic-rpc with server runtime support"
5
+ authors = [
6
+ { name = "Yasushi Itoh" }
7
+ ]
8
+ dependencies = [
9
+ "pydantic-rpc>=0.10.0",
10
+ "hypercorn>=0.17.3",
11
+ "gunicorn>=20.1.0",
12
+ "uvloop>=0.17.0", # Optional performance improvement for asyncio
13
+ ]
14
+ readme = "README.md"
15
+ requires-python = ">= 3.11"
16
+
17
+ [project.scripts]
18
+ pydantic-rpc = "pydantic_rpc_cli.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.7.21,<0.8.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv]
25
+ managed = true
@@ -0,0 +1,3 @@
1
+ """CLI tool for pydantic-rpc."""
2
+
3
+ __version__ = "0.10.0"
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line interface for pydantic-rpc with server runtime support."""
3
+
4
+ import argparse
5
+ import sys
6
+ import importlib
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ import asyncio
13
+
14
+ from pydantic_rpc.core import generate_proto, generate_and_compile_proto
15
+
16
+
17
+ def import_service_class(module_path: str, class_name: Optional[str] = None):
18
+ """Import a service class from a module path.
19
+
20
+ Args:
21
+ module_path: Python module path (e.g., 'myapp.services.UserService')
22
+ class_name: Optional class name if not included in module_path
23
+
24
+ Returns:
25
+ The service class
26
+ """
27
+ # Add current directory to Python path FIRST, before parsing
28
+ current_dir = os.getcwd()
29
+ if current_dir not in sys.path:
30
+ sys.path.insert(0, current_dir)
31
+
32
+ if "." in module_path and not class_name:
33
+ # Try to extract class name from module path
34
+ parts = module_path.split(".")
35
+ potential_class = parts[-1]
36
+ # Check if the last part starts with uppercase (likely a class)
37
+ if potential_class[0].isupper():
38
+ module_path = ".".join(parts[:-1])
39
+ class_name = potential_class
40
+
41
+ if not class_name:
42
+ raise ValueError(
43
+ "Class name must be provided either in module_path or as separate argument"
44
+ )
45
+
46
+ try:
47
+ module = importlib.import_module(module_path)
48
+ service_class = getattr(module, class_name)
49
+ return service_class
50
+ except ImportError as e:
51
+ raise ImportError(f"Failed to import module '{module_path}': {e}")
52
+ except AttributeError:
53
+ raise AttributeError(
54
+ f"Class '{class_name}' not found in module '{module_path}'"
55
+ )
56
+
57
+
58
+ def cmd_generate(args):
59
+ """Generate protobuf file from a service class."""
60
+ try:
61
+ # Import the service class
62
+ service_class = import_service_class(args.service, args.class_name)
63
+
64
+ # Create an instance of the service
65
+ service_instance = service_class()
66
+
67
+ # Generate proto content
68
+ proto_content = generate_proto(service_instance, args.package or "")
69
+
70
+ # Determine output file
71
+ if args.output:
72
+ output_path = Path(args.output)
73
+ if output_path.is_dir():
74
+ # If output is a directory, create file with service name
75
+ proto_filename = f"{service_class.__name__.lower()}.proto"
76
+ output_file = output_path / proto_filename
77
+ else:
78
+ # Use the provided file path
79
+ output_file = output_path
80
+ else:
81
+ # Default to current directory
82
+ proto_filename = f"{service_class.__name__.lower()}.proto"
83
+ output_file = Path.cwd() / proto_filename
84
+
85
+ # Ensure parent directory exists
86
+ output_file.parent.mkdir(parents=True, exist_ok=True)
87
+
88
+ # Write the proto file
89
+ with open(output_file, "w") as f:
90
+ f.write(proto_content)
91
+
92
+ print(f"✓ Generated protobuf file: {output_file}")
93
+
94
+ # Optionally compile the proto file
95
+ if args.compile:
96
+ print("Compiling protobuf file...")
97
+ pb2_grpc, pb2 = generate_and_compile_proto(
98
+ service_instance, args.package or "", existing_proto_path=output_file
99
+ )
100
+ if pb2_grpc and pb2:
101
+ print(
102
+ f"✓ Compiled protobuf modules: {output_file.stem}_pb2.py, {output_file.stem}_pb2_grpc.py"
103
+ )
104
+ else:
105
+ print("✗ Failed to compile protobuf file")
106
+ return 1
107
+
108
+ return 0
109
+
110
+ except Exception as e:
111
+ print(f"✗ Error: {e}", file=sys.stderr)
112
+ return 1
113
+
114
+
115
+ def create_asgi_wrapper(
116
+ service_module: str, service_class_name: str, package_name: str = ""
117
+ ) -> str:
118
+ """Create a Python module that instantiates and exports an ASGI app."""
119
+ return f"""
120
+ # Auto-generated ASGI wrapper for pydantic-rpc
121
+ import sys
122
+ import os
123
+ sys.path.insert(0, os.getcwd())
124
+
125
+ from {service_module} import {service_class_name}
126
+ from pydantic_rpc import ASGIApp
127
+
128
+ # Create service instance
129
+ service = {service_class_name}()
130
+
131
+ # Create ASGI app
132
+ app = ASGIApp(package_name="{package_name}")
133
+
134
+ # Mount the service (this triggers proto generation)
135
+ app.mount(service)
136
+ """
137
+
138
+
139
+ def create_wsgi_wrapper(
140
+ service_module: str, service_class_name: str, package_name: str = ""
141
+ ) -> str:
142
+ """Create a Python module that instantiates and exports a WSGI app."""
143
+ return f"""
144
+ # Auto-generated WSGI wrapper for pydantic-rpc
145
+ import sys
146
+ import os
147
+ sys.path.insert(0, os.getcwd())
148
+
149
+ from {service_module} import {service_class_name}
150
+ from pydantic_rpc import WSGIApp
151
+
152
+ # Create service instance
153
+ service = {service_class_name}()
154
+
155
+ # Create WSGI app
156
+ application = WSGIApp(package_name="{package_name}")
157
+
158
+ # Mount the service (this triggers proto generation)
159
+ application.mount(service)
160
+ """
161
+
162
+
163
+ def run_asgi_server(
164
+ service_module: str, service_class_name: str, port: int, package_name: str = ""
165
+ ):
166
+ """Run an ASGI server with hypercorn."""
167
+ # Create a temporary file with the ASGI wrapper
168
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
169
+ wrapper_code = create_asgi_wrapper(
170
+ service_module, service_class_name, package_name
171
+ )
172
+ f.write(wrapper_code)
173
+ wrapper_path = f.name
174
+
175
+ try:
176
+ # Get the module name from the temp file
177
+ wrapper_module = Path(wrapper_path).stem
178
+
179
+ print(f"✓ Starting ASGI server with Hypercorn on port {port}...")
180
+ print(f" Service: {service_class_name}")
181
+ print(f" HTTP: http://localhost:{port}")
182
+ print(f" Connect RPC endpoint: http://localhost:{port}/{service_class_name}/")
183
+ print("\nPress Ctrl+C to stop the server\n")
184
+
185
+ # Run hypercorn
186
+ cmd = [
187
+ sys.executable,
188
+ "-m",
189
+ "hypercorn",
190
+ f"{wrapper_module}:app",
191
+ "--bind",
192
+ f"0.0.0.0:{port}",
193
+ "--access-logfile",
194
+ "-",
195
+ "--error-logfile",
196
+ "-",
197
+ ]
198
+
199
+ # Set PYTHONPATH to include the temp file directory
200
+ env = os.environ.copy()
201
+ env["PYTHONPATH"] = f"{Path(wrapper_path).parent}:{env.get('PYTHONPATH', '')}"
202
+
203
+ proc = subprocess.Popen(cmd, env=env)
204
+ proc.wait()
205
+
206
+ finally:
207
+ # Clean up temp file
208
+ if os.path.exists(wrapper_path):
209
+ os.unlink(wrapper_path)
210
+
211
+
212
+ def run_wsgi_server(
213
+ service_module: str, service_class_name: str, port: int, package_name: str = ""
214
+ ):
215
+ """Run a WSGI server with gunicorn."""
216
+ # Create a temporary file with the WSGI wrapper
217
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
218
+ wrapper_code = create_wsgi_wrapper(
219
+ service_module, service_class_name, package_name
220
+ )
221
+ f.write(wrapper_code)
222
+ wrapper_path = f.name
223
+
224
+ try:
225
+ # Get the module name from the temp file
226
+ wrapper_module = Path(wrapper_path).stem
227
+
228
+ print(f"✓ Starting WSGI server with Gunicorn on port {port}...")
229
+ print(f" Service: {service_class_name}")
230
+ print(f" HTTP: http://localhost:{port}")
231
+ print(f" Connect RPC endpoint: http://localhost:{port}/{service_class_name}/")
232
+ print("\nPress Ctrl+C to stop the server\n")
233
+
234
+ # Run gunicorn
235
+ cmd = [
236
+ sys.executable,
237
+ "-m",
238
+ "gunicorn",
239
+ f"{wrapper_module}:application",
240
+ "--bind",
241
+ f"0.0.0.0:{port}",
242
+ "--access-logfile",
243
+ "-",
244
+ "--error-logfile",
245
+ "-",
246
+ "--workers",
247
+ str(args.workers) if hasattr(args, "workers") else "1",
248
+ ]
249
+
250
+ # Set PYTHONPATH to include the temp file directory
251
+ env = os.environ.copy()
252
+ env["PYTHONPATH"] = f"{Path(wrapper_path).parent}:{env.get('PYTHONPATH', '')}"
253
+
254
+ proc = subprocess.Popen(cmd, env=env)
255
+ proc.wait()
256
+
257
+ finally:
258
+ # Clean up temp file
259
+ if os.path.exists(wrapper_path):
260
+ os.unlink(wrapper_path)
261
+
262
+
263
+ def cmd_serve(args):
264
+ """Start a server with the specified service."""
265
+ try:
266
+ # Parse module and class name
267
+ if "." in args.service:
268
+ parts = args.service.split(".")
269
+ if parts[-1][0].isupper():
270
+ service_module = ".".join(parts[:-1])
271
+ service_class_name = parts[-1]
272
+ else:
273
+ service_module = args.service
274
+ service_class_name = args.class_name
275
+ else:
276
+ service_module = args.service
277
+ service_class_name = args.class_name
278
+
279
+ if not service_class_name:
280
+ raise ValueError("Class name must be provided")
281
+
282
+ # Import the service class to validate it exists
283
+ service_class = import_service_class(service_module, service_class_name)
284
+
285
+ # Determine server type and run
286
+ if args.asgi:
287
+ # Run ASGI server with hypercorn
288
+ run_asgi_server(
289
+ service_module, service_class_name, args.port, args.package or ""
290
+ )
291
+
292
+ elif args.wsgi:
293
+ # Run WSGI server with gunicorn
294
+ run_wsgi_server(
295
+ service_module, service_class_name, args.port, args.package or ""
296
+ )
297
+
298
+ else:
299
+ # Default to gRPC server
300
+ from pydantic_rpc import Server, AsyncIOServer
301
+
302
+ # Create an instance of the service
303
+ service_instance = service_class()
304
+
305
+ # Check if service has async methods
306
+ has_async = any(
307
+ asyncio.iscoroutinefunction(getattr(service_instance, name))
308
+ for name in dir(service_instance)
309
+ if not name.startswith("_")
310
+ )
311
+
312
+ if has_async or args.asyncio:
313
+ server = AsyncIOServer(
314
+ service=service_instance,
315
+ port=args.port,
316
+ package_name=args.package or "",
317
+ )
318
+ print(f"✓ Starting AsyncIO gRPC server on port {args.port}...")
319
+ print(f" Service: {service_class_name}")
320
+ if args.reflection:
321
+ print(" Reflection: enabled")
322
+ print("\nPress Ctrl+C to stop the server\n")
323
+
324
+ loop = asyncio.get_event_loop()
325
+ loop.run_until_complete(server.run())
326
+
327
+ else:
328
+ server = Server(
329
+ service=service_instance,
330
+ port=args.port,
331
+ package_name=args.package or "",
332
+ )
333
+ print(f"✓ Starting gRPC server on port {args.port}...")
334
+ print(f" Service: {service_class_name}")
335
+ if args.reflection:
336
+ print(" Reflection: enabled")
337
+ print("\nPress Ctrl+C to stop the server\n")
338
+
339
+ server.run()
340
+
341
+ return 0
342
+
343
+ except KeyboardInterrupt:
344
+ print("\n✓ Server stopped")
345
+ return 0
346
+ except Exception as e:
347
+ print(f"✗ Error: {e}", file=sys.stderr)
348
+ import traceback
349
+
350
+ traceback.print_exc()
351
+ return 1
352
+
353
+
354
+ def main():
355
+ """Main entry point for the CLI."""
356
+ parser = argparse.ArgumentParser(
357
+ prog="pydantic-rpc",
358
+ description="CLI tool for pydantic-rpc: Generate and serve gRPC/Connect services from Pydantic models",
359
+ )
360
+
361
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
362
+
363
+ # Generate command
364
+ generate_parser = subparsers.add_parser(
365
+ "generate", help="Generate protobuf file from a service class"
366
+ )
367
+ generate_parser.add_argument(
368
+ "service",
369
+ help="Python module path to the service (e.g., myapp.services.UserService)",
370
+ )
371
+ generate_parser.add_argument(
372
+ "--class-name", help="Service class name (if not included in service path)"
373
+ )
374
+ generate_parser.add_argument(
375
+ "--output",
376
+ "-o",
377
+ help="Output file or directory path (default: current directory)",
378
+ )
379
+ generate_parser.add_argument("--package", "-p", help="Protobuf package name")
380
+ generate_parser.add_argument(
381
+ "--compile",
382
+ "-c",
383
+ action="store_true",
384
+ help="Also compile the generated proto file",
385
+ )
386
+
387
+ # Serve command
388
+ serve_parser = subparsers.add_parser(
389
+ "serve", help="Start a server with the specified service"
390
+ )
391
+ serve_parser.add_argument(
392
+ "service",
393
+ help="Python module path to the service (e.g., myapp.services.UserService)",
394
+ )
395
+ serve_parser.add_argument(
396
+ "--class-name", help="Service class name (if not included in service path)"
397
+ )
398
+ serve_parser.add_argument(
399
+ "--port",
400
+ "-p",
401
+ type=int,
402
+ default=50051,
403
+ help="Port to listen on (default: 50051)",
404
+ )
405
+ serve_parser.add_argument("--package", help="Protobuf package name")
406
+ serve_parser.add_argument(
407
+ "--reflection",
408
+ "-r",
409
+ action="store_true",
410
+ default=True,
411
+ help="Enable gRPC reflection (default: True)",
412
+ )
413
+ serve_parser.add_argument(
414
+ "--asyncio", action="store_true", help="Force use of AsyncIO server"
415
+ )
416
+ serve_parser.add_argument(
417
+ "--asgi",
418
+ action="store_true",
419
+ help="Run as ASGI app with Hypercorn (Connect RPC over HTTP/2)",
420
+ )
421
+ serve_parser.add_argument(
422
+ "--wsgi",
423
+ action="store_true",
424
+ help="Run as WSGI app with Gunicorn (Connect RPC over HTTP/1.1)",
425
+ )
426
+ serve_parser.add_argument(
427
+ "--workers",
428
+ "-w",
429
+ type=int,
430
+ default=1,
431
+ help="Number of worker processes for WSGI server (default: 1)",
432
+ )
433
+
434
+ global args # Make args accessible to run_wsgi_server
435
+ args = parser.parse_args()
436
+
437
+ if not args.command:
438
+ parser.print_help()
439
+ return 1
440
+
441
+ if args.command == "generate":
442
+ return cmd_generate(args)
443
+ elif args.command == "serve":
444
+ return cmd_serve(args)
445
+ else:
446
+ parser.print_help()
447
+ return 1
448
+
449
+
450
+ if __name__ == "__main__":
451
+ sys.exit(main())