• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

emcek / dcspy / 28656188291

03 Jul 2026 10:59AM UTC coverage: 98.155% (-0.08%) from 98.232%
28656188291

Pull #564

github

emcek
update dependencies
Pull Request #564: Improve first run for new user

394 of 394 branches covered (100.0%)

Branch coverage included in aggregate %.

17 of 18 new or added lines in 2 files covered. (94.44%)

19 existing lines in 3 files now uncovered.

4714 of 4810 relevant lines covered (98.0%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.34
/src/dcspy/utils.py
1
from __future__ import annotations
1✔
2

3
import hashlib
1✔
4
import json
1✔
5
import sys
1✔
6
import zipfile
1✔
7
from collections.abc import Callable, Generator, Sequence
1✔
8
from contextlib import suppress
1✔
9
from datetime import datetime
1✔
10
from functools import lru_cache
1✔
11
from glob import glob
1✔
12
from logging import getLogger
1✔
13
from os import chdir, environ, getcwd, makedirs, walk
1✔
14
from pathlib import Path
1✔
15
from platform import python_implementation, python_version, uname
1✔
16
from pprint import pformat
1✔
17
from re import search, sub
1✔
18
from shutil import rmtree
1✔
19
from subprocess import CalledProcessError, run
1✔
20
from tempfile import gettempdir
1✔
21
from typing import Any, ClassVar
1✔
22

23
import yaml
1✔
24
from packaging import version
1✔
25
from PIL import ImageColor
1✔
26
from requests import get
1✔
27

28
from dcspy.models import (CONFIG_YAML, CTRL_LIST_SEPARATOR, DEFAULT_YAML_FILE, AnyButton, BiosValue, ButtonTypes, Color, ControlDepiction, ControlKeyData,
1✔
29
                          DcsBiosPlaneData, DcspyConfigYaml, Gkey, LcdButton, LcdMode, MouseButton, Release, RequestModel, __version__)
30

31
with suppress(ImportError):
1✔
32
    import git
1✔
33

34
    class CloneProgress(git.RemoteProgress):
1✔
35
        """Handler providing an interface to parse progress information emitted by git."""
36

37
        OP_CODES: ClassVar[list[str]] = ['BEGIN', 'CHECKING_OUT', 'COMPRESSING', 'COUNTING', 'END', 'FINDING_SOURCES', 'RECEIVING', 'RESOLVING', 'WRITING']
1✔
38
        OP_CODE_MAP: ClassVar[dict[int, str]] = {getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES}
1✔
39

40
        def __init__(self, progress, stage) -> None:
1✔
41
            """
42
            Initialize the progress handler.
43

44
            :param progress: Progress Qt6 signal
45
            :param stage: Report stage Qt6 signal
46
            """
47
            super().__init__()
1✔
48
            self.progress_signal = progress
1✔
49
            self.stage_signal = stage
1✔
50

51
        def get_curr_op(self, op_code: int) -> str:
1✔
52
            """
53
            Get a stage name from OP code.
54

55
            :param op_code: OP code
56
            :return: stage name
57
            """
58
            op_code_masked = op_code & self.OP_MASK
1✔
59
            return self.OP_CODE_MAP.get(op_code_masked, '?').title()
1✔
60

61
        def update(self, op_code: int, cur_count: str | float, max_count: str | float | None = None, message: str = '') -> None:
1✔
62
            """
63
            Call whenever the progress changes.
64

65
            :param op_code: Integer allowing to be compared against Operation IDs and stage IDs.
66
            :param cur_count: A count of current absolute items
67
            :param max_count: The maximum count of items we expect. It may be None in case there is no maximum number of items or if it is (yet) unknown.
68
            :param message: It contains the number of bytes transferred. It may be used for other purposes as well.
69
            """
70
            if op_code & git.RemoteProgress.BEGIN:
1✔
71
                self.stage_signal.emit(f'Git clone: {self.get_curr_op(op_code)}')
1✔
72

73
            percentage = float(cur_count) / float(max_count) * 100 if max_count else 0
1✔
74
            self.progress_signal.emit(int(percentage))
1✔
75

76

77
LOG = getLogger(__name__)
1✔
78

79
with open(DEFAULT_YAML_FILE) as c_file:
1✔
80
    defaults_cfg: DcspyConfigYaml = yaml.load(c_file, Loader=yaml.SafeLoader)
1✔
81
    defaults_cfg['dcsbios'] = f'C:\\Users\\{environ.get("USERNAME", "UNKNOWN")}\\Saved Games\\DCS\\Scripts\\DCS-BIOS'
1✔
82

83

84
def get_default_yaml(local_appdata: bool = False) -> Path:
1✔
85
    """
86
    Return a full path to the default configuration file.
87

88
    :param local_appdata: If True value C:/Users/<user_name>/AppData/Local is used
89
    :return: Path like an object
90
    """
91
    cfg_ful_path = DEFAULT_YAML_FILE
1✔
92
    if local_appdata:
1✔
93
        user_appdata = get_config_yaml_location()
1✔
94
        makedirs(name=user_appdata, exist_ok=True)
1✔
95
        cfg_ful_path = Path(user_appdata / CONFIG_YAML).resolve()
1✔
96
        if not cfg_ful_path.exists():
1✔
97
            save_yaml(data=defaults_cfg, full_path=cfg_ful_path)
1✔
98
    return cfg_ful_path
1✔
99

100

101
def load_yaml(full_path: Path) -> DcspyConfigYaml:
1✔
102
    """
103
    Load YAML from a file into a dictionary.
104

105
    :param full_path: Full path to YAML file
106
    :return: Dictionary with data
107
    """
108
    try:
1✔
109
        with open(file=full_path, encoding='utf-8') as yaml_file:
1✔
110
            data = yaml.load(yaml_file, Loader=yaml.SafeLoader)
1✔
111
            if not isinstance(data, dict):
1✔
112
                data = {}
1✔
113
    except (FileNotFoundError, yaml.parser.ParserError) as err:
1✔
114
        makedirs(name=full_path.parent, exist_ok=True)
1✔
115
        LOG.warning(f'{type(err).__name__}: {full_path}.', exc_info=True)
1✔
116
        LOG.debug(f'{err}')
1✔
117
        data = {}
1✔
118
    return data
1✔
119

120

121
def save_yaml(data: DcspyConfigYaml, full_path: Path) -> None:
1✔
122
    """
123
    Save data as the YAML file.
124

125
    :param data: Dictionary with data
126
    :param full_path: Full a path to YAML file
127
    """
128
    with open(file=full_path, mode='w', encoding='utf-8') as yaml_file:
1✔
129
        yaml.dump(data, yaml_file, Dumper=yaml.SafeDumper)
1✔
130

131

132
def check_ver_at_github(repo: str) -> Release:
1✔
133
    """
134
    Check a version of <organization>/<package> at GitHub.
135

136
    :param repo: Format '<organization or user>/<package>'
137
    :return: Release object with data
138
    """
139
    package = repo.split('/')[1]
1✔
140
    try:
1✔
141
        response = get(url=f'https://api.github.com/repos/{repo}/releases/latest', timeout=5)
1✔
142
        if response.ok:
1✔
143
            rel = Release(**response.json())
1✔
144
            LOG.debug(f'Latest GitHub release: {rel}')
1✔
145
            return rel
1✔
146
        else:
147
            raise ValueError(f'Try again later. Status={response.status_code}')
1✔
148
    except Exception as exc:
1✔
149
        raise ValueError(f'Unable to check {package} version online: {exc}')
1✔
150

151

152
def get_version_string(repo: str, current_ver: str | version.Version, check: bool = True) -> str:
1✔
153
    """
154
    Generate a formatted string with a version number.
155

156
    :param repo: Format '<organization or user>/<package>'.
157
    :param current_ver: String or Version object.
158
    :param check: Version online.
159
    :return: Formatted version as a string.
160
    """
161
    ver_string = f'v{current_ver}'
1✔
162
    if check:
1✔
163
        try:
1✔
164
            details = ''
1✔
165
            result = check_ver_at_github(repo=repo)
1✔
166
        except ValueError:
1✔
167
            return f'v{current_ver} (failed)'
1✔
168

169
        if result.is_latest(current_ver=current_ver):
1✔
170
            details = ' (latest)'
1✔
171
        elif result.version != version.parse('0.0.0'):
1✔
172
            details = ' (update!)'
1✔
173
            current_ver = result.version
1✔
174
        ver_string = f'v{current_ver}{details}'
1✔
175
    return ver_string
1✔
176

177

178
def download_file(url: str, save_path: Path, progress_fn: Callable[[int], None] | None = None) -> bool:
1✔
179
    """
180
    Download a file from URL and save to save_path.
181

182
    :param url: URL address
183
    :param save_path: full path to save
184
    :param progress_fn: a callable object to report download progress
185
    """
186
    response = get(url=url, stream=True, timeout=5)
1✔
187
    if response.ok:
1✔
188
        file_size = int(response.headers.get('Content-Length', 0))
1✔
189
        LOG.debug(f'File size: {file_size / (1024 * 1024):.2f} MB' if file_size else 'File size: Unknown')
1✔
190
        LOG.debug(f'Download file from: {url}')
1✔
191
        with open(save_path, 'wb+') as dl_file:
1✔
192
            downloaded = 0
1✔
193
            progress = 0
1✔
194
            for chunk in response.iter_content(chunk_size=1024):
1✔
195
                dl_file.write(chunk)
1✔
196
                downloaded += len(chunk)
1✔
197
                new_progress = int((downloaded / file_size) * 100)
1✔
198
                if progress_fn and new_progress == progress + 1:
1✔
199
                    progress = new_progress
×
200
                    progress_fn(progress)
×
201
            LOG.debug(f'Saved as: {save_path}')
1✔
202
            return True
1✔
203
    else:
204
        LOG.warning(f'Can not download from: {url}')
1✔
205
        return False
1✔
206

207

208
def check_dcs_ver(dcs_path: Path) -> str:
1✔
209
    """
210
    Check DCS version and release type.
211

212
    :param dcs_path: Path to DCS installation directory
213
    :return: DCS version as strings
214
    """
215
    result_ver = 'Unknown'
1✔
216
    try:
1✔
217
        with open(file=dcs_path / 'autoupdate.cfg', encoding='utf-8') as autoupdate_cfg:
1✔
218
            autoupdate_data = autoupdate_cfg.read()
1✔
219
    except (FileNotFoundError, PermissionError) as err:
1✔
220
        LOG.debug(f'{type(err).__name__}: {err.filename}')
1✔
221
    else:
222
        if dcs_ver := search(r'"version":\s"([\d.]*)"', autoupdate_data):
1✔
223
            result_ver = str(dcs_ver.group(1))
1✔
224
    return result_ver
1✔
225

226

227
def check_bios_ver(bios_path: Path | str) -> version.Version:
1✔
228
    """
229
    Check the DSC-BIOS release version.
230

231
    :param bios_path: Path to DCS-BIOS directory in the SavedGames folder
232
    :return: Version object
233
    """
234
    bios_ver = version.parse('0.0.0')
1✔
235
    new_location = Path(bios_path) / 'lib' / 'modules' / 'common_modules' / 'CommonData.lua'
1✔
236
    old_location = Path(bios_path) / 'lib' / 'CommonData.lua'
1✔
237

238
    if new_location.is_file():
1✔
239
        with open(file=new_location, encoding='utf-8') as cd_lua:
1✔
240
            cd_lua_data = cd_lua.read()
1✔
241
    elif old_location.is_file():
1✔
242
        with open(file=old_location, encoding='utf-8') as cd_lua:
1✔
243
            cd_lua_data = cd_lua.read()
1✔
244
    else:
245
        cd_lua_data = ''
1✔
246
        LOG.debug(f'No `CommonData.lua` while checking DCS-BIOS version at {new_location.parent} or {old_location.parent}')
1✔
247

248
    if bios_re := search(r'function getVersion\(\)\s*return\s*\"([\d.]*)\"', cd_lua_data):
1✔
249
        bios_ver = version.parse(bios_re.group(1))
1✔
250
    return bios_ver
1✔
251

252

253
def is_git_repo(dir_path: str) -> bool:
1✔
254
    """
255
    Check if dir_path ios Git repository.
256

257
    :param dir_path: Path as string
258
    :return: True if dir is a git repo
259
    """
260
    import git
1✔
261
    try:
1✔
262
        _ = git.Repo(dir_path).git_dir
1✔
263
        return True
1✔
264
    except (git.InvalidGitRepositoryError, git.exc.NoSuchPathError):
1✔
265
        return False
1✔
266

267

268
def _get_sha_hex_str(bios_repo: git.Repo, git_ref: str) -> str:
1✔
269
    """
270
    Return a string representing the commit hash, date, and author of the given Git reference in the provided repository.
271

272
    :param bios_repo: A Git repository object.
273
    :param git_ref: A string representing the Git reference (e.g., commit, branch, tag).
274
    :return: A string representing the commit hash, date, and author.
275
    """
276
    try:
1✔
277
        import git
1✔
278
    except ImportError:
×
279
        raise OSError('Git executable is not available!')
×
280
    try:
1✔
281
        bios_repo.git.checkout(git_ref)
1✔
282
        branch = bios_repo.active_branch.name
1✔
283
        head_commit = bios_repo.head.commit
1✔
284
        sha = f'{branch} from: {head_commit.committed_datetime.strftime("%d-%b-%Y %H:%M:%S")} by: {head_commit.author}'
1✔
285
    except (git.exc.GitCommandError, TypeError):
×
286
        head_commit = bios_repo.head.commit
×
287
        sha = f'{head_commit.hexsha[0:8]} from: {head_commit.committed_datetime.strftime("%d-%b-%Y %H:%M:%S")} by: {head_commit.author}'
×
288
    LOG.debug(f'Checkout: {head_commit.hexsha} from: {head_commit.committed_datetime} | by: {head_commit.author}\n{head_commit.message!r}')
1✔
289
    return sha
1✔
290

291

292
def check_github_repo(git_ref: str, repo_dir: Path, repo: str, update: bool = True, progress: git.RemoteProgress | None = None) -> str:
1✔
293
    """
294
    Update git repository.
295

296
    Return SHA of the latest commit.
297

298
    :param git_ref: Any Git reference as a string
299
    :param repo_dir: Local directory for a repository
300
    :param repo: GitHub repository address
301
    :param update: Perform update process
302
    :param progress: Progress callback
303
    """
304
    bios_repo = _checkout_repo(git_ref=git_ref, repo_dir=repo_dir, repo=repo, progress=progress)
1✔
305
    is_detached = bios_repo.head.is_detached
1✔
306
    LOG.debug(f'Repo at: {git_ref} with HEAD detached: {is_detached}')
1✔
307
    if update and not is_detached:
1✔
308
        f_info = bios_repo.remotes[0].pull(progress=progress)
1✔
309
        LOG.debug(f'Pulled: {f_info[0].name} as: {f_info[0].commit}')
1✔
310
    git_ref =  git_ref.split('/')[1] if 'origin/' in git_ref else git_ref
1✔
311
    sha = _get_sha_hex_str(bios_repo, git_ref)
1✔
312
    return sha
1✔
313

314

315
def _checkout_repo(git_ref: str, repo_dir: Path, repo: str, progress: git.RemoteProgress | None = None) -> git.Repo:
1✔
316
    """
317
    Check out a repository at a main/master branch or clone it when not exists in a system.
318

319
    :param git_ref: Any Git reference as a string
320
    :param repo_dir: Local repository directory
321
    :param repo: Repository address
322
    :param progress: Progress callback
323
    :return: Repo object of the repository
324
    """
325
    import git
1✔
326

327
    makedirs(name=repo_dir, exist_ok=True)
1✔
328
    if is_git_repo(str(repo_dir)):
1✔
329
        bios_repo = git.Repo(repo_dir)
1✔
330
        all_refs = get_all_git_refs(repo_dir=repo_dir)
1✔
331
        named_git_obj = git_ref.removeprefix('origin/')
1✔
332
        if named_git_obj in all_refs:
1✔
333
            bios_repo.git.checkout(named_git_obj, force=True)
1✔
334
        elif is_git_sha(repo=bios_repo, ref=named_git_obj):
1✔
335
            bios_repo.git.checkout('--detach', named_git_obj, force=True)
×
336
        else:
337
            bios_repo.git.checkout(git_ref, b=named_git_obj, force=True)
1✔
338
    else:
339
        rmtree(path=repo_dir, ignore_errors=True)
1✔
340
        bios_repo = git.Repo.clone_from(url=repo, to_path=repo_dir, progress=progress)  # type: ignore[arg-type]
1✔
341
    return bios_repo
1✔
342

343

344
def is_git_sha(repo: git.Repo, ref: str) -> bool:
1✔
345
    """
346
    Check if a ref is a git commit SHA.
347

348
    :param repo: Git Repository object
349
    :param ref: Git commit SHA as string
350
    :return: True if ref is SHA of git commit, False otherwise
351
    """
352
    import gitdb  # type: ignore[import-untyped]
1✔
353

354
    with suppress(gitdb.exc.BadName):
1✔
355
        _  = repo.commit(ref).hexsha
1✔
356
        return True
1✔
357
    return False
1✔
358

359

360
def check_dcs_bios_entry(lua_dst_data: str, lua_dst_path: Path, temp_dir: Path) -> str:
1✔
361
    """
362
    Check the DCS-BIOS entry in the Export.lua file.
363

364
    :param lua_dst_data: Content of Export.lua
365
    :param lua_dst_path: Export.lua path
366
    :param temp_dir: Directory with DCS-BIOS archive
367
    :return: Result of checks
368
    """
369
    result = '\n\nExport.lua exists.'
1✔
370
    lua = 'Export.lua'
1✔
371
    with open(file=temp_dir / lua, encoding='utf-8') as lua_src:
1✔
372
        lua_src_data = lua_src.read()
1✔
373
    export_re = search(r'dofile\(lfs.writedir\(\)\s*\.\.\s*\[\[Scripts\\DCS-BIOS\\BIOS\.lua]]\)', lua_dst_data)
1✔
374
    if not export_re:
1✔
375
        with open(file=lua_dst_path / lua, mode='a+', encoding='utf-8') as exportlua_dst:
1✔
376
            exportlua_dst.write(f'\n{lua_src_data}')
1✔
377
        LOG.debug(f'Add DCS-BIOS to Export.lua: {lua_src_data}')
1✔
378
        result += '\n\nDCS-BIOS entry added.\n\nYou verify installation at:\ngithub.com/DCS-Skunkworks/DCSFlightpanels/wiki/Installation'
1✔
379
    else:
380
        result += '\n\nDCS-BIOS entry detected.'
1✔
381
    return result
1✔
382

383

384
def count_files(directory: Path, extension: str) -> int:
1✔
385
    """
386
    Count files with extension in a directory.
387

388
    :param directory: As Path object
389
    :param extension: File extension
390
    :return: Number of files
391
    """
392
    try:
1✔
393
        json_files = [f.name for f in directory.iterdir() if f.is_file() and f.suffix == f'.{extension}']
1✔
394
        LOG.debug(f'In: {directory} found {json_files} ')
1✔
395
        return len(json_files)
1✔
396
    except FileNotFoundError:
1✔
397
        LOG.debug(f'Wrong directory: {directory}')
1✔
398
        return -1
1✔
399

400

401
def is_git_exec_present() -> bool:
1✔
402
    """
403
    Check if the git executable is present in a system.
404

405
    :return: True if git.exe is available
406
    """
407
    try:
1✔
408
        import git
1✔
409
        return bool(git.GIT_OK)
1✔
410
    except ImportError as err:
×
NEW
411
        LOG.warning(type(err).__name__, exc_info=True)
×
412
        return False
×
413

414

415
def is_git_object(repo_dir: Path, git_obj: str) -> bool:
1✔
416
    """
417
    Check if git_obj is a valid Git reference.
418

419
    :param repo_dir: Directory with repository
420
    :param git_obj: Git reference to check
421
    :return: True if git_obj is git reference, False otherwise
422
    """
423
    import gitdb  # type: ignore[import-untyped]
1✔
424
    result = False
1✔
425
    if is_git_repo(str(repo_dir)):
1✔
426
        all_refs = get_all_git_refs(repo_dir=repo_dir)
1✔
427
        if git_obj in all_refs:
1✔
428
            result = True
1✔
429
        with suppress(gitdb.exc.BadName, TypeError, ValueError):
1✔
430
            git.Repo(repo_dir).commit(git_obj)
1✔
431
            result = True
1✔
432
    return result
1✔
433

434

435
def get_all_git_refs(repo_dir: Path) -> list[str]:
1✔
436
    """
437
    Get a list of branches and tags for repo.
438

439
    :param repo_dir: Directory with a repository
440
    :return: List of git references as strings
441
    """
442
    refs = []
1✔
443
    if is_git_repo(str(repo_dir)):
1✔
444
        for ref in git.Repo(repo_dir).refs:
1✔
445
            refs.append(str(ref))
1✔
446
    return refs
1✔
447

448

449
def collect_debug_data() -> Path:
1✔
450
    """
451
    Collect and zip all data for troubleshooting.
452

453
    :return: Path object to ZIP file
454
    """
455
    config_file = Path(get_config_yaml_location() / CONFIG_YAML).resolve()
1✔
456
    conf_dict = load_yaml(config_file)
1✔
457
    sys_data = _get_sys_file(conf_dict)
1✔
458
    dcs_log = _get_dcs_log(conf_dict)
1✔
459

460
    zip_file = Path(gettempdir()) / f'dcspy_debug_{str(datetime.now()).replace(" ", "_").replace(":", "")}.zip'
1✔
461
    with zipfile.ZipFile(file=zip_file, mode='w', compresslevel=9, compression=zipfile.ZIP_DEFLATED) as archive:
1✔
462
        archive.write(sys_data, arcname=sys_data.name)
1✔
463
        archive.write(dcs_log, arcname=dcs_log.name)
1✔
464
        for log_file in _get_log_files():
1✔
465
            archive.write(log_file, arcname=log_file.name)
1✔
466
        for yaml_file in _get_yaml_files(config_file):
1✔
467
            archive.write(yaml_file, arcname=yaml_file.name)
1✔
468
        for png in _get_png_files():
1✔
469
            archive.write(png, arcname=png.name)
1✔
470

471
    return zip_file
1✔
472

473

474
def _get_sys_file(conf_dict: dict[str, Any]) -> Path:
1✔
475
    """
476
    Save system information to a file and return its path.
477

478
    :param conf_dict: A dictionary containing configuration information.
479
    :return: A Path object representing the path to the system data file.
480
    """
481
    system_info = _fetch_system_info(conf_dict)
1✔
482
    sys_data = Path(gettempdir()) / 'system_data.txt'
1✔
483
    with open(sys_data, 'w+') as debug_file:
1✔
484
        debug_file.write(system_info)
1✔
485
    return sys_data
1✔
486

487

488
def _fetch_system_info(conf_dict: dict[str, Any]) -> str:
1✔
489
    """
490
    Fetch system information.
491

492
    :param conf_dict: A dictionary containing configuration information.
493
    :return: System data as a string
494
    """
495
    name = uname()
1✔
496
    pyver = (python_version(), python_implementation())
1✔
497
    pyexec = sys.executable
1✔
498
    dcs = check_dcs_ver(dcs_path=Path(str(conf_dict['dcs'])))
1✔
499
    bios_ver = check_bios_ver(bios_path=str(conf_dict['dcsbios']))
1✔
500
    repo_dir = Path(str(conf_dict['dcsbios'])).parents[1] / 'dcs-bios'
1✔
501
    git_ver, head_commit, remote_url = _fetch_git_data(repo_dir=repo_dir)
1✔
502
    lgs_dir = '\n'.join([
1✔
503
        str(Path(dir_path) / filename)
504
        for dir_path, _, filenames in walk('C:\\Program Files\\Logitech Gaming Software\\SDK')
505
        for filename in filenames
506
    ])
507
    return f'{__version__=}\n{name=}\n{pyver=}\n{pyexec=}\n{dcs=}\n{bios_ver=}\n{remote_url=}\n{git_ver=}\n{head_commit=}\n{lgs_dir}\ncfg={pformat(conf_dict)}'
1✔
508

509

510
def _fetch_git_data(repo_dir: Path) -> tuple[Sequence[int], str, str]:
1✔
511
    """
512
    Fetch the Git version and SHA of HEAD commit.
513

514
    :param repo_dir: Local directory for repository
515
    :return: Tuple of (a version), SHA of HEAD commit and remote URL
516
    """
517
    try:
1✔
518
        import git
1✔
519
        git_ver = git.cmd.Git().version_info
1✔
520
        repo = git.Repo(repo_dir)
1✔
521
        head_commit = str(repo.head.commit)
×
522
        remote_url = repo.remotes.origin.url
×
523
    except (git.exc.NoSuchPathError, git.exc.InvalidGitRepositoryError, ImportError):
1✔
524
        git_ver = (0, 0, 0, 0)
1✔
525
        head_commit = 'N/A'
1✔
526
        remote_url = 'N/A'
1✔
527
    return git_ver, head_commit, remote_url
1✔
528

529

530
def _get_dcs_log(conf_dict: dict[str, Any]) -> Path:
1✔
531
    """
532
    Get a path to dcs.log path.
533

534
    :param conf_dict: A dictionary containing configuration information.
535
    :return: A Path object representing the path to the dcs.log file.
536
    """
537
    dcs_log_file = Path(conf_dict['dcsbios']).parents[1] / 'Logs' / 'dcs.log'
1✔
538
    return dcs_log_file if dcs_log_file.is_file() else Path()
1✔
539

540

541
def _get_log_files() -> Generator[Path]:
1✔
542
    """
543
    Get a path to all logg files.
544

545
    :return: Generator of a path to log files
546
    """
547
    return (
1✔
548
        Path(gettempdir()) / logfile
549
        for logfile in glob(str(Path(gettempdir()) / 'dcspy.log*'))
550
    )
551

552

553
def _get_yaml_files(config_file: Path) -> Generator[Path]:
1✔
554
    """
555
    Get a path to all configuration YAML files.
556

557
    :param config_file: Path to the config file
558
    :return: Generator of a path to YAML files
559
    """
560
    return (
1✔
561
        Path(dirpath) / filename
562
        for dirpath, _, filenames in walk(config_file.parent)
563
        for filename in filenames
564
        if filename.endswith('yaml')
565
    )
566

567

568
def _get_png_files() -> Generator[Path]:
1✔
569
    """
570
    Get a path to png screenshots for all airplanes.
571

572
    :return: Generator of a path to png files
573
    """
574
    aircrafts = ['FA18Chornet', 'Ka50', 'Ka503', 'Mi8MT', 'Mi24P', 'F16C50', 'F15ESE',
1✔
575
                 'AH64DBLKII', 'A10C', 'A10C2', 'F14A135GR', 'F14B', 'AV8BNA']
576
    return (
1✔
577
        Path(dir_path) / filename
578
        for dir_path, _, filenames in walk(gettempdir())
579
        for filename in filenames
580
        if any(True for aircraft in aircrafts if aircraft in filename and filename.endswith('png'))
581
    )
582

583

584
def get_config_yaml_location() -> Path:
1✔
585
    """
586
    Get a location of YAML configuration files.
587

588
    :rtype: Path object to directory
589
    """
590
    localappdata = environ.get('LOCALAPPDATA', None)
1✔
591
    user_appdata = Path(localappdata) / 'dcspy' if localappdata else DEFAULT_YAML_FILE.parent
1✔
592
    return user_appdata
1✔
593

594

595
def run_command(cmd: Sequence[str], cwd: Path | None = None) -> int:
1✔
596
    """
597
    Run command in shell as a subprocess.
598

599
    :param cmd: The command to be executed as a sequence of strings
600
    :param cwd: current working directory
601
    :return: The return code of command
602
    """
603
    try:
1✔
604
        proc = run(cmd, check=True, shell=False, cwd=cwd)
1✔
605
        return proc.returncode
1✔
606
    except CalledProcessError as e:
1✔
607
        LOG.debug(f'Result: {e}')
1✔
608
        return -1
1✔
609

610

611
def load_json(full_path: Path) -> dict[Any, Any]:
1✔
612
    """
613
    Load JSON from a file into a dictionary.
614

615
    :param full_path: Full path
616
    :return: Python representation of JSON
617
    """
618
    with open(full_path, encoding='utf-8') as json_file:
1✔
619
        data = json_file.read()
1✔
620
    return json.loads(data)
1✔
621

622

623
@lru_cache
1✔
624
def get_full_bios_for_plane(plane: str, bios_dir: Path) -> DcsBiosPlaneData:
1✔
625
    """
626
    Collect full BIOS for the plane with a name.
627

628
    :param plane: BIOS plane name
629
    :param bios_dir: path to DCS-BIOS directory
630
    :return: dict
631
    """
632
    alias_path = bios_dir / 'doc' / 'json' / 'AircraftAliases.json'
1✔
633
    local_json: dict[str, Any] = {}
1✔
634
    aircraft_aliases = load_json(full_path=alias_path)
1✔
635
    for json_file in aircraft_aliases[plane]:
1✔
636
        local_json = {**local_json, **load_json(full_path=bios_dir / 'doc' / 'json' / f'{json_file}.json')}
1✔
637

638
    return DcsBiosPlaneData.model_validate(local_json)
1✔
639

640

641
@lru_cache
1✔
642
def get_inputs_for_plane(plane: str, bios_dir: Path) -> dict[str, dict[str, ControlKeyData]]:
1✔
643
    """
644
    Get dict with all not empty inputs for plane.
645

646
    :param plane: BIOS plane name
647
    :param bios_dir: path to DCS-BIOS
648
    :return: dict.
649
    """
650
    plane_bios = get_full_bios_for_plane(plane=plane, bios_dir=bios_dir)
1✔
651
    inputs = plane_bios.get_inputs()
1✔
652
    return inputs
1✔
653

654

655
def get_list_of_ctrls(inputs: dict[str, dict[str, ControlKeyData]]) -> list[str]:
1✔
656
    """
657
    Get a list of all controllers from dict with sections and inputs.
658

659
    :param inputs: Dictionary with ControlKeyData
660
    :return: List of string
661
    """
662
    result_list = []
1✔
663
    for section, controllers in inputs.items():
1✔
664
        result_list.append(f'{CTRL_LIST_SEPARATOR} {section} {CTRL_LIST_SEPARATOR}')
1✔
665
        for ctrl_name in controllers:
1✔
666
            result_list.append(ctrl_name)
1✔
667
    return result_list
1✔
668

669

670
@lru_cache
1✔
671
def get_planes_list(bios_dir: Path) -> list[str]:
1✔
672
    """
673
    Get a list of all DCS-BIOS supported planes with clickable cockpit.
674

675
    :param bios_dir: Path to DCS-BIOS
676
    :return: List of all supported planes
677
    """
678
    aircraft_aliases = get_plane_aliases(bios_dir=bios_dir, plane=None)
1✔
679
    return [name for name, yaml_data in aircraft_aliases.items() if yaml_data not in (['CommonData', 'FC3'], ['CommonData'])]
1✔
680

681

682
@lru_cache
1✔
683
def get_plane_aliases(bios_dir: Path, plane: str | None = None) -> dict[str, list[str]]:
1✔
684
    """
685
    Get a list of all YAML files for the plane with a name.
686

687
    :param plane: BIOS plane name
688
    :param bios_dir: path to DCS-BIOS
689
    :return: list of all YAML files for plane definition
690
    """
691
    alias_path = bios_dir / 'doc' / 'json' / 'AircraftAliases.json'
1✔
692
    aircraft_aliases = load_json(full_path=alias_path)
1✔
693
    if plane:
1✔
694
        aircraft_aliases = {plane: aircraft_aliases[plane]}
1✔
695
    return aircraft_aliases
1✔
696

697

698
def get_depiction_of_ctrls(inputs: dict[str, dict[str, ControlKeyData]]) -> dict[str, ControlDepiction]:
1✔
699
    """
700
    Get the depiction of controls.
701

702
    :param inputs: Dictionary with ControlKeyData
703
    :return: A dictionary containing the depiction of controls.
704
    """
705
    result = {}
1✔
706
    for section, controllers in inputs.items():
1✔
707
        for ctrl_name, ctrl in controllers.items():
1✔
708
            result[ctrl_name] = ctrl.depiction
1✔
709
    return result
1✔
710

711

712
def substitute_symbols(value: str, symbol_replacement: Sequence[Sequence[str]]) -> str:
1✔
713
    """
714
    Substitute symbols in a string with specified replacements.
715

716
    :param value: The input string to be processed
717
    :param symbol_replacement: A list of symbol patterns and their corresponding replacements.
718
    :return: The processed string with symbols is replaced according to the provided symbol_replacement list.
719
    """
720
    for pattern, replacement in symbol_replacement:
1✔
721
        value = sub(pattern, replacement, value)
1✔
722
    return value
1✔
723

724

725
def replace_symbols(value: str, symbol_replacement: Sequence[Sequence[str]]) -> str:
1✔
726
    """
727
    Replace symbols in a string with specified replacements.
728

729
    :param value: The string in which symbols will be replaced.
730
    :param symbol_replacement: A sequence of sequences containing the original symbols and their replacement strings.
731
    :return: The string with symbols to replace.
732
    """
733
    for original, replacement in symbol_replacement:
1✔
734
        value = value.replace(original, replacement)
1✔
735
    return value
1✔
736

737

738
def _try_key_instance(klass: ButtonTypes, method: str, key_str: str) -> AnyButton | None:
1✔
739
    """
740
    Attempt to invoke a method on a class with a given key string.
741

742
    The method will first attempt to call the provided method with the `key_str` as a parameter.
743
    If there is a TypeError (indicating the method does not support a parameter), it attempts to call
744
    the method without arguments.
745
    If the method is missing or the call fails due to a ValueError or AttributeError, the function returns None.
746

747
    :param klass: The class type on which the method is to be invoked.
748
    :param method: The name of the method to call on the class.
749
    :param key_str: A string key to be passed as a parameter to the method, if supported.
750
    :return: An instance of `AnyButton` from the invoked method, if successful, otherwise None.
751
    """
752
    try:
1✔
753
        return getattr(klass, method)(key_str)
1✔
754
    except TypeError:
1✔
755
        return getattr(klass, method)
1✔
756
    except (ValueError, AttributeError):
1✔
757
        return None
1✔
758

759

760
def get_key_instance(key_str: str) -> AnyButton:
1✔
761
    """
762
    Resolve the provided key string into an instance of a valid key class based on a predefined set of classes and their respective resolution methods.
763

764
    If the key string matches a class method's criteria, it returns the resolved key instance.
765
    If no match is found, an exception is raised.
766

767
    :param key_str: A string representing the name or identifier of the key to be resolved into a key instance (e.g., Gkey, LcdButton, or MouseButton).
768
    :return: An instance of a class (AnyButton) that corresponds to the provided key string, if successfully resolved.
769
    :raises AttributeError: If the provided key string cannot be resolved into a valid key instance using the predefined classes and methods.
770
    """
771
    key_types_and_methods: list[tuple[ButtonTypes, str]] = [(Gkey, 'from_yaml'), (MouseButton, 'from_yaml'), (LcdButton, key_str)]
1✔
772
    for klass, method in key_types_and_methods:
1✔
773
        key_instance = _try_key_instance(klass=klass, method=method, key_str=key_str)
1✔
774
        if key_instance:
1✔
775
            return key_instance
1✔
776
    raise AttributeError(f'Could not resolve "{key_str}" to a Gkey/LcdButton/MouseButton instance')
1✔
777

778

779
class KeyRequest:
1✔
780
    """Map LCD button or G-Key with an abstract request model."""
781

782
    def __init__(self, yaml_path: Path, get_bios_fn: Callable[[str], BiosValue]) -> None:
1✔
783
        """
784
        Load YAML with BIOS request for G-Keys and LCD buttons.
785

786
        :param yaml_path: Path to the airplane YAML file.
787
        :param get_bios_fn: Function used to get a current BIOS value.
788
        """
789
        plane_yaml = load_yaml(full_path=yaml_path)
1✔
790
        self.buttons: dict[AnyButton, RequestModel] = {}
1✔
791
        for key_str, request in plane_yaml.items():
1✔
792
            if request:
1✔
793
                key = get_key_instance(key_str)
1✔
794
                self.buttons[key] = RequestModel.from_request(key=key, request=request, get_bios_fn=get_bios_fn)
1✔
795

796
    @property
1✔
797
    def cycle_button_ctrl_name(self) -> dict[str, int]:
1✔
798
        """Return a dictionary with BIOS selectors to track changes of values for a cycle button to get current values."""
799
        return {req_model.ctrl_name: int() for req_model in self.buttons.values() if req_model.is_cycle}
1✔
800

801
    def get_request(self, button: AnyButton) -> RequestModel:
1✔
802
        """
803
        Get abstract representation for request ti be sent for requested button.
804

805
        :param button: LcdButton, Gkey, or MouseButton
806
        :return: RequestModel object
807
        """
808
        return self.buttons.get(button, RequestModel.make_empty(key=button))
1✔
809

810
    def set_request(self, button: AnyButton, req: str) -> None:
1✔
811
        """
812
        Update the internal string request for the specified button.
813

814
        :param button: LcdButton, Gkey, or MouseButton
815
        :param req: The raw request to set.
816
        """
817
        self.buttons[button].raw_request = req
1✔
818

819

820
def generate_bios_jsons_with_lupa(dcs_save_games: Path, local_compile='./Scripts/DCS-BIOS/test/compile/LocalCompile.lua') -> None:
1✔
821
    r"""
822
    Regenerate DCS-BIOS JSON files.
823

824
    Using the Lupa library, first it will try to use LuaJIT 2.1 if not, it will fall back to Lua 5.1
825

826
    :param dcs_save_games: Full path to the Saved Games\DCS directory.
827
    :param local_compile: Relative path to the LocalCompile.lua file.
828
    """
829
    try:
1✔
830
        import lupa.luajit21 as lupa
1✔
831
    except ImportError:
×
832
        try:
×
833
            import lupa.lua51 as lupa  # type: ignore[no-redef]
×
834
        except ImportError:
×
835
            return
×
836

837
    previous_dir = getcwd()
1✔
838
    try:
1✔
839
        chdir(dcs_save_games)
1✔
840
        LOG.debug(f"Changed to: {dcs_save_games}")
1✔
841
        lua = lupa.LuaRuntime()
1✔
842
        LOG.debug(f"Using {lupa.LuaRuntime().lua_implementation} (compiled with {lupa.LUA_VERSION})")
1✔
843
        with open(local_compile) as lua_file:
1✔
844
            lua_script = lua_file.read()
1✔
845
        lua.execute(lua_script)
1✔
846
    finally:
847
        chdir(previous_dir)
1✔
848
        LOG.debug(f"Change directory back to: {getcwd()}")
1✔
849

850

851
def rgba(c: Color, /, mode: LcdMode | int = LcdMode.TRUE_COLOR) -> tuple[int, ...] | int:
1✔
852
    """
853
    Convert a color to a single integer or tuple of integers.
854

855
    This depends on a given mode/alpha channel:
856
    * If a mode is an integer, then return a tuple of RGBA channels.
857
    * If a mode is a LcdMode.TRUE_COLOR, then return a tuple of RGBA channels.
858
    * If a mode is a LcdMode.BLACK_WHITE, then return a single integer.
859

860
    :param c: Color name to convert
861
    :param mode: Mode of the LCD or alpha channel as an integer
862
    :return: tuple with RGBA channels or single integer
863
    """
864
    if isinstance(mode, int):
1✔
865
        return *rgb(c), mode
1✔
866
    else:
867
        return ImageColor.getcolor(color=c.name, mode=mode.value)
1✔
868

869

870
def rgb(c: Color, /) -> tuple[int, int, int]:
1✔
871
    """
872
    Convert a Color instance to its RGB components as a tuple of integers.
873

874
    The function extracts the red, green, and blue components from the
875
    color's value, which is expected to be a single integer representing
876
    a 24-bit RGB color.
877

878
    :param c: An instance of Color, whose value is a 24-bit RGB integer.
879
    :return: A tuple containing the red, green, and blue components.
880
    """
881
    red = (c.value >> 16) & 0xff
1✔
882
    green = (c.value >> 8) & 0xff
1✔
883
    blue = c.value & 0xff
1✔
884
    return red, green, blue
1✔
885

886

887
def detect_system_color_mode() -> str:
1✔
888
    """
889
    Detect the color mode of the system.
890

891
    Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode.
892
    In case of error, it will return 'light' as default.
893

894
    :return: Dark or light as string
895
    """
896
    from winreg import HKEY_CURRENT_USER, OpenKey, QueryValueEx  # type: ignore[attr-defined]
1✔
897

898
    try:
1✔
899
        key = OpenKey(HKEY_CURRENT_USER, 'Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize')
1✔
900
        subkey = QueryValueEx(key, 'AppsUseLightTheme')[0]
1✔
901
    except (OSError, IndexError):
×
902
        return 'Light'
×
903
    return {0: 'Dark', 1: 'Light'}[subkey]
1✔
904

905
def verify_hashes(file_path: Path, digest_file: Path) -> tuple[bool, dict[str, bool]]:
1✔
906
    """
907
    Check hashes for a file.
908

909
    :param file_path: Path to the file
910
    :param digest_file: Path to the file with digests
911
    :return: Overall verdict and detailed results
912
    """
913
    if not file_path.is_file() or not digest_file.is_file():
1✔
914
        return False, {}
1✔
915

916
    with open(digest_file) as f_digests:
1✔
917
        all_digests = f_digests.readlines()
1✔
918

919
    hashes: dict[str, dict[str, str]] = {}
1✔
920
    for line in all_digests:
1✔
921
        if line.startswith('#HASH'):
1✔
922
            hash_type = line.split()[1]
1✔
923
        elif line.strip():
1✔
924
            hash_and_file = line.split()
1✔
925
            filename = hash_and_file[1] if len(hash_and_file) > 1 else ''
1✔
926
            hashes.setdefault(filename, {})[hash_type] = hash_and_file[0]
1✔
927
    LOG.debug(f'Supported algorithms are: {hashlib.algorithms_guaranteed}')
1✔
928
    results = _compute_hash_and_check_file(file_path=file_path, hashes=hashes)
1✔
929

930
    return all(results.values()), results
1✔
931

932

933
def _compute_hash_and_check_file(file_path: Path, hashes: dict[str, dict[str, str]]) -> dict[str, bool]:
1✔
934
    """
935
    Compute and verify hashes for a file.
936

937
    :param file_path: Path for a file to check hashes
938
    :param hashes: Dictionary of hash types and values
939
    :return: Dictionary of verification results
940
    """
941
    result = {}
1✔
942
    for hash_type, hash_value in hashes.get(file_path.name, {}).items():
1✔
943
        try:
1✔
944
            with open(file_path, 'rb') as f_path:
1✔
945
                if sys.version_info.minor > 10:
1✔
946
                    computed_hash = hashlib.file_digest(f_path, hash_type).hexdigest()
1✔
947
                else:
948
                    h = hashlib.new(hash_type)
×
949
                    h.update(f_path.read())
×
950
                    computed_hash = h.hexdigest()
×
951
        except ValueError:
1✔
952
            computed_hash = ''
1✔
953
        result[hash_type] = computed_hash == hash_value
1✔
954
    return result
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc