winebox 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.
winebox/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """WineBox - Wine Cellar Management Application."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CLI tools for WineBox."""
winebox/cli/server.py ADDED
@@ -0,0 +1,313 @@
1
+ """WineBox server control script.
2
+
3
+ Usage:
4
+ winebox-server start [--port PORT] [--reload]
5
+ winebox-server stop
6
+ winebox-server restart [--port PORT]
7
+ winebox-server status
8
+ """
9
+
10
+ import argparse
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+
19
+ # Configuration
20
+ DATA_DIR = Path("data")
21
+ PID_FILE = DATA_DIR / "winebox.pid"
22
+ LOG_FILE = DATA_DIR / "winebox.log"
23
+ DEFAULT_PORT = 8000
24
+ DEFAULT_HOST = "0.0.0.0"
25
+
26
+
27
+ def ensure_directories() -> None:
28
+ """Ensure required directories exist."""
29
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
30
+ (DATA_DIR / "images").mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ def get_pid() -> int | None:
34
+ """Get the PID of the running server, if any."""
35
+ if not PID_FILE.exists():
36
+ return None
37
+
38
+ try:
39
+ pid = int(PID_FILE.read_text().strip())
40
+ # Check if process is actually running
41
+ os.kill(pid, 0)
42
+ return pid
43
+ except (ValueError, ProcessLookupError, PermissionError):
44
+ # PID file exists but process is not running
45
+ PID_FILE.unlink(missing_ok=True)
46
+ return None
47
+
48
+
49
+ def find_running_server() -> int | None:
50
+ """Find any running winebox uvicorn process."""
51
+ try:
52
+ result = subprocess.run(
53
+ ["pgrep", "-f", "uvicorn winebox.main:app"],
54
+ capture_output=True,
55
+ text=True,
56
+ )
57
+ if result.returncode == 0 and result.stdout.strip():
58
+ # Return first PID found
59
+ return int(result.stdout.strip().split()[0])
60
+ except Exception:
61
+ pass
62
+ return None
63
+
64
+
65
+ def start_server(port: int = DEFAULT_PORT, host: str = DEFAULT_HOST,
66
+ reload: bool = False, foreground: bool = False) -> bool:
67
+ """Start the WineBox server.
68
+
69
+ Args:
70
+ port: Port to bind to
71
+ host: Host to bind to
72
+ reload: Enable auto-reload for development
73
+ foreground: Run in foreground (blocking)
74
+
75
+ Returns:
76
+ True if server started successfully
77
+ """
78
+ # Check if already running
79
+ pid = get_pid() or find_running_server()
80
+ if pid:
81
+ print(f"Server is already running (PID: {pid})")
82
+ return False
83
+
84
+ ensure_directories()
85
+
86
+ cmd = [
87
+ sys.executable, "-m", "uvicorn",
88
+ "winebox.main:app",
89
+ "--host", host,
90
+ "--port", str(port),
91
+ ]
92
+
93
+ if reload:
94
+ cmd.append("--reload")
95
+
96
+ print(f"Starting WineBox server on http://{host}:{port}")
97
+
98
+ if foreground:
99
+ print("Press Ctrl+C to stop the server")
100
+ try:
101
+ subprocess.run(cmd)
102
+ except KeyboardInterrupt:
103
+ print("\nServer stopped")
104
+ return True
105
+
106
+ # Start in background
107
+ with open(LOG_FILE, "w") as log:
108
+ process = subprocess.Popen(
109
+ cmd,
110
+ stdout=log,
111
+ stderr=subprocess.STDOUT,
112
+ start_new_session=True,
113
+ )
114
+
115
+ # Wait a moment and check if it started
116
+ time.sleep(1)
117
+ if process.poll() is None:
118
+ PID_FILE.write_text(str(process.pid))
119
+ print(f"Server started with PID: {process.pid}")
120
+ print(f"Logs available at: {LOG_FILE}")
121
+ return True
122
+ else:
123
+ print("Failed to start server. Check logs for details.")
124
+ return False
125
+
126
+
127
+ def stop_server() -> bool:
128
+ """Stop the WineBox server.
129
+
130
+ Returns:
131
+ True if server was stopped
132
+ """
133
+ pid = get_pid()
134
+
135
+ if not pid:
136
+ # Try to find running server anyway
137
+ pid = find_running_server()
138
+
139
+ if not pid:
140
+ print("Server is not running")
141
+ return False
142
+
143
+ print(f"Stopping server (PID: {pid})...")
144
+
145
+ try:
146
+ # Send SIGTERM for graceful shutdown
147
+ os.kill(pid, signal.SIGTERM)
148
+
149
+ # Wait for process to terminate
150
+ for _ in range(10):
151
+ time.sleep(0.5)
152
+ try:
153
+ os.kill(pid, 0)
154
+ except ProcessLookupError:
155
+ break
156
+ else:
157
+ # Process didn't stop, send SIGKILL
158
+ print("Server didn't stop gracefully, forcing...")
159
+ os.kill(pid, signal.SIGKILL)
160
+
161
+ print("Server stopped")
162
+ PID_FILE.unlink(missing_ok=True)
163
+ return True
164
+
165
+ except ProcessLookupError:
166
+ print("Server was not running")
167
+ PID_FILE.unlink(missing_ok=True)
168
+ return False
169
+ except PermissionError:
170
+ print(f"Permission denied to stop process {pid}")
171
+ return False
172
+
173
+
174
+ def restart_server(port: int = DEFAULT_PORT, host: str = DEFAULT_HOST) -> bool:
175
+ """Restart the WineBox server.
176
+
177
+ Args:
178
+ port: Port to bind to
179
+ host: Host to bind to
180
+
181
+ Returns:
182
+ True if server restarted successfully
183
+ """
184
+ print("Restarting WineBox server...")
185
+ stop_server()
186
+ time.sleep(1)
187
+ return start_server(port=port, host=host)
188
+
189
+
190
+ def server_status() -> None:
191
+ """Print the server status."""
192
+ pid = get_pid()
193
+
194
+ if not pid:
195
+ pid = find_running_server()
196
+
197
+ if pid:
198
+ print(f"WineBox server is running (PID: {pid})")
199
+
200
+ # Try to get health status
201
+ try:
202
+ import urllib.request
203
+ with urllib.request.urlopen("http://localhost:8000/health", timeout=2) as response:
204
+ import json
205
+ data = json.loads(response.read().decode())
206
+ print(f" Status: {data.get('status', 'unknown')}")
207
+ print(f" Version: {data.get('version', 'unknown')}")
208
+ except Exception:
209
+ print(" (Could not fetch health status)")
210
+ else:
211
+ print("WineBox server is not running")
212
+
213
+
214
+ def main() -> int:
215
+ """Main entry point."""
216
+ parser = argparse.ArgumentParser(
217
+ description="WineBox server control script",
218
+ formatter_class=argparse.RawDescriptionHelpFormatter,
219
+ epilog="""
220
+ Examples:
221
+ %(prog)s start Start server on default port (8000)
222
+ %(prog)s start --port 8080 Start server on port 8080
223
+ %(prog)s start --reload Start with auto-reload for development
224
+ %(prog)s start --foreground Start in foreground (blocking)
225
+ %(prog)s stop Stop the server
226
+ %(prog)s restart Restart the server
227
+ %(prog)s status Check server status
228
+ """,
229
+ )
230
+
231
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
232
+
233
+ # Start command
234
+ start_parser = subparsers.add_parser("start", help="Start the server")
235
+ start_parser.add_argument(
236
+ "--port", "-p",
237
+ type=int,
238
+ default=DEFAULT_PORT,
239
+ help=f"Port to bind to (default: {DEFAULT_PORT})",
240
+ )
241
+ start_parser.add_argument(
242
+ "--host",
243
+ default=DEFAULT_HOST,
244
+ help=f"Host to bind to (default: {DEFAULT_HOST})",
245
+ )
246
+ start_parser.add_argument(
247
+ "--reload", "-r",
248
+ action="store_true",
249
+ help="Enable auto-reload for development",
250
+ )
251
+ start_parser.add_argument(
252
+ "--foreground", "-f",
253
+ action="store_true",
254
+ help="Run in foreground (blocking)",
255
+ )
256
+
257
+ # Stop command
258
+ subparsers.add_parser("stop", help="Stop the server")
259
+
260
+ # Restart command
261
+ restart_parser = subparsers.add_parser("restart", help="Restart the server")
262
+ restart_parser.add_argument(
263
+ "--port", "-p",
264
+ type=int,
265
+ default=DEFAULT_PORT,
266
+ help=f"Port to bind to (default: {DEFAULT_PORT})",
267
+ )
268
+ restart_parser.add_argument(
269
+ "--host",
270
+ default=DEFAULT_HOST,
271
+ help=f"Host to bind to (default: {DEFAULT_HOST})",
272
+ )
273
+
274
+ # Status command
275
+ subparsers.add_parser("status", help="Check server status")
276
+
277
+ args = parser.parse_args()
278
+
279
+ if not args.command:
280
+ parser.print_help()
281
+ return 1
282
+
283
+ try:
284
+ if args.command == "start":
285
+ success = start_server(
286
+ port=args.port,
287
+ host=args.host,
288
+ reload=args.reload,
289
+ foreground=args.foreground,
290
+ )
291
+ return 0 if success else 1
292
+
293
+ elif args.command == "stop":
294
+ success = stop_server()
295
+ return 0 if success else 1
296
+
297
+ elif args.command == "restart":
298
+ success = restart_server(port=args.port, host=args.host)
299
+ return 0 if success else 1
300
+
301
+ elif args.command == "status":
302
+ server_status()
303
+ return 0
304
+
305
+ except KeyboardInterrupt:
306
+ print("\nInterrupted")
307
+ return 130
308
+
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())
@@ -0,0 +1,258 @@
1
+ """User administration script for WineBox.
2
+
3
+ Commands:
4
+ add Add a new user
5
+ list List all users
6
+ disable Disable a user account
7
+ enable Enable a user account
8
+ remove Remove a user account
9
+ passwd Change a user's password
10
+ """
11
+
12
+ import argparse
13
+ import asyncio
14
+ import sys
15
+ from getpass import getpass
16
+ from typing import Optional
17
+
18
+ from sqlalchemy import select
19
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
20
+
21
+ from winebox.config import settings
22
+ from winebox.database import Base
23
+ from winebox.models.user import User
24
+ from winebox.services.auth import get_password_hash
25
+
26
+
27
+ async def get_db_session() -> AsyncSession:
28
+ """Create a database session, ensuring tables exist."""
29
+ engine = create_async_engine(settings.database_url, echo=False)
30
+
31
+ # Create tables if they don't exist
32
+ async with engine.begin() as conn:
33
+ await conn.run_sync(Base.metadata.create_all)
34
+
35
+ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
36
+ return async_session()
37
+
38
+
39
+ async def add_user(
40
+ username: str,
41
+ password: str,
42
+ email: Optional[str] = None,
43
+ is_admin: bool = False,
44
+ ) -> None:
45
+ """Add a new user."""
46
+ async with await get_db_session() as db:
47
+ # Check if user already exists
48
+ result = await db.execute(select(User).where(User.username == username))
49
+ if result.scalar_one_or_none():
50
+ print(f"Error: User '{username}' already exists.")
51
+ sys.exit(1)
52
+
53
+ # Check if email already exists
54
+ if email:
55
+ result = await db.execute(select(User).where(User.email == email))
56
+ if result.scalar_one_or_none():
57
+ print(f"Error: Email '{email}' already in use.")
58
+ sys.exit(1)
59
+
60
+ # Create user
61
+ user = User(
62
+ username=username,
63
+ email=email,
64
+ hashed_password=get_password_hash(password),
65
+ is_admin=is_admin,
66
+ is_active=True,
67
+ )
68
+ db.add(user)
69
+ await db.commit()
70
+
71
+ role = "admin" if is_admin else "user"
72
+ print(f"User '{username}' created successfully as {role}.")
73
+
74
+
75
+ async def list_users() -> None:
76
+ """List all users."""
77
+ async with await get_db_session() as db:
78
+ result = await db.execute(select(User).order_by(User.username))
79
+ users = result.scalars().all()
80
+
81
+ if not users:
82
+ print("No users found.")
83
+ return
84
+
85
+ print(f"{'Username':<20} {'Email':<30} {'Admin':<6} {'Active':<6} {'Last Login':<20}")
86
+ print("-" * 90)
87
+
88
+ for user in users:
89
+ last_login = user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "Never"
90
+ admin = "Yes" if user.is_admin else "No"
91
+ active = "Yes" if user.is_active else "No"
92
+ email = user.email or ""
93
+ print(f"{user.username:<20} {email:<30} {admin:<6} {active:<6} {last_login:<20}")
94
+
95
+
96
+ async def disable_user(username: str) -> None:
97
+ """Disable a user account."""
98
+ async with await get_db_session() as db:
99
+ result = await db.execute(select(User).where(User.username == username))
100
+ user = result.scalar_one_or_none()
101
+
102
+ if not user:
103
+ print(f"Error: User '{username}' not found.")
104
+ sys.exit(1)
105
+
106
+ if not user.is_active:
107
+ print(f"User '{username}' is already disabled.")
108
+ return
109
+
110
+ user.is_active = False
111
+ await db.commit()
112
+ print(f"User '{username}' has been disabled.")
113
+
114
+
115
+ async def enable_user(username: str) -> None:
116
+ """Enable a user account."""
117
+ async with await get_db_session() as db:
118
+ result = await db.execute(select(User).where(User.username == username))
119
+ user = result.scalar_one_or_none()
120
+
121
+ if not user:
122
+ print(f"Error: User '{username}' not found.")
123
+ sys.exit(1)
124
+
125
+ if user.is_active:
126
+ print(f"User '{username}' is already active.")
127
+ return
128
+
129
+ user.is_active = True
130
+ await db.commit()
131
+ print(f"User '{username}' has been enabled.")
132
+
133
+
134
+ async def remove_user(username: str, force: bool = False) -> None:
135
+ """Remove a user account."""
136
+ async with await get_db_session() as db:
137
+ result = await db.execute(select(User).where(User.username == username))
138
+ user = result.scalar_one_or_none()
139
+
140
+ if not user:
141
+ print(f"Error: User '{username}' not found.")
142
+ sys.exit(1)
143
+
144
+ if not force:
145
+ confirm = input(f"Are you sure you want to remove user '{username}'? [y/N]: ")
146
+ if confirm.lower() != "y":
147
+ print("Aborted.")
148
+ return
149
+
150
+ await db.delete(user)
151
+ await db.commit()
152
+ print(f"User '{username}' has been removed.")
153
+
154
+
155
+ async def change_password(username: str, password: str) -> None:
156
+ """Change a user's password."""
157
+ async with await get_db_session() as db:
158
+ result = await db.execute(select(User).where(User.username == username))
159
+ user = result.scalar_one_or_none()
160
+
161
+ if not user:
162
+ print(f"Error: User '{username}' not found.")
163
+ sys.exit(1)
164
+
165
+ user.hashed_password = get_password_hash(password)
166
+ await db.commit()
167
+ print(f"Password for user '{username}' has been updated.")
168
+
169
+
170
+ def get_password_interactive(confirm: bool = True) -> str:
171
+ """Get password interactively from user."""
172
+ password = getpass("Password: ")
173
+ if not password:
174
+ print("Error: Password cannot be empty.")
175
+ sys.exit(1)
176
+
177
+ if confirm:
178
+ password2 = getpass("Confirm password: ")
179
+ if password != password2:
180
+ print("Error: Passwords do not match.")
181
+ sys.exit(1)
182
+
183
+ return password
184
+
185
+
186
+ def main() -> int:
187
+ """Main entry point."""
188
+ parser = argparse.ArgumentParser(
189
+ description="User administration for WineBox",
190
+ formatter_class=argparse.RawDescriptionHelpFormatter,
191
+ epilog=__doc__,
192
+ )
193
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
194
+
195
+ # Add user command
196
+ add_parser = subparsers.add_parser("add", help="Add a new user")
197
+ add_parser.add_argument("username", help="Username for the new user")
198
+ add_parser.add_argument("--email", "-e", help="Email address")
199
+ add_parser.add_argument("--admin", "-a", action="store_true", help="Make user an admin")
200
+ add_parser.add_argument("--password", "-p", help="Password (will prompt if not provided)")
201
+
202
+ # List users command
203
+ subparsers.add_parser("list", help="List all users")
204
+
205
+ # Disable user command
206
+ disable_parser = subparsers.add_parser("disable", help="Disable a user account")
207
+ disable_parser.add_argument("username", help="Username to disable")
208
+
209
+ # Enable user command
210
+ enable_parser = subparsers.add_parser("enable", help="Enable a user account")
211
+ enable_parser.add_argument("username", help="Username to enable")
212
+
213
+ # Remove user command
214
+ remove_parser = subparsers.add_parser("remove", help="Remove a user account")
215
+ remove_parser.add_argument("username", help="Username to remove")
216
+ remove_parser.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
217
+
218
+ # Change password command
219
+ passwd_parser = subparsers.add_parser("passwd", help="Change a user's password")
220
+ passwd_parser.add_argument("username", help="Username to change password for")
221
+ passwd_parser.add_argument("--password", "-p", help="New password (will prompt if not provided)")
222
+
223
+ args = parser.parse_args()
224
+
225
+ if not args.command:
226
+ parser.print_help()
227
+ return 1
228
+
229
+ try:
230
+ if args.command == "add":
231
+ password = args.password if args.password else get_password_interactive()
232
+ asyncio.run(add_user(args.username, password, args.email, args.admin))
233
+
234
+ elif args.command == "list":
235
+ asyncio.run(list_users())
236
+
237
+ elif args.command == "disable":
238
+ asyncio.run(disable_user(args.username))
239
+
240
+ elif args.command == "enable":
241
+ asyncio.run(enable_user(args.username))
242
+
243
+ elif args.command == "remove":
244
+ asyncio.run(remove_user(args.username, args.force))
245
+
246
+ elif args.command == "passwd":
247
+ password = args.password if args.password else get_password_interactive()
248
+ asyncio.run(change_password(args.username, password))
249
+
250
+ except KeyboardInterrupt:
251
+ print("\nAborted.")
252
+ return 1
253
+
254
+ return 0
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())
winebox/config.py ADDED
@@ -0,0 +1,43 @@
1
+ """Configuration settings for WineBox application."""
2
+
3
+ import secrets
4
+ from pathlib import Path
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ def generate_secret_key() -> str:
9
+ """Generate a random secret key."""
10
+ return secrets.token_urlsafe(32)
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ """Application settings."""
15
+
16
+ model_config = SettingsConfigDict(
17
+ env_prefix="WINEBOX_",
18
+ env_file=".env",
19
+ )
20
+
21
+ # Application
22
+ app_name: str = "WineBox"
23
+ debug: bool = False
24
+
25
+ # Database
26
+ database_url: str = "sqlite+aiosqlite:///./data/winebox.db"
27
+
28
+ # Image storage
29
+ image_storage_path: Path = Path("data/images")
30
+
31
+ # Server
32
+ host: str = "0.0.0.0"
33
+ port: int = 8000
34
+
35
+ # OCR
36
+ tesseract_cmd: str | None = None # Use system default if None
37
+
38
+ # Authentication
39
+ secret_key: str = generate_secret_key() # Override with WINEBOX_SECRET_KEY env var
40
+ auth_enabled: bool = True # Set to False to disable authentication
41
+
42
+
43
+ settings = Settings()
winebox/database.py ADDED
@@ -0,0 +1,47 @@
1
+ """Database setup and session management."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
6
+ from sqlalchemy.orm import DeclarativeBase
7
+
8
+ from winebox.config import settings
9
+
10
+ engine = create_async_engine(
11
+ settings.database_url,
12
+ echo=settings.debug,
13
+ )
14
+
15
+ async_session_maker = async_sessionmaker(
16
+ engine,
17
+ class_=AsyncSession,
18
+ expire_on_commit=False,
19
+ )
20
+
21
+
22
+ class Base(DeclarativeBase):
23
+ """Base class for all database models."""
24
+
25
+ pass
26
+
27
+
28
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
29
+ """Dependency to get database session."""
30
+ async with async_session_maker() as session:
31
+ try:
32
+ yield session
33
+ await session.commit()
34
+ except Exception:
35
+ await session.rollback()
36
+ raise
37
+
38
+
39
+ async def init_db() -> None:
40
+ """Initialize the database, creating all tables."""
41
+ async with engine.begin() as conn:
42
+ await conn.run_sync(Base.metadata.create_all)
43
+
44
+
45
+ async def close_db() -> None:
46
+ """Close database connections."""
47
+ await engine.dispose()