Skip to content

Commit 7b8e670

Browse files
committed
Enforce SonarQube/Codacy rules; split platform_wrapper backends
- CLAUDE.md: add Static Analysis Compliance section covering complexity, exception chaining, Bandit security, resource management, and naming. - Replace print() in library code with autocontrol_logger across utils, wrapper, osx, and linux backends to satisfy python:S4792. - Narrow bare `except Exception` to specific exception tuples and add `raise ... from error` for chain preservation (python:S5655). - Remove shell=True from ShellManager; normalize commands via shlex to argv list, eliminating Bandit B602. - Replace wildcard Cocoa/Foundation imports in osx_listener with explicit symbols (python:S2208). - Drop sys.argv side effects from start_autocontrol_socket_server; bind default host to 127.0.0.1 for least privilege. - Split platform_wrapper monolith into _platform_linux / _platform_osx / _platform_windows to satisfy the 750-line file limit.
1 parent 11070b4 commit 7b8e670

30 files changed

Lines changed: 1215 additions & 1263 deletions

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,83 @@ python -m build
133133
- JSON action command names use `AC_` prefix (e.g., `AC_click_mouse`).
134134
- Platform backends follow naming: `{platform}_{function}.py` (e.g., `win32_ctype_mouse_control.py`).
135135
- Virtual key mappings are in `core/utils/*_vk.py` per platform.
136+
137+
## Static Analysis Compliance (SonarQube / Codacy / Pylint / Bandit)
138+
139+
All code must satisfy the following rules so automated scanners (SonarQube, Codacy, Pylint, Bandit, Radon, Prospector) report zero new issues.
140+
141+
### Complexity & Size Limits
142+
143+
- **Cyclomatic complexity** per function ≤ 10. Refactor with early returns, extracted helpers, or lookup dicts when exceeded.
144+
- **Cognitive complexity** per function ≤ 15 (SonarQube `python:S3776`).
145+
- **Function length** ≤ 75 lines. Long procedural flows must be split into named helpers.
146+
- **Parameter count** ≤ 7 (`python:S107`). Group related parameters into a dataclass or config object when exceeded.
147+
- **Nesting depth** ≤ 4 (`python:S134`). Flatten with guard clauses.
148+
- **File length** ≤ 750 lines. Split large modules along responsibility lines.
149+
- **Identical branches**: `if`/`elif`/`else` branches must not have identical bodies (`python:S3923`).
150+
- **Duplicated code**: no duplicated blocks ≥ 10 lines across the project (Sonar default). Extract to a shared helper.
151+
152+
### Bug & Correctness Rules
153+
154+
- **No bare `except:`** — always catch a specific exception type (`python:S5754`, Bandit `B001`).
155+
- **No empty `except` blocks** (`python:S2737`). At minimum log the error or re-raise.
156+
- **Preserve exception chain**: inside `except`, raising a new exception must use `raise NewError(...) from exc` (`python:S5655`).
157+
- **No mutable default arguments** (`python:S5727`): never use `def f(x=[])` or `{}`. Use `None` + lazy init.
158+
- **No unused imports, variables, parameters, or assignments** (`python:S1481`, `S1854`, `S1172`). Remove them; do not rename to `_unused`.
159+
- **No dead / unreachable code** (`python:S1763`).
160+
- **No commented-out code blocks** (`python:S125`) — delete instead; git history preserves it.
161+
- **No `TODO`/`FIXME`/`XXX`** without an issue-tracker reference in the same comment (`python:S1135`).
162+
- **No `print()`** in library code (`je_auto_control/` outside `gui/` stdout tooling). Use the project logger.
163+
- **No `assert` for runtime checks** in non-test code (Bandit `B101`) — `assert` is stripped with `-O`. Raise explicit exceptions.
164+
- **String formatting**: prefer f-strings over `%` or `.format()` for readability; never interpolate untrusted data into shell/SQL.
165+
- **Equality with `None`/`True`/`False`**: use `is` / `is not`, never `==` (`python:S2589`).
166+
- **Boolean simplification**: no `if cond: return True else: return False` — return the expression directly (`python:S1126`).
167+
- **Identical expressions on both sides** of `and`/`or`/`==`/`!=` are forbidden (`python:S1764`).
168+
169+
### Security Rules (Bandit / Sonar Security Hotspots)
170+
171+
- **No `eval`, `exec`, `compile`** on any runtime-sourced string (Bandit `B307`, `B102`).
172+
- **No `pickle`, `marshal`, `shelve`, `dill`** on data from disk, network, or user input (Bandit `B301`, `B302`). Use JSON with schema validation.
173+
- **No `subprocess` with `shell=True`** or string-built command lines (Bandit `B602`, `B605`). Pass argv lists and validate against allowlists.
174+
- **No `os.system`, `os.popen`, `commands.*`** (Bandit `B605`, `B607`).
175+
- **No insecure hash** (`md5`, `sha1`) for security purposes (Bandit `B303`, `B324`). Use `hashlib.sha256` or better.
176+
- **No `tempfile.mktemp`** — use `NamedTemporaryFile` / `mkstemp` (Bandit `B306`).
177+
- **No hardcoded passwords, tokens, or secrets** (Bandit `B105``B107`, `python:S2068`).
178+
- **No `yaml.load`** without `SafeLoader` (Bandit `B506`).
179+
- **`requests`/`urllib` calls** must set explicit `timeout=` (`python:S5332`, Bandit `B113`).
180+
- **No `ssl._create_unverified_context` / `verify=False`** (Bandit `B501`).
181+
- **Path traversal**: validate and `os.path.realpath` user-supplied paths before I/O.
182+
- **Socket binds** must default to `127.0.0.1`; `0.0.0.0` requires an explicit, documented opt-in.
183+
184+
### Resource & Concurrency
185+
186+
- **Always use `with`** for files, sockets, locks, and OpenCV `VideoCapture`/`VideoWriter` (`python:S5720`). No manual `close()` in normal flow.
187+
- **Release platform resources** (GDI handles, Quartz event sources, X display) in `finally` or `__exit__`.
188+
- **Thread-safety**: shared mutable state between the socket server, recording thread, and callback executor must be guarded by `threading.Lock` / `queue.Queue`.
189+
190+
### Style & Naming
191+
192+
- **snake_case** for functions, methods, variables, modules; **PascalCase** for classes; **UPPER_SNAKE_CASE** for module-level constants (`python:S117`, Pylint `C0103`).
193+
- **Max line length**: 120 chars (`python:S103`).
194+
- **Docstrings** on every public module, class, and function (`python:S1720`, Pylint `C0114``C0116`) — one-line summary minimum; type hints replace parameter-type prose.
195+
- **Import order**: stdlib → third-party → first-party, separated by blank lines; no wildcard imports except in `__init__.py` façade (`python:S2208`).
196+
- **No `global`** statements outside module initialization (`python:S2208`).
197+
198+
### Test Hygiene
199+
200+
- Tests must avoid `assert` against object identity of mutable literals and must not depend on execution order (Pylint / Sonar `python:S5914`).
201+
- No `time.sleep` > 1s in unit tests; use fakes / event signals.
202+
203+
### Automated Verification
204+
205+
Run before every commit; fix all new findings:
206+
207+
```bash
208+
pip install ruff pylint bandit radon
209+
ruff check je_auto_control/
210+
pylint je_auto_control/
211+
bandit -r je_auto_control/ -x je_auto_control/test
212+
radon cc je_auto_control/ -a -nc # flags functions with CC >= C (>10)
213+
```
214+
215+
If a rule must be suppressed, add an inline justification: `# noqa: <code> # reason: <why>` or `# nosec B404 # reason: <why>`. Blanket suppressions at file/module level are forbidden.

