Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Run Tests

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH # Ensure UV is available

- name: Run Pytest with Coverage
run: |
uv run pytest --junitxml=pytest-report.xml --cov=src # Ensure `src` matches your package


12 changes: 10 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[project]
name = "eida-a10y"
version = "1.0.3"
version = "1.0.4"
readme = "README.md"
requires-python = "<4.0,>=3.11"
dependencies = [
"aiohttp>=3.11.12",
"aiosignal>=1.3.1",
"appdirs>=1.4.4",
"async-timeout>=4.0.3",
"attrs>=23.1.0",
"certifi>=2023.11.17",
Expand All @@ -21,6 +22,8 @@ dependencies = [
"msgpack>=1.1.0",
"multidict>=6.0.4",
"pygments>=2.17.2",
"pytest>=8.3.4",
"pytest-asyncio>=0.25.3",
"requests>=2.31.0",
"rich>=13.7.0",
"textual==0.43.2",
Expand All @@ -46,4 +49,9 @@ packages = ["src/a10y"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
build-backend = "hatchling.build"

[dependency-groups]
dev = [
"pytest-cov>=6.0.0",
]
3 changes: 2 additions & 1 deletion src/a10y/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
A10y: A terminal-based availability tool with a Textual UI.
"""

__version__ = "1.0.3"
__version__ = "1.0.4"

153 changes: 153 additions & 0 deletions src/a10y/a10y.tcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
Screen {
max-width:200;
overflow: hidden;
}
Header {
max-width: 200;
}


.box {
border: solid green;
min-width: 160;
max-width:200;
}

.hide {
display: none;
}

Explanations {
border: solid gray;
padding: 1;
background: black;
color: white;
}

#explanations-keys {
text-style: bold;
padding: 1;
width: auto;
}

.version {
color: cyan;
text-style: bold italic;
padding-top: 1;
}

Requests {
layout: grid;
grid-size: 6 5;
grid-columns: 0.7fr 0.5fr 0.7fr 1fr 1fr 1fr;
grid-rows: 1 3 3 3 3;
max-height: 20;
grid-gutter: 1;
}

#request-title {
column-span: 6;
}

#nodes-container {
row-span: 3;
max-width: 24;
}
#reload-nodes{
height:100%;
align: center middle;
text-align: center;

}
#nodes {
height: 100%;
}

#send-request{
column-span:4;
width:100%;
}
#options,#nslc,#timeframe{
column-span: 5;
width:100%;
}

.request-label {
margin-top: 1;
}

.short-input {
width: 16;
}



AutoComplete {
max-height: 4;
max-width: 16;
margin-right: 3;
align: left top;
}

.date-input {
width: 27;
margin-right: 1;
}

#times {
width: 30;
}

#mergegaps {
max-width: 12;
}

#request-button {
margin: 0 5 0 5;
}

#post-file {
width: 50;
}

#status-container {
max-height: 5;
}

#status-collapse {
max-height: 10;
}

#error-results {
margin-left: 2;
}

ContentSwitcher {
margin-left: 2;
}

#info-bar {
background: $primary;
}

#lines {
height: auto;
}

#results-container {
max-height: 29;
height: auto;
}

#plain-container {
max-height: 30;
height: auto;
}

.result-item {
height: auto;
}

CursoredText {
margin-left: 2;
}
98 changes: 80 additions & 18 deletions src/a10y/app.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
from textual.app import App
from textual.widgets import Header, Footer, Checkbox, Select, Input, Button, Collapsible, ContentSwitcher,Static,Label
from textual.containers import ScrollableContainer , Container, Horizontal
from widgets import Explanations, Requests, Results, Status, CursoredText # Import modular widgets
from a10y.widgets import Explanations, Requests, Results, Status, CursoredText # Import modular widgets
import requests
from datetime import datetime, timedelta
from textual.binding import Binding
from textual_autocomplete import AutoComplete, Dropdown, DropdownItem
from textual.app import App, ComposeResult
from textual_autocomplete import DropdownItem
from textual.app import ComposeResult
from textual import work
from textual.worker import get_current_worker
import math
import os
import sys
import json
import threading
from pathlib import Path
from appdirs import user_cache_dir
from urllib.parse import urlparse
from a10y import __version__
CACHE_DIR = Path(user_cache_dir("a10y"))
CACHE_FILE = CACHE_DIR / "nodes_cache.json"
QUERY_URL = "https://www.orfeus-eu.org/eidaws/routing/1/globalconfig?format=fdsn"

class AvailabilityUI(App):
def __init__(self, nodes_urls, routing, **kwargs):
self.nodes_urls = nodes_urls # Store nodes for later use
self.routing = routing # Store routing URL
self.config = kwargs # Store remaining settings
super().__init__()

def action_quit(self) -> None:
"""Ensure terminal resets properly when quitting."""
self.exit()
Expand All @@ -28,17 +37,20 @@ def action_quit(self) -> None:
else:
os.system("reset") # Linux/macOS: Reset terminal

CSS_PATH = "a10y.css"
CSS_PATH = "a10y.tcss"
BINDINGS = [

Binding("ctrl+c", "quit", "Quit"),
Binding("tab/shift+tab", "navigate", "Navigate"),
Binding("ctrl+s", "send_button", "Send Request"),
Binding("?", "toggle_help", "Help"),
Binding("Submit Issues", "", "https://github.com/EIDA/a10y/issues"),
Binding("Submit Issues", "", "https://github.com/EIDA/a10y/issues",show=True),
Binding("Version","",f"{__version__}"),
Binding("ctrl+t", "first_line", "Move to first line", show=False),
Binding("ctrl+b", "last_line", "Move to last line", show=False),
Binding("t", "lines_view", "Toggle view to lines", show=False),
Binding("escape", "cancel_request", "Cancel request", show=False),

]

req_text = ""
Expand All @@ -54,19 +66,61 @@ def compose(self) -> ComposeResult:
id="application-container"
)
yield Footer()
def fetch_nodes_from_api(self):
"""Fetch fresh nodes from API and update cache."""
nodes_urls = []
try:
response = requests.get(QUERY_URL, timeout=60)
response.raise_for_status()
data = response.json()

for node in data.get("datacenters", []):
node_name = node["name"]
fdsnws_url = None

for repo in node.get("repositories", []):
for service in repo.get("services", []):
if service["name"] == "fdsnws-station-1":
fdsnws_url = service["url"]
break
if fdsnws_url:
break

if fdsnws_url:
parsed_url = urlparse(fdsnws_url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/fdsnws/"
nodes_urls.append((node_name, base_url, True))

if nodes_urls:
self.save_nodes_to_cache(nodes_urls)
except requests.RequestException:
pass
finally:
self.exit()



def save_nodes_to_cache(self, nodes):
"""Save nodes to cache file permanently (no expiration)."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump({"nodes": nodes}, f)

def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""A function to select/deselect all nodes when corresponding checkbox is clicked"""
if event.checkbox == self.query_one("#all-nodes"):
if self.query_one("#all-nodes").value:
self.query_one("#nodes").select_all()
else:
self.query_one("#nodes").deselect_all()
"""Toggle between 'Select all' and 'Deselect all' when the checkbox is clicked."""
all_nodes_checkbox = self.query_one("#all-nodes") # Get the checkbox widget
nodes_list = self.query_one("#nodes") # Get the nodes list

if all_nodes_checkbox.value:
nodes_list.deselect_all()
all_nodes_checkbox.label = "Select all" # ✅ Change to "Select all"
else:
nodes_list.select_all()
all_nodes_checkbox.label = "Deselect all" # ✅ Change to "Deselect all"

all_nodes_checkbox.refresh() # ✅ Force UI update




def on_select_changed(self, event: Select.Changed) -> None:
"""A function to issue appropriate request and update status when a Node or when a common time frame is selected"""
if event.select == self.query_one("#times"):
Expand Down Expand Up @@ -119,7 +173,7 @@ def parallel_requests_autocomplete(self, url, data) -> None:


@work(exclusive=True, thread=True)
def on_input_submitted(self, event: Input.Submitted) -> None:
def on_input_changed(self, event: Input.Changed) -> None:
"""A function to change status when an NSLC input field is submitted (i.e. is typed and enter is hit)"""
# COULD BE ON Change
# keep app responsive while making requests
Expand Down Expand Up @@ -231,9 +285,13 @@ def change_button_disabled(self, disabled: bool) -> None:

@work(exclusive=True, thread=True)
async def on_button_pressed(self, event: Button.Pressed) -> None:
# Disable the button to prevent multiple clicks
self.call_from_thread(lambda: self.change_button_disabled(True))

# Disable the button to prevent multiple clicks
if event.button.id == "reload-nodes":
button = self.query_one("#reload-nodes")
button.label = "Reloading..."
button.disabled = True
self.fetch_nodes_from_api()
try:
start = self.query_one("#start").value
end = self.query_one("#end").value
Expand Down Expand Up @@ -331,7 +389,11 @@ async def show_results(self, r):
infoBar = Static("Quality: Timestamp: Trace start: Trace end: ", id="info-bar")
self.query_one('#lines').mount(infoBar)
self.query_one('#lines').mount(ScrollableContainer(id="results-container"))
num_spans = 130
# Dynamically calculate num_spans based on the results container width
num_spans = self.query_one("#results-widget").size.width // 2 # Scale width properly
num_spans = max(num_spans, 160) # Ensure a reasonable span count


if not self.query_one("#start").value.strip():
self.query_one("#status-line").update(
f"{self.query_one('#status-line').renderable}\n[orange1]⚠️ Please enter a start date![/orange1]"
Expand Down
Loading