Skip to content

Commit 162118e

Browse files
Ian-bugclaude
andcommitted
chore: bump version to 1.3.7
Major improvements in this release: - Added comprehensive logging system with file and console output - Implemented signal blocking context manager for safer Qt operations - Added complete type hints throughout the codebase - Fixed unsafe bar pool recycling with removed flag tracking - Added system font fallbacks for cross-platform compatibility - Improved error handling with proper exception logging - Added configuration validation with automatic value clamping - Added thread-safety documentation for input monitoring - Removed magic numbers by moving constants to VisualSettings - Fixed bar_color property to use @Property decorator This release represents a significant code quality improvement with production-ready error handling, logging, and cross-platform support. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5e50b87 commit 162118e

File tree

9 files changed

+640
-246
lines changed

9 files changed

+640
-246
lines changed

build.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
import shutil
44
import subprocess
55
import sys
6+
import logging
7+
8+
# Setup basic logging for build script
9+
logging.basicConfig(
10+
level=logging.INFO,
11+
format='%(asctime)s - %(levelname)s - %(message)s',
12+
datefmt='%H:%M:%S'
13+
)
14+
logger = logging.getLogger(__name__)
15+
616
from core.settings_manager import SettingsManager
717

818
CONFIG_FILES = ['config.ini', 'config.cfg']
@@ -18,13 +28,13 @@ def clean_directories():
1828
dirs_to_clean = [OUTPUT_DIR, BUILD_DIR]
1929
for d in dirs_to_clean:
2030
if os.path.exists(d):
21-
print(f"Cleaning {d}...")
31+
logger.info(f"Cleaning {d}...")
2232
shutil.rmtree(d)
2333

