Make the module files directly runnable again; no install step

The previous commit required "pip install -e ." before anything would
run, and made "python hblink4/hblink.py" fail outright. That trades a
real support burden -- an install step people forget after git pull, and
an error message that looks like a broken checkout -- for an internal
correctness property. Not a good trade for this project's users, who have
run hblink by interpreter-plus-path since HBlink3 and DMRlink.

Establish the package context explicitly instead. When hblink4/hblink.py
or dashboard/server.py is run by path, __package__ is empty, so each now
puts the project root on sys.path and sets __package__ before its
relative imports. That is enough for "from .constants import ..." to
resolve to hblink4.constants -- one copy, not the two the old
except-ImportError fallback produced. Importing the parent package is
cheap because __init__.py is metadata only.

Every invocation style now works and none of them double-load:

  python hblink4/hblink.py config/config.json      <- systemd uses this
  python dashboard/server.py                       <- systemd uses this
  python run_hblink.py config/config.json          <- convenience
  python run_dashboard.py                          <- convenience
  python -m hblink4.hblink
  hblink4                                          <- if pip installed

The systemd units invoke the module files directly rather than going
through a launcher, so there is one less moving part between systemd and
the program. run_hblink.py and run_dashboard.py stay in the project root
as launchers for interactive use, but they are now genuinely logic-free:
the uvicorn call that used to live in run_dashboard.py is dashboard.server.run().

requirements.txt and requirements-dashboard.txt come back as the
documented install path. pyproject.toml stays for those who want
"pip install -e .", but is now optional and reads its dependencies from
those files so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cortandClaude Opus 5 committed 2026-08-06 20:54:37 -05:00
1 parent 6a644a818c
commit f1a2e4133c
15 files changed
+126 -67

No files matched your search