je_auto_control/__main__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from je_auto_control.utils.file_process.get_dir_file_list import \
1313
get_dir_files_as_list
1414
from je_auto_control.utils.json.json_file import read_action_json
15+
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
1516
from je_auto_control.utils.project.create_project_structure import create_project_dir
1617

1718
if __name__ == "__main__":
@@ -61,6 +62,9 @@ def preprocess_read_str_execute_action(execute_str: str):
6162
argparse_event_dict.get(key)(value)
6263
if all(value is None for value in args.values()):
6364
raise AutoControlArgparseException(argparse_get_wrong_data_error_message)
64-
except Exception as error:
65-
print(repr(error), file=sys.stderr)
65+
except AutoControlArgparseException as error:
66+
autocontrol_logger.error("argparse failure: %r", error)
67+
sys.exit(1)
68+
except (OSError, ValueError, RuntimeError) as error:
69+
autocontrol_logger.error("cli execution failed: %r", error)
6670
sys.exit(1)
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
from PySide6.QtGui import QIntValidator
2+
from PySide6.QtWidgets import (
3+
QWidget, QLineEdit, QComboBox, QPushButton, QVBoxLayout, QLabel,
4+
QGridLayout, QHBoxLayout, QRadioButton, QButtonGroup, QMessageBox,
5+
QGroupBox,
6+
)
7+
8+
from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper
9+
from je_auto_control.wrapper.auto_control_keyboard import (
10+
type_keyboard, hotkey, write, get_keyboard_keys_table,
11+
)
12+
from je_auto_control.wrapper.auto_control_mouse import (
13+
click_mouse, get_mouse_position, mouse_scroll,
14+
mouse_keys_table, special_mouse_keys_table,
15+
)
16+
17+
18+
def _t(key: str) -> str:
19+
return language_wrapper.language_word_dict.get(key, key)
20+
21+
22+
class AutoClickTabMixin:
23+
"""
24+
Mixin that provides the auto-click tab UI and handlers.
25+
Requires the host widget to expose `self.timer`, `self.repeat_count`,
26+
`self.repeat_max` attributes set in its __init__.
27+
"""
28+
29+
def _build_auto_click_tab(self) -> QWidget:
30+
tab = QWidget()
31+
outer = QVBoxLayout()
32+
33+
click_group = QGroupBox(_t("tab_auto_click"))
34+
grid = QGridLayout()
35+
row = 0
36+
37+
grid.addWidget(QLabel(_t("input_method")), row, 0)
38+
self.mouse_radio = QRadioButton(_t("mouse_radio"))
39+
self.keyboard_radio = QRadioButton(_t("keyboard_radio"))
40+
self.mouse_radio.setChecked(True)
41+
self._input_group = QButtonGroup()
42+
self._input_group.addButton(self.mouse_radio)
43+
self._input_group.addButton(self.keyboard_radio)
44+
h = QHBoxLayout()
45+
h.addWidget(self.mouse_radio)
46+
h.addWidget(self.keyboard_radio)
47+
grid.addLayout(h, row, 1)
48+
49+
row += 1
50+
grid.addWidget(QLabel(_t("interval_time")), row, 0)
51+
self.interval_input = QLineEdit("1000")
52+
self.interval_input.setValidator(QIntValidator(1, 999999999))
53+
grid.addWidget(self.interval_input, row, 1)
54+
55+
row += 1
56+
grid.addWidget(QLabel(_t("cursor_x")), row, 0)
57+
self.cursor_x_input = QLineEdit()
58+
self.cursor_x_input.setValidator(QIntValidator())
59+
grid.addWidget(self.cursor_x_input, row, 1)
60+
61+
row += 1
62+
grid.addWidget(QLabel(_t("cursor_y")), row, 0)
63+
self.cursor_y_input = QLineEdit()
64+
self.cursor_y_input.setValidator(QIntValidator())
65+
grid.addWidget(self.cursor_y_input, row, 1)
66+
67+
row += 1
68+
grid.addWidget(QLabel(_t("mouse_button")), row, 0)
69+
self.mouse_button_combo = QComboBox()
70+
self.mouse_button_combo.addItems(
71+
list(mouse_keys_table.keys()) if isinstance(mouse_keys_table, dict) else list(mouse_keys_table)
72+
)
73+
grid.addWidget(self.mouse_button_combo, row, 1)
74+
75+
row += 1
76+
grid.addWidget(QLabel(_t("keyboard_button")), row, 0)
77+
self.keyboard_button_combo = QComboBox()
78+
self.keyboard_button_combo.addItems(list(get_keyboard_keys_table().keys()))
79+
grid.addWidget(self.keyboard_button_combo, row, 1)
80+
81+
row += 1
82+
grid.addWidget(QLabel(_t("click_type")), row, 0)
83+
self.click_type_combo = QComboBox()
84+
self.click_type_combo.addItems([_t("single_click"), _t("double_click")])
85+
grid.addWidget(self.click_type_combo, row, 1)
86+
87+
row += 1
88+
self.repeat_until_stopped = QRadioButton(_t("repeat_until_stopped_radio"))
89+
self.repeat_count_times = QRadioButton(_t("repeat_radio"))
90+
self.repeat_count_input = QLineEdit()
91+
self.repeat_count_input.setValidator(QIntValidator(1, 999999999))
92+
self.repeat_count_input.setPlaceholderText(_t("times"))
93+
rg = QButtonGroup(tab)
94+
rg.addButton(self.repeat_until_stopped)
95+
rg.addButton(self.repeat_count_times)
96+
self.repeat_until_stopped.setChecked(True)
97+
rh = QHBoxLayout()
98+
rh.addWidget(self.repeat_until_stopped)
99+
rh.addWidget(self.repeat_count_times)
100+
rh.addWidget(self.repeat_count_input)
101+
grid.addLayout(rh, row, 0, 1, 2)
102+
103+
row += 1
104+
btn_h = QHBoxLayout()
105+
self.start_button = QPushButton(_t("start"))
106+
self.start_button.clicked.connect(self._start_auto_click)
107+
self.stop_button = QPushButton(_t("stop"))
108+
self.stop_button.clicked.connect(self._stop_auto_click)
109+
btn_h.addWidget(self.start_button)
110+
btn_h.addWidget(self.stop_button)
111+
grid.addLayout(btn_h, row, 0, 1, 2)
112+
113+
click_group.setLayout(grid)
114+
outer.addWidget(click_group)
115+
116+
pos_group = QGroupBox(_t("get_position"))
117+
pos_layout = QHBoxLayout()
118+
self.pos_btn = QPushButton(_t("get_position"))
119+
self.pos_btn.clicked.connect(self._get_mouse_pos)
120+
self.pos_label = QLabel(_t("current_position") + " --")
121+
pos_layout.addWidget(self.pos_btn)
122+
pos_layout.addWidget(self.pos_label)
123+
pos_group.setLayout(pos_layout)
124+
outer.addWidget(pos_group)
125+
126+
hotkey_group = QGroupBox(_t("hotkey_label"))
127+
hk_layout = QHBoxLayout()
128+
self.hotkey_input = QLineEdit()
129+
self.hotkey_input.setPlaceholderText("ctrl,a")
130+
self.hotkey_btn = QPushButton(_t("hotkey_send"))
131+
self.hotkey_btn.clicked.connect(self._send_hotkey)
132+
hk_layout.addWidget(self.hotkey_input)
133+
hk_layout.addWidget(self.hotkey_btn)
134+
hotkey_group.setLayout(hk_layout)
135+
outer.addWidget(hotkey_group)
136+
137+
write_group = QGroupBox(_t("write_label"))
138+
wr_layout = QHBoxLayout()
139+
self.write_input = QLineEdit()
140+
self.write_btn = QPushButton(_t("write_send"))
141+
self.write_btn.clicked.connect(self._send_write)
142+
wr_layout.addWidget(self.write_input)
143+
wr_layout.addWidget(self.write_btn)
144+
write_group.setLayout(wr_layout)
145+
outer.addWidget(write_group)
146+
147+
scroll_group = QGroupBox(_t("mouse_scroll_label"))
148+
sc_layout = QHBoxLayout()
149+
self.scroll_value_input = QLineEdit("3")
150+
self.scroll_value_input.setValidator(QIntValidator())
151+
sc_layout.addWidget(QLabel(_t("mouse_scroll_label")))
152+
sc_layout.addWidget(self.scroll_value_input)
153+
if special_mouse_keys_table:
154+
self.scroll_dir_combo = QComboBox()
155+
self.scroll_dir_combo.addItems(list(special_mouse_keys_table.keys()))
156+
sc_layout.addWidget(self.scroll_dir_combo)
157+
else:
158+
self.scroll_dir_combo = None
159+
self.scroll_btn = QPushButton(_t("scroll_send"))
160+
self.scroll_btn.clicked.connect(self._send_scroll)
161+
sc_layout.addWidget(self.scroll_btn)
162+
scroll_group.setLayout(sc_layout)
163+
outer.addWidget(scroll_group)
164+
165+
outer.addStretch()
166+
167+
self.mouse_radio.toggled.connect(self._update_click_mode)
168+
self._update_click_mode()
169+
170+
tab.setLayout(outer)
171+
return tab
172+
173+
def _update_click_mode(self):
174+
use_mouse = self.mouse_radio.isChecked()
175+
self.cursor_x_input.setEnabled(use_mouse)
176+
self.cursor_y_input.setEnabled(use_mouse)
177+
self.mouse_button_combo.setEnabled(use_mouse)
178+
self.keyboard_button_combo.setEnabled(not use_mouse)
179+
180+
def _start_auto_click(self):
181+
try:
182+
interval = int(self.interval_input.text())
183+
except ValueError:
184+
QMessageBox.warning(self, "Warning", "Interval must be a number")
185+
return
186+
self.repeat_count = 0
187+
try:
188+
self.repeat_max = int(self.repeat_count_input.text())
189+
except ValueError:
190+
self.repeat_max = 0
191+
self.timer.setInterval(interval)
192+
try:
193+
self.timer.timeout.disconnect(self._timer_tick)
194+
except RuntimeError:
195+
pass
196+
self.timer.timeout.connect(self._timer_tick)
197+
self.timer.start()
198+
199+
def _stop_auto_click(self):
200+
self.timer.stop()
201+
202+
def _timer_tick(self):
203+
if self.repeat_until_stopped.isChecked():
204+
self._do_click()
205+
elif self.repeat_count_times.isChecked():
206+
self.repeat_count += 1
207+
if self.repeat_count <= self.repeat_max:
208+
self._do_click()
209+
else:
210+
self.repeat_count = 0
211+
self.timer.stop()
212+
213+
def _do_click(self):
214+
try:
215+
is_double = self.click_type_combo.currentIndex() == 1
216+
if self.mouse_radio.isChecked():
217+
btn = self.mouse_button_combo.currentText()
218+
x = int(self.cursor_x_input.text() or "0")
219+
y = int(self.cursor_y_input.text() or "0")
220+
click_mouse(btn, x, y)
221+
if is_double:
222+
click_mouse(btn, x, y)
223+
else:
224+
key = self.keyboard_button_combo.currentText()
225+
type_keyboard(key)
226+
if is_double:
227+
type_keyboard(key)
228+
except (OSError, ValueError, TypeError, RuntimeError) as error:
229+
self.timer.stop()
230+
QMessageBox.warning(self, "Error", str(error))
231+
232+
def _get_mouse_pos(self):
233+
try:
234+
x, y = get_mouse_position()
235+
self.pos_label.setText(_t("current_position") + f" ({x}, {y})")
236+
self.cursor_x_input.setText(str(x))
237+
self.cursor_y_input.setText(str(y))
238+
except (OSError, ValueError, TypeError, RuntimeError) as error:
239+
QMessageBox.warning(self, "Error", str(error))
240+
241+
def _send_hotkey(self):
242+
try:
243+
keys = [k.strip() for k in self.hotkey_input.text().split(",") if k.strip()]
244+
if keys:
245+
hotkey(keys)
246+
except (OSError, ValueError, TypeError, RuntimeError) as error:
247+
QMessageBox.warning(self, "Error", str(error))
248+
249+
def _send_write(self):
250+
try:
251+
text = self.write_input.text()
252+
if text:
253+
write(text)
254+
except (OSError, ValueError, TypeError, RuntimeError) as error:
255+
QMessageBox.warning(self, "Error", str(error))
256+
257+
def _send_scroll(self):
258+
try:
259+
val = int(self.scroll_value_input.text() or "3")
260+
direction = self.scroll_dir_combo.currentText() if self.scroll_dir_combo else "scroll_down"
261+
mouse_scroll(val, scroll_direction=direction)
262+
except (OSError, ValueError, TypeError, RuntimeError) as error:
263+
QMessageBox.warning(self, "Error", str(error))

0 commit comments

Comments
 (0)