2434
def build(debug_mode):
2535
"""Invokes PyInstaller to build the executable."""
26-
print(f"Building {EXECUTABLE_NAME} (Debug Mode: {debug_mode})...")
27-
36+
logger.info(f"Building {EXECUTABLE_NAME} (Debug Mode: {debug_mode})...")
37+
2838
cmd = [
2939
'pyinstaller',
3040
'--onedir',
@@ -41,7 +51,7 @@ def build(debug_mode):
4151
# Note: We don't explicitly add --add-data here because we copy config manually after build.
4252
# If hidden imports are needed (e.g. pynput, PySide6), PyInstaller usually finds them.
4353
# If issues arise, we can add --hidden-import.
44-
54+
4555
# Run PyInstaller
4656
subprocess.check_call(cmd)
4757

@@ -56,42 +66,42 @@ def copy_config_to_dist():
5666
if os.path.exists(cfg):
5767
input_config = cfg
5868
shutil.copy2(input_config, target_dir)
59-
print(f"Copied {input_config} to {target_dir}")
69+
logger.info(f"Copied {input_config} to {target_dir}")
6070
copied = True
6171
break
62-
72+
6373
if not copied:
64-
print("Warning: No config file found to copy.")
74+
logger.warning("No config file found to copy.")
6575

6676
def create_zip(debug_mode):
6777
"""Packages the dist folder into a zip file."""
6878
source_dir = os.path.join(OUTPUT_DIR, EXECUTABLE_NAME)
69-
79+
7080
zip_name = EXECUTABLE_NAME
7181
if debug_mode:
7282
zip_name += "-debug"
73-
83+
7484
# shutil.make_archive expects the base_name without extension
7585
# It creates base_name.zip
76-
77-
print(f"\nPackaging into {zip_name}.zip...")
78-
86+
87+
logger.info(f"Packaging into {zip_name}.zip...")
88+
7989
# format='zip': create a zip file
8090
# root_dir=OUTPUT_DIR: the root directory to archive
8191
# base_dir=EXECUTABLE_NAME: the directory inside root_dir to start archiving from
8292
# This prevents the zip from containing 'dist/...' structure, but rather just the executable folder
83-
93+
8494
# We want the zip to contain the top-level folder 'RainingKeysPython'
8595
# So we archive 'dist' but only the 'RainingKeysPython' subdirectory
86-
96+
8797
shutil.make_archive(zip_name, 'zip', root_dir=OUTPUT_DIR, base_dir=EXECUTABLE_NAME)
88-
print(f"Zip created: {zip_name}.zip")
98+
logger.info(f"Zip created: {zip_name}.zip")
8999

90100
def update_config_debug_mode(debug_mode):
91101
"""Updates config.ini to match the current build mode."""
92102
# We read the config to preserve other settings, but force debug_mode
93103
config = configparser.ConfigParser()
94-
104+
95105
# Manually read to handle potential BOM (Byte Order Mark) issues
96106
read_success = False
97107
for cfg in CONFIG_FILES:
@@ -102,8 +112,8 @@ def update_config_debug_mode(debug_mode):
102112
read_success = True
103113
break
104114
except Exception as e:
105-
print(f"Warning: Could not read {cfg}: {e}")
106-
115+
logger.warning(f"Could not read {cfg}: {e}")
116+
107117
# Ensure sections exist
108118
if not config.has_section('General'):
109119
if config.has_section('Debug'):
@@ -113,41 +123,41 @@ def update_config_debug_mode(debug_mode):
113123
config.add_section('General')
114124
config.set('General', 'debug_mode', str(debug_mode))
115125
else:
116-
config.set('General', 'debug_mode', str(debug_mode))
117-
126+
config.set('General', 'debug_mode', str(debug_mode))
127+
118128
# Write back
119129
# We pick the first available config file to write to, usually config.ini
120130
target_cfg = CONFIG_FILES[0]
121131
with open(target_cfg, 'w', encoding='utf-8') as f:
122132
config.write(f)
123133

124134
def run_build_cycle(debug_mode):
125-
print(f"\n>>> Starting {'DEBUG' if debug_mode else 'RELEASE'} Build Cycle <<<")
126-
135+
logger.info(f"\n>>> Starting {'DEBUG' if debug_mode else 'RELEASE'} Build Cycle <<<")
136+
127137
# Reset config to defaults if building for Release
128138
if not debug_mode:
129-
print("Resetting configuration to defaults for Release build...")
139+
logger.info("Resetting configuration to defaults for Release build...")
130140
try:
131141
settings = SettingsManager()
132142
settings.reset_to_defaults()
133-
print("Configuration reset successful.")
143+
logger.info("Configuration reset successful.")
134144
except Exception as e:
135-
print(f"Warning: Failed to reset configuration: {e}")
145+
logger.warning(f"Failed to reset configuration: {e}")
136146

137147
# Update config file so the built executable reads the correct mode at runtime
138148
# AND so that the copy_config_to_dist puts the correct config in the dist folder
139149
update_config_debug_mode(debug_mode)
140-
150+
141151
# Build
142152
try:
143153
build(debug_mode)
144154
except subprocess.CalledProcessError as e:
145-
print(f"Error during build: {e}")
155+
logger.error(f"Error during build: {e}")
146156
sys.exit(1)
147-
157+
148158
# Copy Config
149159
copy_config_to_dist()
150-
160+
151161
# Zip
152162
create_zip(debug_mode)
153163

@@ -157,25 +167,25 @@ def main():
157167

158168
# 2. Check dependencies
159169
try:
160-
subprocess.call(['pyinstaller', '--version'], stdout=subprocess.DEVNULL)
170+
subprocess.call(['pyinstaller', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
161171
except FileNotFoundError:
162-
print("Error: 'pyinstaller' not found. Please install it via 'pip install pyinstaller'.")
163-
sys.exit(1)
172+
logger.error("'pyinstaller' not found. Please install it via 'pip install pyinstaller'.")
173+
sys.exit(1)
164174

165175
# 3. Release Build (Normal)
166176
run_build_cycle(debug_mode=False)
167-
177+
168178
# 4. Debug Build
169179
# We clean build/ between runs to ensure clean state, but NOT dist/ (so we keep the zips)
170180
if os.path.exists(BUILD_DIR):
171181
shutil.rmtree(BUILD_DIR)
172-
182+
173183
run_build_cycle(debug_mode=True)
174184

175-
print("\nAll builds complete!")
176-
print(f"Artifacts located in project root:")
177-
print(f" - {EXECUTABLE_NAME}.zip")
178-
print(f" - {EXECUTABLE_NAME}-debug.zip")
185+
logger.info("\nAll builds complete!")
186+
logger.info(f"Artifacts located in project root:")
187+
logger.info(f" - {EXECUTABLE_NAME}.zip")
188+
logger.info(f" - {EXECUTABLE_NAME}-debug.zip")
179189

180190
if __name__ == "__main__":
181191
main()

core/configuration.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from dataclasses import dataclass, field
2-
from typing import Dict, List
2+
from typing import Dict, List, Optional
33
from PySide6.QtGui import QColor
4+
from .logging_config import get_logger
5+
6+
logger = get_logger(__name__)
47

58
@dataclass
69
class VisualSettings:
@@ -10,20 +13,59 @@ class VisualSettings:
1013
bar_height: int = 20
1114
bar_color_str: str = "0,255,255,200"
1215
fall_direction: str = "up"
13-
16+
17+
# Display constants
18+
LANE_START_X: int = 50
19+
EXTRA_PADDING: int = 50
20+
KEYVIEWER_OFFSET_Y_TOP: int = 50
21+
KEYVIEWER_OFFSET_Y_BOTTOM: int = 50
22+
FALLBACK_SCREEN_HEIGHT: int = 1080
23+
1424
@property
1525
def bar_color(self) -> QColor:
26+
"""Parse and validate color string, returning QColor."""
1627
try:
17-
r, g, b, a = map(int, self.bar_color_str.split(','))
28+
parts = self.bar_color_str.split(',')
29+
if len(parts) != 4:
30+
raise ValueError("Color must have 4 components (R,G,B,A)")
31+
32+
r, g, b, a = map(int, parts)
33+
# Validate ranges
34+
if not all(0 <= x <= 255 for x in [r, g, b, a]):
35+
raise ValueError("Color values must be between 0 and 255")
36+
1837
return QColor(r, g, b, a)
19-
except ValueError:
38+
except (ValueError, AttributeError) as e:
39+
logger.warning(f"Invalid color string '{self.bar_color_str}': {e}. Using default.")
2040
return QColor(0, 255, 255, 200)
2141

2242
@dataclass
2343
class PositionSettings:
2444
x: int = 0
2545
y: int = 0
2646

47+
def validate(self) -> bool:
48+
"""Validate position values are within reasonable bounds.
49+
50+
Returns:
51+
True if all values were valid, False if any were clamped.
52+
"""
53+
MIN_POS = -10000
54+
MAX_POS = 10000
55+
valid = True
56+
57+
if not (MIN_POS <= self.x <= MAX_POS):
58+
logger.warning(f"X position {self.x} out of range, clamping to [{MIN_POS}, {MAX_POS}]")
59+
self.x = max(MIN_POS, min(MAX_POS, self.x))
60+
valid = False
61+
62+
if not (MIN_POS <= self.y <= MAX_POS):
63+
logger.warning(f"Y position {self.y} out of range, clamping to [{MIN_POS}, {MAX_POS}]")
64+
self.y = max(MIN_POS, min(MAX_POS, self.y))
65+
valid = False
66+
67+
return valid
68+
2769
@dataclass
2870
class KeyViewerSettings:
2971
enabled: bool = True
@@ -35,6 +77,34 @@ class KeyViewerSettings:
3577
height: int = 60
3678
opacity: float = 0.2
3779

80+
def validate(self) -> bool:
81+
"""Validate KeyViewer settings.
82+
83+
Returns:
84+
True if all values were valid, False if any were clamped/changed.
85+
"""
86+
valid = True
87+
88+
# Validate height
89+
if not (10 <= self.height <= 500):
90+
logger.warning(f"KeyViewer height {self.height} out of range [10, 500], clamping")
91+
self.height = max(10, min(500, self.height))
92+
valid = False
93+
94+
# Validate opacity
95+
if not (0.0 <= self.opacity <= 1.0):
96+
logger.warning(f"KeyViewer opacity {self.opacity} out of range [0.0, 1.0], clamping")
97+
self.opacity = max(0.0, min(1.0, self.opacity))
98+
valid = False
99+
100+
# Validate panel_position
101+
if self.panel_position not in ("above", "below"):
102+
logger.warning(f"Invalid panel_position '{self.panel_position}', using 'below'")
103+
self.panel_position = "below"
104+
valid = False
105+
106+
return valid
107+
38108
@dataclass
39109
class AppConfig:
40110
visual: VisualSettings = field(default_factory=VisualSettings)
@@ -48,13 +118,18 @@ class AppConfig:
48118
FADE_START_Y: int = 800
49119
FADE_RANGE: int = 200
50120
DEBUG_MODE: bool = False
51-
VERSION: str = "1.3.5"
121+
VERSION: str = "1.3.7"
52122

53123
def __post_init__(self):
54124
if not self.lane_map:
55125
self.lane_map = {'d': 0, 'f': 1, 'j': 2, 'k': 3}
56126

57-
def set_lane_keys(self, keys: List[str]):
127+
def set_lane_keys(self, keys: List[str]) -> None:
128+
"""Set lane key mapping.
129+
130+
Args:
131+
keys: List of key strings to map to lanes (by index).
132+
"""
58133
new_map = {}
59134
for idx, key in enumerate(keys):
60135
new_map[key] = idx

0 commit comments

Comments
 (0)