feat: Tema sonoro DeepSpace per KDE Plasma
- index.theme con Directories=stereo, OutputProfile=stereo, Inherits=freedesktop ed Example=theme-demo - 61 cue sintetizzati da zero (Ogg Vorbis 48 kHz stereo) piu' il montaggio di anteprima theme-demo.oga - tools/gen_sounds.py: motore di sintesi riproducibile (voci FM, pluck Karplus-Strong, rumore a banda variabile, sub-impact, pad additivi, riverbero a convoluzione, modello di degrado) - tools/verify_theme.py: verifica della spec, igiene audio (clipping, DC, click ai bordi) e copertura dei nomi suono richiesti dai notifyrc di KDE - preview/index.html: player nel browser per ogni cue del tema - Makefile, install.sh, LICENSES (audio CC-BY-SA-4.0, codice BSD-2-Clause)
This commit is contained in:
+1147
File diff suppressed because it is too large
Load Diff
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
"""Generate preview/index.html for a sound theme."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).with_name("preview_template.html")
|
||||
|
||||
|
||||
def duration(path: Path) -> float:
|
||||
ffp = shutil.which("ffprobe") or "ffprobe"
|
||||
return float(subprocess.run(
|
||||
[ffp, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", str(path)],
|
||||
capture_output=True, text=True, check=True).stdout.strip())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
theme = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("preview/index.html")
|
||||
files = sorted((theme / "stereo").glob("*.oga"))
|
||||
names = ", ".join(f'["{f.stem}", {duration(f):.2f}]' for f in files
|
||||
if f.stem != "theme-demo")
|
||||
html = TEMPLATE.read_text(encoding="utf-8")
|
||||
extra = {}
|
||||
for cand in (Path("preview.meta"), theme.parent / "preview.meta",
|
||||
theme / "preview.meta"):
|
||||
if cand.is_file():
|
||||
meta = cand
|
||||
break
|
||||
else:
|
||||
meta = None
|
||||
if meta is not None:
|
||||
for line in meta.read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
extra[k.strip()] = v.strip()
|
||||
extra.setdefault("title", theme.name)
|
||||
extra.setdefault("tagline", "")
|
||||
extra.setdefault("accent", "#2fb4d2")
|
||||
extra.setdefault("accent2", "#70c493")
|
||||
extra.setdefault("bg", "#0c1822")
|
||||
extra.setdefault("fg", "#d7e6ee")
|
||||
extra.setdefault("license", "CC BY-SA 4.0")
|
||||
extra["names"] = "[ " + names + " ]"
|
||||
extra["nsounds"] = str(len(files))
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(html.format(**extra), encoding="utf-8")
|
||||
print(f"scritto {out} ({len(files)} cue)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
<!-- preview_template.html — sorgente di preview/index.html.
|
||||
Generato da scaffold_repo.py; usa str.format(), quindi le graffe
|
||||
di CSS e JavaScript sono raddoppiate. Rigenera la pagina con:
|
||||
python3 tools/make_preview.py DeepSpace preview/index.html
|
||||
SPDX-License-Identifier: BSD-2-Clause -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} — sound theme</title>
|
||||
<style>
|
||||
:root {{ --accent: {accent}; --accent2: {accent2}; --bg: {bg}; --fg: {fg}; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin: 0; padding: 2.5rem 1.25rem 4rem;
|
||||
background: radial-gradient(120% 80% at 50% 0%, #1a2634 0%, var(--bg) 55%, #05080c 100%);
|
||||
color: var(--fg); min-height: 100vh;
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }}
|
||||
.wrap {{ max-width: 960px; margin: 0 auto; }}
|
||||
h1 {{ font-size: 2.4rem; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 .2rem; }}
|
||||
h1 span {{ color: var(--accent); }}
|
||||
.tag {{ color: var(--accent); font-style: italic; margin: 0 0 1.2rem; }}
|
||||
.meta {{ display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin: 0 0 2rem;
|
||||
opacity: .75; font-size: .9rem; }}
|
||||
.meta b {{ font-weight: 600; color: var(--accent2); }}
|
||||
.demo {{ background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.10);
|
||||
border-left: 3px solid var(--accent); border-radius: 10px;
|
||||
padding: 1rem 1.2rem; margin: 0 0 2rem; }}
|
||||
.demo h2 {{ margin: 0 0 .5rem; font-size: 1rem; font-weight: 600; }}
|
||||
.grid {{ display: grid; gap: 6px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); }}
|
||||
.row {{ display: flex; align-items: center; gap: .7rem;
|
||||
padding: .45rem .7rem; border-radius: 8px;
|
||||
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.06);
|
||||
transition: background .15s, border-color .15s; }}
|
||||
.row:hover {{ background: rgba(255,255,255,.07); border-color: var(--accent); }}
|
||||
.row.playing {{ border-color: var(--accent); background: rgba(255,255,255,.09); }}
|
||||
.row button {{ flex: 0 0 30px; height: 30px; border-radius: 50%; cursor: pointer;
|
||||
border: 1px solid var(--accent); background: transparent;
|
||||
color: var(--accent); font-size: 12px; line-height: 1;
|
||||
display: grid; place-items: center; }}
|
||||
.row button:hover {{ background: var(--accent); color: var(--bg); }}
|
||||
.row .n {{ flex: 1; font-size: .87rem; font-family: ui-monospace, monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||
.row .d {{ flex: 0 0 auto; font-size: .76rem; opacity: .55;
|
||||
font-variant-numeric: tabular-nums; }}
|
||||
footer {{ margin-top: 3rem; padding-top: 1.2rem; font-size: .82rem; opacity: .6;
|
||||
border-top: 1px solid rgba(255,255,255,.08); }}
|
||||
footer a {{ color: var(--accent); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>{title}</h1>
|
||||
<p class="tag">{tagline}</p>
|
||||
<div class="meta">
|
||||
<span><b>{nsounds}</b> cue</span>
|
||||
<span><b>48 kHz</b> stereo Ogg Vorbis</span>
|
||||
<span><b>freedesktop</b> sound theme spec</span>
|
||||
<span><b>KDE Plasma 6</b></span>
|
||||
</div>
|
||||
|
||||
<div class="demo">
|
||||
<h2>theme-demo — anteprima del tema</h2>
|
||||
<audio id="demo" controls preload="none" src="../{title}/stereo/theme-demo.oga"></audio>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<footer>
|
||||
{title} — {license}. I cue sono sintetizzati da zero con
|
||||
<code>tools/gen_sounds.py</code>: nessun campione di terze parti.
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
const NAMES = {names};
|
||||
const grid = document.getElementById('grid');
|
||||
let current = null;
|
||||
for (const [name, dur] of NAMES) {{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '▶';
|
||||
btn.setAttribute('aria-label', 'riproduci ' + name);
|
||||
const label = document.createElement('span');
|
||||
label.className = 'n';
|
||||
label.textContent = name;
|
||||
const d = document.createElement('span');
|
||||
d.className = 'd';
|
||||
d.textContent = dur + 's';
|
||||
const audio = new Audio('../{title}/stereo/' + name + '.oga');
|
||||
audio.preload = 'none';
|
||||
btn.onclick = () => {{
|
||||
if (current && current !== audio) {{ current.pause(); current.currentTime = 0; }}
|
||||
document.querySelectorAll('.row.playing').forEach(r => r.classList.remove('playing'));
|
||||
if (!audio.paused) {{ audio.pause(); audio.currentTime = 0; btn.textContent = '▶'; return; }}
|
||||
current = audio;
|
||||
btn.textContent = '❚❚';
|
||||
row.classList.add('playing');
|
||||
audio.play();
|
||||
}};
|
||||
audio.onended = () => {{ btn.textContent = '▶'; row.classList.remove('playing'); }};
|
||||
row.append(btn, label, d);
|
||||
grid.append(row);
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
#
|
||||
# verify_theme.py - validate a freedesktop/KDE sound theme.
|
||||
#
|
||||
# Checks, per audio file:
|
||||
# * decodes with ffmpeg, is Ogg Vorbis, 48 kHz, stereo
|
||||
# * no clipping, no DC offset, no click at the edges
|
||||
# * duration within a sane range for a desktop cue
|
||||
# and, per theme:
|
||||
# * index.theme parses and declares [Sound Theme] + a matching section for
|
||||
# every entry of Directories
|
||||
# * Inherits/Example point at things that exist
|
||||
# * every sound requested by the installed KDE notifyrc files is covered
|
||||
# (directly or via the Inherits chain)
|
||||
#
|
||||
# Copyright (c) 2026 enne2
|
||||
"""Verify a sound theme against the freedesktop spec and KDE usage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
|
||||
SR = 48000
|
||||
CLIP_THRESHOLD = 0.999
|
||||
DC_LIMIT = 0.005
|
||||
EDGE_LIMIT = 0.02 # absolute amplitude allowed in the first/last sample
|
||||
MIN_DUR = 0.05
|
||||
MAX_DUR = 4.5
|
||||
PEAK_MIN_DB = -14.0
|
||||
PEAK_MAX_DB = -0.5
|
||||
|
||||
|
||||
def decode(path: Path) -> np.ndarray:
|
||||
"""Decode any audio file to float64 stereo via ffmpeg."""
|
||||
ff = shutil.which("ffmpeg")
|
||||
if not ff:
|
||||
raise RuntimeError("ffmpeg non trovato")
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as t:
|
||||
tmp = t.name
|
||||
try:
|
||||
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", str(path), "-ac", "2", "-ar", str(SR), tmp],
|
||||
check=True)
|
||||
sr, x = wavfile.read(tmp)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
if x.dtype.kind == "i":
|
||||
x = x.astype(np.float64) / (2 ** (8 * x.dtype.itemsize - 1))
|
||||
return x
|
||||
|
||||
|
||||
def probe(path: Path) -> dict:
|
||||
ffp = shutil.which("ffprobe") or "ffprobe"
|
||||
out = subprocess.run(
|
||||
[ffp, "-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name,sample_rate,channels",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", str(path)],
|
||||
capture_output=True, text=True, check=True).stdout.split()
|
||||
return {"codec": out[0], "sample_rate": int(out[1]),
|
||||
"channels": int(out[2]), "duration": float(out[3])}
|
||||
|
||||
|
||||
def check_audio(path: Path, is_example: bool = False) -> tuple[list[str], list[str], dict]:
|
||||
errs: list[str] = []
|
||||
warns: list[str] = []
|
||||
info = probe(path)
|
||||
if info["codec"] != "vorbis":
|
||||
errs.append(f"codec {info['codec']} != vorbis")
|
||||
if info["sample_rate"] != SR:
|
||||
errs.append(f"sample rate {info['sample_rate']} != {SR}")
|
||||
if info["channels"] != 2:
|
||||
errs.append(f"channels {info['channels']} != 2")
|
||||
|
||||
x = decode(path)
|
||||
mono = x.mean(axis=1)
|
||||
peak = float(np.max(np.abs(x)))
|
||||
rms = float(np.sqrt(np.mean(x ** 2)))
|
||||
peak_db = 20 * math.log10(peak) if peak > 0 else -200.0
|
||||
dc = float(mono.mean())
|
||||
start = float(np.mean(np.abs(x[:8])))
|
||||
end = float(np.mean(np.abs(x[-8:])))
|
||||
clipped = int((np.abs(x) >= CLIP_THRESHOLD).sum())
|
||||
silent_tail = 0
|
||||
thr = 10 ** (-60 / 20)
|
||||
for s in range(len(mono) - 1, 0, -1):
|
||||
if abs(mono[s]) > thr:
|
||||
silent_tail = (len(mono) - 1 - s) / SR
|
||||
break
|
||||
|
||||
if clipped:
|
||||
errs.append(f"clipping: {clipped} campioni a >= {CLIP_THRESHOLD}")
|
||||
if abs(dc) > DC_LIMIT:
|
||||
errs.append(f"DC offset {dc:+.4f} (limite {DC_LIMIT})")
|
||||
if start > EDGE_LIMIT:
|
||||
errs.append(f"attacco non a zero ({start:.4f}) -> click")
|
||||
if end > EDGE_LIMIT:
|
||||
warns.append(f"coda non a zero ({end:.4f})")
|
||||
if info["duration"] < MIN_DUR:
|
||||
errs.append(f"durata {info['duration']:.3f}s < {MIN_DUR}s")
|
||||
if info["duration"] > MAX_DUR and not is_example:
|
||||
warns.append(f"durata {info['duration']:.2f}s > {MAX_DUR}s")
|
||||
if not (PEAK_MIN_DB <= peak_db <= PEAK_MAX_DB):
|
||||
warns.append(f"picco {peak_db:+.2f} dBFS fuori range "
|
||||
f"[{PEAK_MIN_DB}, {PEAK_MAX_DB}]")
|
||||
if silent_tail > 0.5:
|
||||
warns.append(f"coda di silenzio {silent_tail:.2f}s")
|
||||
|
||||
return errs, warns, {"dur": info["duration"], "peak_db": peak_db,
|
||||
"rms_db": 20 * math.log10(rms) if rms > 0 else -200.0,
|
||||
"dc": dc}
|
||||
|
||||
|
||||
SECT = re.compile(r"^\[(.+)\]$")
|
||||
|
||||
|
||||
def parse_index(path: Path) -> tuple[dict, dict]:
|
||||
"""Minimal ini parser that tolerates KDE's repeated/localised keys.
|
||||
|
||||
The metadata lives in the mandatory ``[Sound Theme]`` group (not in a
|
||||
pre-section area), so that group is returned as the "top" dict; a bare
|
||||
pre-section area would be merged in too, for robustness.
|
||||
"""
|
||||
top: dict[str, str] = {}
|
||||
sections: dict[str, dict] = {}
|
||||
cur = None
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = SECT.match(line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
sections.setdefault(cur, {})
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k, v = k.strip(), v.strip()
|
||||
if cur is None:
|
||||
top[k] = v
|
||||
else:
|
||||
sections[cur][k] = v
|
||||
root = sections.get("Sound Theme") or sections.get("SoundTheme")
|
||||
if root is None:
|
||||
for name, body in sections.items():
|
||||
if "Directories" in body:
|
||||
root = body
|
||||
break
|
||||
if root:
|
||||
top = {**top, **root}
|
||||
return top, sections
|
||||
|
||||
|
||||
def resolve(name: str, dirs: list[Path]) -> Path | None:
|
||||
"""Find theme `name` inside one of the `sounds` base directories."""
|
||||
for base in dirs:
|
||||
cand = base / name
|
||||
if cand.is_dir():
|
||||
return cand
|
||||
if base.name == name and base.is_dir():
|
||||
return base
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def theme_dirs_chain(theme: Path, search: list[Path]) -> list[Path]:
|
||||
"""Follow Inherits recursively, returning the lookup order."""
|
||||
chain: list[Path] = [theme]
|
||||
seen = {theme.resolve()}
|
||||
queue = [theme]
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
top, _ = parse_index(cur / "index.theme")
|
||||
for parent in (top.get("Inherits") or "").split(","):
|
||||
parent = parent.strip()
|
||||
if not parent:
|
||||
continue
|
||||
d = resolve(parent, search)
|
||||
if d and d.resolve() not in seen:
|
||||
seen.add(d.resolve())
|
||||
chain.append(d)
|
||||
queue.append(d)
|
||||
return chain
|
||||
|
||||
|
||||
def kde_requested_names(search: list[Path]) -> set[str]:
|
||||
"""Sound names Plasma/KDE applications actually ask for."""
|
||||
names: set[str] = set()
|
||||
roots = [Path("/usr/share/knotifications6"), Path("/usr/share/knotifications5")]
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for f in root.rglob("*.notifyrc"):
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for m in re.finditer(r"^\s*Sound\s*=\s*(\S+)", text, re.M):
|
||||
v = m.group(1)
|
||||
if v.endswith((".oga", ".ogg", ".wav")):
|
||||
v = Path(v).stem
|
||||
names.add(v)
|
||||
return names
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("theme", type=Path, help="theme directory (contains index.theme)")
|
||||
ap.add_argument("--quiet", action="store_true")
|
||||
ap.add_argument("--no-kde-check", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
theme = args.theme.resolve()
|
||||
errs: list[str] = []
|
||||
warns: list[str] = []
|
||||
|
||||
idx = theme / "index.theme"
|
||||
if not idx.is_file():
|
||||
print(f"ERRORE: {idx} non esiste")
|
||||
return 2
|
||||
|
||||
top, sections = parse_index(idx)
|
||||
for req in ("Name", "Directories"):
|
||||
if req not in top:
|
||||
errs.append(f"index.theme: chiave obbligatoria '{req}' mancante")
|
||||
dirs = [d.strip() for d in top.get("Directories", "").split(",") if d.strip()]
|
||||
for d in dirs:
|
||||
if d not in sections:
|
||||
errs.append(f"index.theme: manca la sezione [{d}] dichiarata in Directories")
|
||||
if not (theme / d).is_dir():
|
||||
errs.append(f"index.theme: la directory '{d}' non esiste")
|
||||
for d in dirs:
|
||||
prof = sections.get(d, {}).get("OutputProfile")
|
||||
if prof is None:
|
||||
warns.append(f"[{d}] senza OutputProfile")
|
||||
if "stereo" not in dirs:
|
||||
errs.append("index.theme: manca 'stereo' in Directories "
|
||||
"(obbligatorio come profilo di ripiego)")
|
||||
|
||||
example = top.get("Example")
|
||||
if example:
|
||||
if not any((theme / d / f"{example}.oga").is_file()
|
||||
or (theme / d / f"{example}.ogg").is_file() for d in dirs):
|
||||
errs.append(f"index.theme: Example='{example}' non trovato")
|
||||
|
||||
audio_files = sorted((theme / "stereo").glob("*.oga"))
|
||||
if not audio_files:
|
||||
errs.append("nessun file .oga in stereo/")
|
||||
print("\n".join(errs))
|
||||
return 1
|
||||
|
||||
print(f"Tema: {theme.name} (Name={top.get('Name')!r})")
|
||||
print(f"Directories: {', '.join(dirs)} Inherits: {top.get('Inherits','-')} "
|
||||
f"Example: {example or '-'}")
|
||||
print(f"File audio: {len(audio_files)}\n")
|
||||
if not args.quiet:
|
||||
print(f"{'suono':<38}{'dur':>7}{'picco':>9}{'rms':>9}")
|
||||
print("-" * 63)
|
||||
|
||||
total = 0.0
|
||||
for f in audio_files:
|
||||
e, w, info = check_audio(f, is_example=(f.stem == example))
|
||||
errs += [f"{f.name}: {m}" for m in e]
|
||||
warns += [f"{f.name}: {m}" for m in w]
|
||||
total += info["dur"]
|
||||
if not args.quiet:
|
||||
print(f"{f.stem:<38}{info['dur']:6.2f}s{info['peak_db']:8.2f}dB"
|
||||
f"{info['rms_db']:8.2f}dB")
|
||||
print(f"\nDurata totale: {total:.1f}s media {total/len(audio_files):.2f}s")
|
||||
|
||||
# --- coverage against the installed KDE sound requests ----------------- #
|
||||
if not args.no_kde_check:
|
||||
search = [theme.parent, Path("/usr/share/sounds"),
|
||||
Path.home() / ".local/share/sounds"]
|
||||
chain = theme_dirs_chain(theme, search)
|
||||
available: set[str] = set()
|
||||
for d in chain:
|
||||
available |= {p.stem for p in (d / "stereo").glob("*.oga")}
|
||||
available |= {p.stem for p in (d / "stereo").glob("*.ogg")}
|
||||
available |= {p.stem for p in (d / "stereo").glob("*.wav")}
|
||||
requested = kde_requested_names(search)
|
||||
missing = sorted(requested - available)
|
||||
print(f"\nCatena Inherits: {' -> '.join(d.name for d in chain)}")
|
||||
print(f"Nomi richiesti dai notifyrc KDE: {len(requested)}; "
|
||||
f"risolti: {len(requested) - len(missing)}")
|
||||
if missing:
|
||||
for m in missing:
|
||||
print(f" [i] non fornito (ripiega su freedesktop o silenzio): {m}")
|
||||
|
||||
print()
|
||||
for w in warns:
|
||||
print(f"WARN {w}")
|
||||
for e in errs:
|
||||
print(f"FAIL {e}")
|
||||
if errs:
|
||||
print(f"\n{len(errs)} errori, {len(warns)} avvisi")
|
||||
return 1
|
||||
print(f"OK — nessun errore, {len(warns)} avvisi")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user