+5 -4
View File
@@ -50,13 +50,14 @@ except ImportError:
```bash
# Development setup
python3 -m venv venv && source venv/bin/activate
pip install -e ".[dashboard,dev]" # editable install + console scripts
pip install -r requirements.txt
pip install -r requirements-dashboard.txt
# Run server only
hblink4 [config/config.json] # console script from pip install -e .
python run_hblink.py [config/config.json] # or: python hblink4/hblink.py ...
# Run dashboard only (bind/port from the 'web' section of dashboard/config.json)
hblink4-dashboard [bind] [port]
python run_dashboard.py [bind] [port] # or: python dashboard/server.py ...
# Run both (production-like)
./run_all.sh
@@ -105,7 +106,7 @@ target_repeater = user_cache.lookup_user(target_id)
- **Core logic**: `hblink4/` package with protocol handling
- **Configuration**: `config/config.json` (server), `dashboard/config.json` (web)
- **Entry points**: console scripts `hblink4` -> `hblink4.hblink:main` and `hblink4-dashboard` -> `dashboard.server:run`, declared in `pyproject.toml`; `run_all.sh` runs both for development. Never run the module files by path -- it loads the package twice.
- **Entry points**: `hblink4/hblink.py` and `dashboard/server.py` are directly runnable (what systemd uses). `run_hblink.py` / `run_dashboard.py` in the root are logic-free convenience launchers; `run_all.sh` runs both. No install step required. `pip install -e .` additionally provides `hblink4` / `hblink4-dashboard` commands.
- **Deployment**: Systemd services expect user ownership, virtual environment
- **Data**: `logs/`, `dashboard/data/` for persistence
- **Tests**: `tests/` with pytest, focus on access control and stream tracking
+8 -14
View File
@@ -9,30 +9,24 @@ This directory contains systemd service files for running HBlink4 and its dashbo
## Upgrading an existing install
**The launcher scripts `run.py` and `run_dashboard.py` have been removed, and
HBlink4 is now installed as a package with console-script entry points.** Older
unit files point at files that no longer exist and **will fail on the next
restart** — install the package and update the units before restarting anything.
**The units now invoke the modules directly** instead of going through a
launcher script, so there is one less moving part between systemd and the
program. `run_hblink.py` and `run_dashboard.py` still exist in the project root
as convenience launchers for interactive use. There is **no install step** —
everything runs straight from the checkout, so a `git pull` needs nothing beyond
a restart.
What changed in the units:
| | Old | New |
|---|---|---|
| `hblink4` `ExecStart` | `venv/bin/python .../run.py` | `venv/bin/hblink4 /home/cort/hblink4/config/config.json` |
| `hblink4-dash` `ExecStart` | `venv/bin/python .../run_dashboard.py` | `venv/bin/hblink4-dashboard` (bind/port from `dashboard/config.json`) |
| `hblink4` `ExecStart` | `venv/bin/python .../run.py` | `venv/bin/python .../hblink4/hblink.py .../config/config.json` |
| `hblink4-dash` `ExecStart` | `venv/bin/python .../run_dashboard.py` | `venv/bin/python .../dashboard/server.py` (bind/port now from `dashboard/config.json`) |
| `hblink4-dash` `WorkingDirectory` | `.../hblink4/dashboard` | `.../hblink4` |
| both | `PrivateTmp=true` | *removed* — see [Security Features](#security-features) |
The entry points exist because running the module files by path puts the
package's interior on `sys.path` and loads every module twice under two sets of
names — the dual import trap. Install first, then swap the units:
```bash
cd /home/cort/hblink4
source venv/bin/activate
pip install -e ".[dashboard]"
ls -l venv/bin/hblink4 venv/bin/hblink4-dashboard # both should exist
sudo cp hblink4.service hblink4-dash.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart hblink4 hblink4-dash
+1 -1
View File
@@ -81,7 +81,7 @@ The button appears as the first item in the header status area with a light blue
## Usage
The dashboard is started automatically with `./run_all.sh`, or separately with the `hblink4-dashboard` command (installed by `pip install -e ".[dashboard]"`). It binds the address and port from the `web` section of `dashboard/config.json` (default `0.0.0.0:8080`); passing `[bind] [port]` on the command line overrides that for one-off runs. Access at http://localhost:8080 (or your server IP for remote access).
The dashboard is started automatically with `./run_all.sh`, or separately with `python3 run_dashboard.py` (or `python3 dashboard/server.py`) from the repository root. It binds the address and port from the `web` section of `dashboard/config.json` (default `0.0.0.0:8080`); passing `[bind] [port]` on the command line overrides that for one-off runs. Access at http://localhost:8080 (or your server IP for remote access).
## Dashboard Components
+10 -7
View File
@@ -21,13 +21,16 @@ from fastapi.staticfiles import StaticFiles
from pathlib import Path
import logging
try:
from .user_db import UserDatabase, compute_next_refresh_seconds, _age_str
except ImportError:
# Running the dashboard directly without package install
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent))
from user_db import UserDatabase, compute_next_refresh_seconds, _age_str
# Allow this file to be run directly by path (python dashboard/server.py) as
# well as imported. Establishing the package context means user_db is imported
# once, as dashboard.user_db, rather than a second bare copy under sys.path
# surgery.
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).parent.parent))
__package__ = "dashboard"
import dashboard # noqa: F401 (parent package must be in sys.modules)
from .user_db import UserDatabase, compute_next_refresh_seconds, _age_str
# Configure logging
logging.basicConfig(level=logging.INFO)
+1 -1
View File
@@ -237,7 +237,7 @@ The `web` section is the **browser-facing web UI**:
}
```
`hblink4-dashboard [bind] [port]` overrides these for one-off runs; with no arguments the config values are used.
`python3 run_dashboard.py [bind] [port]` overrides these for one-off runs; with no arguments the config values are used.
The `event_receiver` section is the **listening socket for the link from HBlink4**:
+1 -1
View File
@@ -8,7 +8,7 @@ Type=simple
User=cort
Group=cort
WorkingDirectory=/home/cort/hblink4
ExecStart=/home/cort/hblink4/venv/bin/hblink4-dashboard
ExecStart=/home/cort/hblink4/venv/bin/python /home/cort/hblink4/dashboard/server.py
Restart=always
RestartSec=10
+1 -1
View File
@@ -7,7 +7,7 @@ Type=simple
User=cort
Group=cort
WorkingDirectory=/home/cort/hblink4
ExecStart=/home/cort/hblink4/venv/bin/hblink4 /home/cort/hblink4/config/config.json
ExecStart=/home/cort/hblink4/venv/bin/python /home/cort/hblink4/hblink4/hblink.py /home/cort/hblink4/config/config.json
Restart=always
RestartSec=10
+11 -4
View File
@@ -32,10 +32,17 @@ LOGGER = logging.getLogger(__name__)
import os
import sys
# Package-relative imports only. There is deliberately no "except ImportError"
# fallback to bare module names: that fallback let hblink.py be run by file path,
# which loads this package's modules twice under two sets of names (see the note
# in __init__.py). Run the installed console script, or python -m hblink4.hblink.
# Allow this file to be run directly by path (python hblink4/hblink.py) as well
# as imported. Establishing the package context up front means the relative
# imports below resolve to hblink4.* exactly once -- the older approach of
# falling back to bare "from constants import ..." loaded every module a second
# time under a second name (see the note in __init__.py). Cheap: __init__.py is
# metadata only, so importing the parent package pulls in nothing else.
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
__package__ = "hblink4"
import hblink4 # noqa: F401 (parent package must be in sys.modules)
from .constants import (
RPTA, RPTL, RPTK, RPTC, RPTCL, MSTCL, DMRD,
MSTNAK, MSTPONG, RPTPING, RPTACK, RPTP, RPTO, DMRA
+9 -18
View File
@@ -9,24 +9,13 @@ readme = "readme.md"
requires-python = ">=3.9"
license = "GPL-3.0-or-later"
authors = [{ name = "Cort Buffington, N0MJS" }]
dynamic = ["version"]
dependencies = [
"typing-extensions>=4.7.1",
"dmr_utils3>=0.1.29",
]
[project.optional-dependencies]
# The dashboard is a separate process; install with: pip install -e ".[dashboard]"
dashboard = [
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"websockets>=12.0",
]
dev = [
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
]
# Installing is OPTIONAL. HBlink4 runs straight from a git checkout:
# pip install -r requirements.txt -r requirements-dashboard.txt
# python hblink.py config/config.json
# This file exists for people who prefer `pip install -e .`, which additionally
# provides `hblink4` / `hblink4-dashboard` commands. Dependencies are read from
# the requirements files so there is only one place to declare them.
dynamic = ["version", "dependencies", "optional-dependencies"]
[project.urls]
Homepage = "https://github.com/n0mjs710/HBlink4"
@@ -39,6 +28,8 @@ hblink4-dashboard = "dashboard.server:run"
[tool.setuptools.dynamic]
version = { attr = "hblink4.__version__" }
dependencies = { file = ["requirements.txt"] }
optional-dependencies.dashboard = { file = ["requirements-dashboard.txt"] }
[tool.setuptools]
packages = ["hblink4", "dashboard"]
+17 -9
View File
@@ -53,13 +53,14 @@ python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install HBlink4 and its dependencies:
3. Install requirements:
```bash
pip install -e ".[dashboard]" # add ,dev for the test suite
pip install -r requirements.txt
pip install -r requirements-dashboard.txt # if you want the dashboard
```
This installs the package in editable mode and creates two commands in the
virtual environment: `hblink4` and `hblink4-dashboard`.
HBlink4 runs straight from the checkout — there is no install step, and
nothing to redo after a `git pull`.
## Configuration
@@ -80,14 +81,21 @@ For production deployments with automatic startup, see [SYSTEMD.md](SYSTEMD.md).
# Start all services together
./run_all.sh
# Or start services separately (with the venv activated):
hblink4 config/config.json # HBlink4 server
hblink4-dashboard # Web dashboard (in another terminal)
# Or start services separately (from the repository root):
python3 run_hblink.py config/config.json # HBlink4 server
python3 run_dashboard.py # Web dashboard (in another terminal)
```
Both read their configuration from disk: the server's config path defaults to `config/config.json`, and the dashboard takes its bind address and port from the `web` section of `dashboard/config.json`. Each can be overridden on the command line — `hblink4 <config>` and `hblink4-dashboard [bind] [port]` — for one-off runs.
`run_hblink.py` and `run_dashboard.py` are convenience launchers containing no
logic. The modules they point at run just as well on their own, which is what
the systemd units use — no venv activation needed:
Run the modules by file path and Python loads the package twice under two sets of names; use these commands, or `python3 -m hblink4.hblink`.
```bash
/path/to/hblink4/venv/bin/python /path/to/hblink4/hblink4/hblink.py /path/to/hblink4/config/config.json
/path/to/hblink4/venv/bin/python /path/to/hblink4/dashboard/server.py
```
Both read their configuration from disk: the server's config path defaults to `config/config.json`, and the dashboard takes its bind address and port from the `web` section of `dashboard/config.json`. Each accepts the same overrides on the command line for one-off runs.
Access the dashboard at http://localhost:8080. See [Dashboard Documentation](dashboard/README.md) for features and configuration.
+11
View File
@@ -0,0 +1,11 @@
# HBlink4 Dashboard Requirements
# Install with: pip install -r requirements-dashboard.txt
# Web framework
fastapi>=0.104.0
# ASGI server with WebSocket support
uvicorn[standard]>=0.24.0
# WebSocket support (included in uvicorn[standard])
websockets>=12.0
+6
View File
@@ -0,0 +1,6 @@
# asyncio is part of Python 3.7+ standard library (no external dependency needed)
# Removed: twisted>=22.10.0, pyOpenSSL>=23.2.0, service_identity>=23.1.0
typing-extensions>=4.7.1 # For Python <3.9 compatibility
pytest>=7.4.0 # For running tests
pytest-cov>=4.1.0 # For test coverage reports
dmr_utils3>=0.1.29 # For DMR FEC decoding and LC extraction
+7 -7
View File
@@ -15,7 +15,7 @@ NC='\033[0m' # No Color
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo -e "${YELLOW}Virtual environment not found. Please run: python3 -m venv venv && source venv/bin/activate && pip install -e \".[dashboard]\"${NC}"
echo -e "${YELLOW}Virtual environment not found. Please run: python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt${NC}"
exit 1
fi
@@ -23,10 +23,10 @@ fi
echo -e "${BLUE}Activating virtual environment...${NC}"
source venv/bin/activate
# Check that the package (and its dashboard extra) is installed
if ! command -v hblink4-dashboard >/dev/null 2>&1; then
echo -e "${YELLOW}HBlink4 not installed in the venv. Installing...${NC}"
pip install -e ".[dashboard]"
# Check if dashboard requirements are installed
if ! python3 -c "import fastapi" 2>/dev/null; then
echo -e "${YELLOW}Dashboard dependencies not found. Installing...${NC}"
pip install -r requirements-dashboard.txt
fi
# Function to cleanup on exit
@@ -44,7 +44,7 @@ echo
# Start dashboard in background
echo -e "${BLUE}Starting Dashboard (bind/port from dashboard/config.json)...${NC}"
hblink4-dashboard &
python3 run_dashboard.py &
DASHBOARD_PID=$!
# Give dashboard time to start
@@ -52,7 +52,7 @@ sleep 2
# Start HBlink4 server
echo -e "${BLUE}Starting HBlink4 server...${NC}"
hblink4 config/config.json &
python3 run_hblink.py config/config.json &
HBLINK_PID=$!
echo
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""
Convenience launcher for the HBlink4 dashboard.
python run_dashboard.py [bind] [port]
Purely a shortcut for interactive use -- it contains no logic beyond locating
dashboard/server.py, which is equally runnable on its own:
python dashboard/server.py [bind] [port]
The systemd unit uses that direct path rather than this script. With no
arguments the bind address and port come from the 'web' section of
dashboard/config.json.
"""
from dashboard.server import run
if __name__ == '__main__':
run()
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""
Convenience launcher for the HBlink4 server.
python run_hblink.py [config/config.json]
Purely a shortcut for interactive use -- it contains no logic beyond locating
hblink4/hblink.py, which is equally runnable on its own:
python hblink4/hblink.py [config/config.json]
The systemd unit uses that direct path rather than this script.
"""
from hblink4.hblink import main
if __name__ == '__main__':
main()