19 Commits

Author SHA1 Message Date
herel 0a88217443 chore: bump version to v0.5.4 2025-08-19 19:06:47 +02:00
herel 2d783778a7 feat: update agegroup filter to show only agegroup and filter accordingly 2025-08-19 19:06:24 +02:00
herel a3d4114044 chore: bump version to v0.5.3 2025-08-19 18:50:12 +02:00
herel cec54a45d7 fix: improve UI layout and add loading indicator for events 2025-08-19 18:48:56 +02:00
herel 3efa7101e1 fix: finalize UI improvements and ensure all functionality works
Final adjustments to UI layout and positioning. Ensured all JavaScript functions are properly connected and working. Verified event loading functionality is fully restored.
2025-08-19 16:12:51 +02:00
herel 861ff0650f fix: restore event loading functionality and improve UI layout
Fixed incomplete fetchEvents function by adding missing data processing logic. Restored updateAgeGroupOptions, displayEvents, and fetchEventDetails functions. Moved connected user info to top-right corner. Hide title when logged in. Reduced vertical spacing for better layout. Made event filters more compact. All functionality restored and UI improved.
2025-08-19 16:09:57 +02:00
herel 73f72d1bbe feat: enhance login button styling and fix JavaScript syntax errors
Centered the login button on the page with improved styling including padding and shadow effects. Fixed JavaScript syntax errors in the event handling code. Removed unmatched closing div tags to ensure valid HTML structure.
2025-08-19 15:49:28 +02:00
herel a407a108ed fix: improve user authentication and account handling
Fixed issues with user display by fetching user info from userinfo endpoint

Improved error handling for JSON responses in schedule endpoint

Fixed account selection to use available accounts from config instead of default

Enhanced frontend to properly handle API responses and errors
2025-08-19 15:17:45 +02:00
herel d6277d7766 fix: improve authentication handling and error management 2025-08-19 15:04:53 +02:00
herel 5b1d741a16 feat: implement OpenID Connect authentication with Infomaniak 2025-08-19 11:16:07 +02:00
herel 5957868e0f chore: bump version to v0.5.2 2025-08-19 10:48:55 +02:00
herel 525d3bf326 fix: escape control characters in JSON output to make it jq compatible 2025-08-19 10:48:04 +02:00
herel 0e1eb0da3f chore: bump version to v0.5.1 2025-08-19 09:41:57 +02:00
herel 4b81cc7f9f fix: improve JSON response handling in API calls 2025-08-19 09:41:28 +02:00
herel c4d9236b16 feat: make config_section a global CLI option
Users can now specify --config-section once at the beginning of the command instead of repeating it for each subcommand. Also bumped version to v0.5.0 for this new feature.
2025-08-19 09:25:50 +02:00
herel 2a5883375f chore: display userid after fetch 2025-08-19 09:10:04 +02:00
herel c4b6e39e9e fix: correct project scripts section name and bump version
- Changed [projectscripts] to [project.scripts] in pyproject.toml to properly register the myice command line tool
- Bump version to v0.4.3
2025-08-19 09:03:42 +02:00
herel d3b5b6b6fd fix: resolve typer/click compatibility issue with make_metavar error
Fixed TypeError: Parameter.make_metavar() missing 1 required positional argument: 'ctx'
by correcting typer.Option(...) usage for optional parameters with default None values.

Bump version to v0.4.2
2025-08-19 08:57:37 +02:00
herel bcde9fccf5 chore: bump version to v0.4.1 and update dependencies 2025-08-19 08:52:39 +02:00
7 changed files with 688 additions and 195 deletions
+8 -8
View File
@@ -3,7 +3,7 @@
--- ---
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: check-case-conflict - id: check-case-conflict
@@ -15,7 +15,7 @@ repos:
- id: detect-private-key - id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.6.8 rev: v0.12.9
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff
@@ -24,26 +24,26 @@ repos:
- id: ruff-format - id: ruff-format
args: [--diff, --target-version, py312] args: [--diff, --target-version, py312]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2 rev: v1.17.1
hooks: hooks:
- id: mypy - id: mypy
exclude: ^(docs/|example-plugin/) exclude: ^(docs/|example-plugin/)
args: [--ignore-missing-imports] args: [--ignore-missing-imports]
additional_dependencies: [types-requests, PyPDF2] additional_dependencies: [types-requests, PyPDF2]
- repo: https://github.com/adrienverge/yamllint.git - repo: https://github.com/adrienverge/yamllint.git
rev: v1.35.1 rev: v1.37.1
hooks: hooks:
- id: yamllint - id: yamllint
args: [--strict] args: [--strict]
- repo: https://github.com/markdownlint/markdownlint - repo: https://github.com/markdownlint/markdownlint
rev: v0.12.0 rev: v0.13.0
hooks: hooks:
- id: markdownlint - id: markdownlint
exclude: "^.github|(^docs/_sidebar\\.md$)" exclude: "^.github|(^docs/_sidebar\\.md$)"
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1 rev: v0.11.0.1
hooks: hooks:
- id: shellcheck - id: shellcheck
args: ["--severity=error"] args: ["--severity=error"]
@@ -51,12 +51,12 @@ repos:
files: "\\.sh$" files: "\\.sh$"
- repo: https://github.com/golangci/misspell - repo: https://github.com/golangci/misspell
rev: v0.6.0 rev: v0.7.0
hooks: hooks:
- id: misspell - id: misspell
args: ["-i", "charactor"] args: ["-i", "charactor"]
- repo: https://github.com/python-poetry/poetry - repo: https://github.com/python-poetry/poetry
rev: "2.0.0" rev: "2.1.4"
hooks: hooks:
- id: poetry-check - id: poetry-check
- id: poetry-lock - id: poetry-lock
+21 -2
View File
@@ -76,15 +76,34 @@ Then open your browser at `http://localhost:8000`. The web interface allows you
- View upcoming games and practices - View upcoming games and practices
- See detailed information about events including player rosters - See detailed information about events including player rosters
### Authentication
The web interface supports two authentication methods:
1. **Infomaniak OpenID Connect (Recommended)**: Click the "Se connecter avec Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only users in the allowed list will be granted access.
2. **Static API Key**: For development purposes, you can still use `abc` as the token.
### Environment Variables
To configure OIDC authentication, set the following environment variables:
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_SECRET`: Your OIDC client secret
- `REDIRECT_URI`: The redirect URI (default: http://localhost:8000/callback)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., "user1@example.com,user2@example.com")
The web API provides the following endpoints: The web API provides the following endpoints:
- `/schedule` - Get the schedule for a specific account - `/schedule` - Get the schedule for a specific account
- `/game/{game_id}` - Get details for a specific game - `/game/{game_id}` - Get details for a specific game
- `/accounts` - Get a list of available accounts - `/accounts` - Get a list of available accounts
- `/health` - Health check endpoint - `/health` - Health check endpoint
- `/login` - Initiate OIDC login flow
- `/callback` - Handle OIDC callback
- `/userinfo` - Get user information
All endpoints (except `/health`) require an Authorization header with a Bearer token. All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
For development purposes, you can use `abc` as the token.
## mobile functions ## mobile functions
+300 -60
View File
@@ -10,28 +10,49 @@
</head> </head>
<body> <body>
<div class="container mt-4"> <div id="connectedUser" class="position-fixed top-0 end-0 m-2 p-2 bg-light border rounded"
<h1 class="mb-4 text-center">MyIce - Games</h1> style="display: none; z-index: 1000; font-size: 0.9rem;">
<span>Connecté: <span id="userName"></span></span>
<button id="disconnect" class="btn btn-danger btn-sm ms-2"
style="font-size: 0.8rem; padding: 0.1rem 0.3rem;">Déco</button>
</div>
<div id="apikeyContainer" class="mb-3"></div> <div class="container-fluid d-flex align-items-center justify-content-center">
<div class="text-center">
<h1 id="loginTitle" class="mb-4">MyIce - Games</h1>
<div id="loginContainer" class="mb-3">
<div id="oidcLoginSection">
<button id="oidcLoginBtn" class="btn btn-primary btn-md px-4 py-3 shadow">Se connecter</button>
</div>
</div>
</div>
</div>
<div class="container mt-2" id="mainContent" style="display: none;">
<div id="eventFilters" style="display: none;"> <div id="eventFilters" style="display: none;">
<div class="mb-3"> <div class="mb-3">
<label for="account" class="form-label">Sélectionner un compte</label> <label for="account" class="form-label">Compte</label>
<select id="account" class="form-select"> <select id="account" class="form-select">
<option value="default">Compte par défaut</option> <option value="default">Défaut</option>
</select> </select>
</div> <label for="agegroup" class="form-label">Âge</label>
<div class="mb-3">
<label for="agegroup" class="form-label">Filtrer par groupe d'âge</label>
<select id="agegroup" class="form-select"> <select id="agegroup" class="form-select">
<option value="">Tous</option> <option value="">Tous</option>
</select> </select>
<button id="fetchEvents" class="btn btn-primary" style="margin-top: 1.2rem;">Charger</button>
</div> </div>
<button id="fetchEvents" class="btn btn-primary mb-3">Charger les événements</button>
</div> </div>
<div id="eventList" class="row"></div> <div id="loadingIndicator" class="text-center my-3" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2">Chargement des événements...</p>
</div>
<div id="eventList" class="row mt-2"></div>
<!-- Modal pour afficher les détails d'un événement --> <!-- Modal pour afficher les détails d'un événement -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsLabel" <div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsLabel"
@@ -50,7 +71,9 @@
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const apikeyContainer = document.getElementById("apikeyContainer"); const loginContainer = document.getElementById("loginContainer");
const oidcLoginSection = document.getElementById("oidcLoginSection");
const connectedUser = document.getElementById("connectedUser");
const eventFilters = document.getElementById("eventFilters"); const eventFilters = document.getElementById("eventFilters");
const accountSelect = document.getElementById("account"); const accountSelect = document.getElementById("account");
const agegroupSelect = document.getElementById("agegroup"); const agegroupSelect = document.getElementById("agegroup");
@@ -62,43 +85,174 @@
let storedApiKey = localStorage.getItem("apikey"); let storedApiKey = localStorage.getItem("apikey");
let lastFetchedEvents = []; let lastFetchedEvents = [];
let storedAccount = localStorage.getItem("account") || "default"; let storedAccount = localStorage.getItem("account") || "default";
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
function renderApiKeyInput() { // If we have an API key but no userInfo, fetch it from the server
if (storedApiKey) { if (storedApiKey && !userInfo) {
apikeyContainer.innerHTML = `<button id="disconnect" class="btn btn-danger">Déconnecter</button>`; fetch(`${apiBaseUrl}/userinfo`, {
document.getElementById("disconnect").addEventListener("click", () => { headers: { "Authorization": `Bearer ${storedApiKey}` }
})
.then(response => response.json())
.then(userData => {
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
renderLoginSection();
})
.catch(e => {
console.error("Error fetching user info:", e);
// If we can't fetch user info, proceed with rendering
renderLoginSection();
});
} else {
// Render the login section normally
renderLoginSection();
}
// Handle the static "abc" key case - removed for security
/*
if (storedApiKey === "abc" && !userInfo) {
userInfo = {email: "utilisateur@example.com"};
localStorage.setItem("userInfo", JSON.stringify(userInfo));
}
*/
// Handle OIDC callback
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const accessToken = hashParams.get('access_token');
// Display error message if present
if (error) {
alert("Erreur d'authentification: " + decodeURIComponent(error));
// Remove error from URL
window.history.replaceState({}, document.title, "/");
}
// Handle access token in URL fragment
if (accessToken) {
// Store the access token
localStorage.setItem("apikey", accessToken);
storedApiKey = accessToken;
// Get user info from the userinfo endpoint
fetch(`${apiBaseUrl}/userinfo`, {
headers: { "Authorization": `Bearer ${accessToken}` }
})
.then(response => response.json())
.then(userData => {
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
// Update UI
renderLoginSection();
})
.catch(e => {
console.error("Error fetching user info:", e);
// Fallback to parsing token as JWT
try {
// First, try to parse as JWT
const tokenParts = accessToken.split('.');
let userData = {};
if (tokenParts.length === 3) {
// Standard JWT format
const payload = tokenParts[1];
if (payload) {
// Add padding if needed
const paddedPayload = payload.padEnd(payload.length + (4 - payload.length % 4) % 4, '=');
const decodedPayload = atob(paddedPayload);
userData = JSON.parse(decodedPayload);
}
} else {
// Non-JWT token, treat as opaque token
console.log("Non-JWT token received, using default user info");
userData = { email: "utilisateur@" + window.location.hostname };
}
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
} catch (parseError) {
console.error("Error decoding token:", parseError);
// Fallback to a generic user object
userInfo = { email: "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
}
// Update UI
renderLoginSection();
});
// Remove token from URL
window.history.replaceState({}, document.title, "/");
}
function renderLoginSection() {
const mainContent = document.getElementById("mainContent");
const loginView = document.querySelector(".container-fluid");
const connectedUser = document.getElementById("connectedUser");
const loginTitle = document.getElementById("loginTitle");
if (storedApiKey || userInfo) {
// User is logged in
loginView.style.display = "none";
mainContent.style.display = "block";
loginTitle.style.display = "none";
connectedUser.style.display = "block";
oidcLoginSection.style.display = "none";
const userNameElement = document.getElementById("userName");
if (userInfo && userInfo.email) {
userNameElement.textContent = userInfo.email;
} else {
userNameElement.textContent = "Utilisateur";
}
eventFilters.style.display = "block";
updateAccountOptions();
// Don't automatically fetch events on page load
// Wait for user to explicitly select an account or click fetch
} else {
// User is not logged in
loginView.style.display = "flex";
mainContent.style.display = "none";
loginTitle.style.display = "block";
connectedUser.style.display = "none";
oidcLoginSection.style.display = "block";
eventFilters.style.display = "none";
// Remove any existing event listeners to prevent duplicates
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
const newOidcLoginBtn = oidcLoginBtn.cloneNode(true);
oidcLoginBtn.parentNode.replaceChild(newOidcLoginBtn, oidcLoginBtn);
newOidcLoginBtn.addEventListener("click", initiateOIDCLogin);
}
// Add disconnect handler
const disconnectBtn = document.getElementById("disconnect");
if (disconnectBtn) {
// Remove any existing event listeners to prevent duplicates
const newDisconnectBtn = disconnectBtn.cloneNode(true);
disconnectBtn.parentNode.replaceChild(newDisconnectBtn, disconnectBtn);
newDisconnectBtn.addEventListener("click", logout);
}
}
function initiateOIDCLogin() {
// Redirect to backend login endpoint
window.location.href = `${apiBaseUrl}/login`;
}
function logout() {
localStorage.removeItem("apikey"); localStorage.removeItem("apikey");
localStorage.removeItem("account"); localStorage.removeItem("account");
localStorage.removeItem("userInfo");
storedApiKey = null;
userInfo = null;
location.reload(); location.reload();
});
eventFilters.style.display = "block";
// Load available accounts from server or use predefined ones
updateAccountOptions();
fetchEvents(storedApiKey, storedAccount);
} else {
apikeyContainer.innerHTML = `
<label for="apikey" class="form-label">API Key</label>
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key">
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button>
`;
eventFilters.style.display = "none";
document.getElementById("validateApiKey").addEventListener("click", saveApiKey);
document.getElementById("apikey").addEventListener("keypress", function (event) {
if (event.key === "Enter") {
saveApiKey();
}
});
}
}
function saveApiKey() {
const key = document.getElementById("apikey").value;
if (key) {
localStorage.setItem("apikey", key);
location.reload();
} else {
alert("Veuillez entrer une clé API valide.");
}
} }
function updateAccountOptions() { function updateAccountOptions() {
@@ -106,29 +260,61 @@
fetch(`${apiBaseUrl}/accounts`, { fetch(`${apiBaseUrl}/accounts`, {
headers: { "Authorization": `Bearer ${storedApiKey}` } headers: { "Authorization": `Bearer ${storedApiKey}` }
}) })
.then(response => response.json()) .then(response => {
if (!response.ok) {
return response.json().then(errorData => {
console.error("Accounts error response:", errorData);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.detail || 'Unknown error'}`);
});
}
return response.json();
})
.then(accounts => { .then(accounts => {
// Check if accounts is actually an array
if (!Array.isArray(accounts)) {
console.error("Accounts data is not an array:", accounts);
throw new Error("Invalid accounts data format");
}
accountSelect.innerHTML = ''; accountSelect.innerHTML = '';
// If no accounts are available, add a default option
if (accounts.length === 0) {
const option = document.createElement("option");
option.value = "default";
option.textContent = "Default";
accountSelect.appendChild(option);
return;
}
// Add all available accounts
accounts.forEach(account => { accounts.forEach(account => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = account.name; option.value = account.name;
option.textContent = account.label; option.textContent = account.label;
if (account.name === storedAccount) {
option.selected = true;
}
accountSelect.appendChild(option); accountSelect.appendChild(option);
}); });
// Select the stored account if it exists, otherwise select the first account
let accountToSelect = storedAccount;
if (!accounts.some(account => account.name === storedAccount)) {
accountToSelect = accounts[0].name;
// Update stored account
storedAccount = accountToSelect;
localStorage.setItem("account", accountToSelect);
}
// Set the selected account in the dropdown
accountSelect.value = accountToSelect;
}) })
.catch(error => { .catch(error => {
console.error("Erreur lors du chargement des comptes:", error); console.error("Erreur lors du chargement des comptes:", error);
// Fallback to default options alert("Accès refusé. Vous n'êtes pas autorisé à accéder à cette application. Veuillez contacter l'administrateur.");
accountSelect.innerHTML = ` logout();
<option value="default" ${storedAccount === "default" ? "selected" : ""}>Compte par défaut</option>
`;
}); });
} }
renderApiKeyInput(); renderLoginSection();
accountSelect.addEventListener("change", () => { accountSelect.addEventListener("change", () => {
const selectedAccount = accountSelect.value; const selectedAccount = accountSelect.value;
@@ -140,7 +326,7 @@
fetchButton.addEventListener("click", () => { fetchButton.addEventListener("click", () => {
if (!storedApiKey) { if (!storedApiKey) {
alert("Veuillez entrer une clé API"); alert("Veuillez vous connecter");
return; return;
} }
const selectedAccount = accountSelect.value; const selectedAccount = accountSelect.value;
@@ -152,22 +338,75 @@
}); });
function fetchEvents(apiKey, account) { function fetchEvents(apiKey, account) {
// Show loading indicator
const loadingIndicator = document.getElementById("loadingIndicator");
const eventList = document.getElementById("eventList");
loadingIndicator.style.display = "block";
eventList.innerHTML = ""; // Clear previous events
fetch(`${apiBaseUrl}/schedule?account=${account}`, { fetch(`${apiBaseUrl}/schedule?account=${account}`, {
headers: { "Authorization": `Bearer ${apiKey}` } headers: { "Authorization": `Bearer ${apiKey}` }
}) })
.then(response => response.json()) .then(response => {
if (response.status === 401) {
// Token expired or invalid
alert("Votre session a expiré. Veuillez vous reconnecter.");
logout();
return;
}
if (!response.ok) {
// Try to parse error response as JSON, but handle plain text as well
return response.text().then(errorText => {
let errorMessage = 'Unknown error';
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.detail || errorData.message || errorText;
} catch (e) {
// If parsing fails, use the raw text
errorMessage = errorText || 'Unknown error';
}
console.error("Schedule error response:", errorText);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorMessage}`);
});
}
return response.text().then(text => {
try {
return JSON.parse(text);
} catch (e) {
console.error("Invalid JSON response:", text);
throw new Error("Le serveur a renvoyé une réponse invalide. Veuillez réessayer.");
}
});
})
.then(data => { .then(data => {
// Hide loading indicator
loadingIndicator.style.display = "none";
if (data) {
// Check if data is an array
if (!Array.isArray(data)) {
console.error("Schedule data is not an array:", data);
throw new Error("Invalid schedule data format");
}
lastFetchedEvents = data.filter(event => event.event === "Jeu"); lastFetchedEvents = data.filter(event => event.event === "Jeu");
updateAgeGroupOptions(lastFetchedEvents); updateAgeGroupOptions(lastFetchedEvents);
displayEvents(lastFetchedEvents); displayEvents(lastFetchedEvents);
}
}) })
.catch(error => console.error("Erreur lors du chargement des événements:", error)); .catch(error => {
// Hide loading indicator on error
loadingIndicator.style.display = "none";
console.error("Erreur lors du chargement des événements:", error);
alert("Erreur lors du chargement des événements: " + error.message);
});
} }
function updateAgeGroupOptions(events) { function updateAgeGroupOptions(events) {
let agegroups = new Set(events.map(event => `${event.agegroup} ${event.name}`.trim())); let agegroups = new Set(events.map(event => event.agegroup));
agegroupSelect.innerHTML = '<option value="">Tous</option>'; agegroupSelect.innerHTML = '<option value="">Tous</option>';
agegroups.forEach(group => { Array.from(agegroups).sort().forEach(group => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = group; option.value = group;
option.textContent = group; option.textContent = group;
@@ -178,7 +417,7 @@
function displayEvents(events) { function displayEvents(events) {
eventList.innerHTML = ""; eventList.innerHTML = "";
let selectedAgegroup = agegroupSelect.value; let selectedAgegroup = agegroupSelect.value;
let filteredEvents = events.filter(event => event.event === "Jeu" && (selectedAgegroup === "" || `${event.agegroup} ${event.name}` === selectedAgegroup)); let filteredEvents = events.filter(event => event.event === "Jeu" && (selectedAgegroup === "" || event.agegroup === selectedAgegroup));
if (filteredEvents.length === 0) { if (filteredEvents.length === 0) {
eventList.innerHTML = "<p class='text-muted'>Aucun événement 'Jeu' trouvé.</p>"; eventList.innerHTML = "<p class='text-muted'>Aucun événement 'Jeu' trouvé.</p>";
@@ -191,7 +430,8 @@
eventCard.innerHTML = ` eventCard.innerHTML = `
<div class="card" style="border-left: 5px solid ${event.color}" data-id="${event.id_event}"> <div class="card" style="border-left: 5px solid ${event.color}" data-id="${event.id_event}">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">${event.title}</h5> <h5 class="card-title">${event.agegroup} - ${event.name}</h5>
<p class="card-text"><strong>Adversaire:</strong> ${event.opponent}</p>
<p class="card-text"><strong>Lieu:</strong> ${event.place}</p> <p class="card-text"><strong>Lieu:</strong> ${event.place}</p>
<p class="card-text"><strong>Heure:</strong> ${event.start} - ${event.end}</p> <p class="card-text"><strong>Heure:</strong> ${event.start} - ${event.end}</p>
</div> </div>
+104 -84
View File
@@ -75,7 +75,29 @@ def sanitize_json_response(text):
except json.JSONDecodeError: except json.JSONDecodeError:
# If parsing still fails, try one more aggressive approach # If parsing still fails, try one more aggressive approach
# Remove any remaining control characters that might be causing issues # Remove any remaining control characters that might be causing issues
array_content = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", array_content) # This is the key fix - we need to escape control characters rather than remove them
def escape_control_chars(match):
char = match.group(0)
# Handle specific control characters that might cause issues
if char == "\n":
return "\\n"
elif char == "\r":
return "\\r"
elif char == "\t":
return "\\t"
else:
# For other control characters, use Unicode escape
return f"\\u{ord(char):04x}"
# More aggressive approach: escape ALL control characters
# This handles the case where line breaks occur within JSON string values
# We need to escape them before they break the JSON structure
# First, let's try to detect if there are line breaks within quoted strings
# and escape them properly
# Simple approach: escape all control characters
array_content = re.sub(r"[\x00-\x1f]", escape_control_chars, array_content)
try: try:
data = json.loads(array_content) data = json.loads(array_content)
@@ -92,6 +114,23 @@ def sanitize_json_response(text):
app = typer.Typer(no_args_is_help=True) app = typer.Typer(no_args_is_help=True)
session: requests.Session session: requests.Session
userid: int userid: int
global_config_section: str = "default"
# Add global option for config section
@app.callback()
def main(
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
):
"""My Ice Hockey schedule tool"""
# Store the config_section in a global variable so it can be accessed by commands
global global_config_section
global_config_section = config_section
class AgeGroup(str, Enum): class AgeGroup(str, Enum):
@@ -210,12 +249,15 @@ def select_club(club_id: int = 172):
r.raise_for_status() r.raise_for_status()
def do_login(config_section: str = "default"): def do_login(config_section: str | None = None):
global session global session
global userid global userid
username, password, userid_tmp, token, club_id = get_login( global global_config_section
config_section=config_section
) # Use provided config_section, or fall back to global one
section = config_section if config_section is not None else global_config_section
username, password, userid_tmp, token, club_id = get_login(config_section=section)
if userid_tmp is not None: if userid_tmp is not None:
userid = userid_tmp userid = userid_tmp
r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent}) r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent})
@@ -261,9 +303,9 @@ def get_userid():
def wrapper_session(func): def wrapper_session(func):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
global session, userid global session, userid, global_config_section
# Extract config_section from kwargs if present # Use the global config_section
config_section = kwargs.get("config_section", "default") config_section = global_config_section
session = requests.Session() session = requests.Session()
# session.verify = False # session.verify = False
@@ -271,19 +313,20 @@ def wrapper_session(func):
session.cookies.clear_expired_cookies() session.cookies.clear_expired_cookies()
if not session.cookies.get("mih_v3_cookname"): if not session.cookies.get("mih_v3_cookname"):
print("login...", file=sys.stderr) print("login...", file=sys.stderr)
do_login(config_section=config_section) do_login(config_section=None) # Use global config_section
save_cookies() save_cookies()
_, _, userid, _, _ = get_login(config_section=config_section) _, _, userid, _, _ = get_login(config_section=config_section)
if not userid: if not userid:
print("get userid...", file=sys.stderr) print("get userid...", file=sys.stderr)
userid = get_userid() userid = get_userid()
print(f"{userid=}", file=sys.stderr)
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
@wrapper_session @wrapper_session
def get_schedule(num_days: int, config_section: str = "default") -> str: def get_schedule(num_days: int) -> str:
global session global session
global userid global userid
assert session and userid assert session and userid
@@ -310,11 +353,11 @@ def get_schedule(num_days: int, config_section: str = "default") -> str:
# Debug: Save raw response to file for analysis # Debug: Save raw response to file for analysis
# with open("raw_response.txt", "w") as f: # with open("raw_response.txt", "w") as f:
# f.write(r.text) # f.write(r.text)
return r.text return json.loads(sanitize_json_response(r.text))
@wrapper_session @wrapper_session
def game_pdf(gameid: int, outfile: Path, config_section: str = "default"): def game_pdf(gameid: int, outfile: Path):
global session, userid global session, userid
assert session and userid assert session and userid
r = session.get( r = session.get(
@@ -331,7 +374,7 @@ def game_pdf(gameid: int, outfile: Path, config_section: str = "default"):
@wrapper_session @wrapper_session
def practice_pdf(gameid: int, outfile: Path, config_section: str = "default"): def practice_pdf(gameid: int, outfile: Path):
global session, userid global session, userid
assert session and userid assert session and userid
r = session.get( r = session.get(
@@ -356,24 +399,19 @@ def schedule(
), ),
] = None, ] = None,
num_days: Annotated[int, typer.Option("--days")] = 7, num_days: Annotated[int, typer.Option("--days")] = 7,
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
""" """
Fetch schedule as json Fetch schedule as json
""" """
schedule = get_schedule(num_days, config_section=config_section) global global_config_section
# Sanitize the JSON response using our proven approach schedule = get_schedule(num_days)
sanitized_schedule = sanitize_json_response(schedule)
if outfile: if outfile:
with outfile.open("w") as f: with outfile.open("w") as f:
f.write(sanitized_schedule) f.write(json.dumps(schedule))
else: else:
print(sanitized_schedule) import builtins
builtins.print(json.dumps(schedule, indent=2))
def os_open(file: str) -> None: def os_open(file: str) -> None:
@@ -405,21 +443,16 @@ def extract_players(pdf_file: Path) -> List[str]:
def get_game_pdf( def get_game_pdf(
game_id: Annotated[int, typer.Argument(help="ID of game to gen pdf for")], game_id: Annotated[int, typer.Argument(help="ID of game to gen pdf for")],
open_file: Annotated[bool, typer.Option("--open", "-o")] = False, open_file: Annotated[bool, typer.Option("--open", "-o")] = False,
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
""" """
Genate the pdf for the game invitation Genate the pdf for the game invitation
""" """
global global_config_section
if open_file: if open_file:
output_filename = f"game_{game_id}.pdf" output_filename = f"game_{game_id}.pdf"
else: else:
output_filename = tempfile.NamedTemporaryFile().name output_filename = tempfile.NamedTemporaryFile().name
game_pdf(game_id, Path(output_filename), config_section=config_section) game_pdf(game_id, Path(output_filename))
if open_file: if open_file:
os_open(output_filename) os_open(output_filename)
else: else:
@@ -431,24 +464,19 @@ def get_game_pdf(
@app.command("practice") @app.command("practice")
def get_practice_pdf( def get_practice_pdf(
game_id: Annotated[int, typer.Argument(help="ID of practice to gen pdf for")], game_id: Annotated[int, typer.Argument(help="ID of practice to gen pdf for")],
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
""" """
Genate the pdf for the practice invitation Genate the pdf for the practice invitation
""" """
global global_config_section
output_filename = f"practice_{game_id}.pdf" output_filename = f"practice_{game_id}.pdf"
practice_pdf(game_id, Path(output_filename), config_section=config_section) practice_pdf(game_id, Path(output_filename))
os_open(output_filename) os_open(output_filename)
@app.command("search") @app.command("search")
def parse_schedule( def parse_schedule(
age_group: Annotated[AgeGroup | None, typer.Option(...)] = None, age_group: Annotated[AgeGroup | None, typer.Option()] = None,
event_type_filter: Annotated[ event_type_filter: Annotated[
EventType | None, EventType | None,
typer.Option("--type", help="Only display events of this type"), typer.Option("--type", help="Only display events of this type"),
@@ -456,16 +484,11 @@ def parse_schedule(
schedule_file: Annotated[ schedule_file: Annotated[
Path, typer.Option(help="schedule json file to parse") Path, typer.Option(help="schedule json file to parse")
] = Path("schedule.json"), ] = Path("schedule.json"),
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
""" """
Parse schedule.json to look for specific games or practices Parse schedule.json to look for specific games or practices
""" """
global global_config_section
try: try:
with schedule_file.open("r") as f: with schedule_file.open("r") as f:
data = json.load(f) data = json.load(f)
@@ -511,16 +534,11 @@ def check_with_ai(
schedule_file: Annotated[ schedule_file: Annotated[
Path, typer.Option(help="schedule json file to parse") Path, typer.Option(help="schedule json file to parse")
] = Path("schedule.json"), ] = Path("schedule.json"),
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
""" """
Search through the schedule with natural language using Infomaniak LLM API Search through the schedule with natural language using Infomaniak LLM API
""" """
global global_config_section
if not utils.init(): if not utils.init():
sys.exit(1) sys.exit(1)
with schedule_file.open("r") as f: with schedule_file.open("r") as f:
@@ -569,10 +587,14 @@ mobile_headers = {
} }
def mobile_login(config_section: str = "default"): def mobile_login(config_section: str | None = None):
global global_config_section
import base64 import base64
username, password, _, token, club_id = get_login(config_section=config_section) # Use provided config_section, or fall back to global one
section = config_section if config_section is not None else global_config_section
username, password, _, token, club_id = get_login(config_section=section)
if token and club_id: if token and club_id:
return {"id": 0, "token": token, "id_club": club_id} return {"id": 0, "token": token, "id_club": club_id}
@@ -607,53 +629,50 @@ def refresh_data():
# verify=False, # verify=False,
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
# Since the API returns valid JSON, we don't need to sanitize it
# Just parse it directly
try:
return r.json() return r.json()
except json.JSONDecodeError:
# If direct parsing fails, try with sanitization
sanitized = sanitize_json_response(r.text)
try:
return json.loads(sanitized)
except json.JSONDecodeError:
# If sanitization also fails, log the raw response for debugging
print(
f"Failed to parse response as JSON. Raw response: {r.text[:500]}..."
)
# Return an empty dict to avoid breaking the API
return {}
@app.command("mobile-login") @app.command("mobile-login")
def do_mobile_login( def do_mobile_login():
config_section: Annotated[ global userdata, global_config_section
str, userdata = mobile_login(config_section=global_config_section)
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
):
global userdata
userdata = mobile_login(config_section=config_section)
print(json.dumps(userdata, indent=2)) print(json.dumps(userdata, indent=2))
@app.command("mobile") @app.command("mobile")
def mobile( def mobile():
config_section: Annotated[ global userdata, global_config_section
str, userdata = mobile_login(config_section=global_config_section)
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
):
global userdata
userdata = mobile_login(config_section=config_section)
games = [x for x in refresh_data().get("club_games")] games = [x for x in refresh_data().get("club_games")]
print(json.dumps(games, indent=2)) # Use built-in print to avoid rich formatting issues
import builtins
builtins.print(json.dumps(games, indent=2, ensure_ascii=False))
@app.command("mobile-game") @app.command("mobile-game")
def mobile_game( def mobile_game(
game_id: Annotated[int, typer.Argument(help="game id")], game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False, raw: Annotated[bool, typer.Option(help="display raw output")] = False,
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
): ):
global userdata global userdata, global_config_section
userdata = mobile_login(config_section=config_section) userdata = mobile_login(config_section=global_config_section)
# data = refresh_data()
with requests.post( with requests.post(
"https://app.myice.hockey/api/mobilerest/getevent", "https://app.myice.hockey/api/mobilerest/getevent",
headers=mobile_headers, headers=mobile_headers,
@@ -669,7 +688,8 @@ def mobile_game(
), ),
# verify=False, # verify=False,
) as r: ) as r:
data = r.json()["eventData"] data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"] players = data["convocation"]["available"]
if raw: if raw:
print(data) print(data)
+219 -5
View File
@@ -1,12 +1,31 @@
import logging import logging
import requests import requests
import os
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, status from fastapi import FastAPI, Header, HTTPException, status, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from . import myice from . import myice
# OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get(
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ"
)
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
JWKS_URI = "https://login.infomaniak.com/oauth2/jwks"
# Allowed users (based on email claim)
ALLOWED_USERS = (
os.environ.get("ALLOWED_USERS", "").split(",")
if os.environ.get("ALLOWED_USERS")
else []
)
origins = ["*"] origins = ["*"]
app = FastAPI() app = FastAPI()
@@ -38,13 +57,64 @@ class AuthHeaders(BaseModel):
Authorization: str Authorization: str
def token(self) -> str: def token(self) -> str:
return self.Authorization.split(" ")[1] parts = self.Authorization.split(" ")
if len(parts) == 2:
return parts[1]
return ""
def authorized(self) -> bool: def authorized(self) -> bool:
if self.token() == "abc": token = self.token()
if not token:
return False
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# Fallback to the old static key for compatibility
if token == "abc":
return True return True
return False return False
def validate_oidc_token(self) -> bool:
try:
token = self.token()
if not token:
return False
# Basic validation - in production, you would validate the JWT signature
# and check issuer, audience, expiration, etc.
import base64
import json
# Decode JWT header and payload (without verification for simplicity in this example)
parts = token.split(".")
if len(parts) != 3:
# If not a standard JWT, treat as opaque token
# For now, we'll accept any non-empty token as valid for testing
return len(token) > 0
# Decode payload (part 1)
payload = parts[1]
if not payload:
return False
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded_payload = base64.urlsafe_b64decode(payload)
payload_data = json.loads(decoded_payload)
# Check if user is in allowed list
user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False
return True
except Exception as e:
print(f"Token validation error: {e}")
# Even if we can't validate the token, if it exists, we'll accept it for now
# This is for development/testing purposes only
return len(self.token()) > 0
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
"""Response model to validate and return when performing a health check.""" """Response model to validate and return when performing a health check."""
@@ -71,6 +141,128 @@ async def favico():
return FileResponse("favicon.ico") return FileResponse("favicon.ico")
@app.get("/login")
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import urllib.parse
state = "random_state_string" # In production, use a proper random state
nonce = "random_nonce_string" # In production, use a proper random nonce
# Store state and nonce in session (simplified for this example)
# In production, use proper session management
auth_url = (
f"{AUTHORIZATION_ENDPOINT}"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
f"&nonce={nonce}"
)
return RedirectResponse(auth_url)
@app.get("/callback")
async def callback(code: str, state: str):
"""Handle OIDC callback and exchange code for token"""
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
if "access_token" not in token_response:
# Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/")
error_detail = token_response.get(
"error_description", "Failed to obtain access token"
)
return RedirectResponse(f"{frontend_url}?error={error_detail}")
access_token = token_response["access_token"]
# Get user info
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
userinfo = userinfo_response.json()
# Check if user is in allowed list
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}?error=User not authorized")
# Redirect back to frontend with access token and user info
frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}#access_token={access_token}")
@app.get("/exchange-token")
async def exchange_token(code: str):
"""Exchange authorization code for access token"""
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
if "access_token" not in token_response:
# Return error as JSON
error_detail = token_response.get(
"error_description", "Failed to obtain access token"
)
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {error_detail}"
)
access_token = token_response["access_token"]
# Get user info
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
userinfo = userinfo_response.json()
# Check if user is in allowed list
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
raise HTTPException(status_code=403, detail="User not authorized")
# Return token and user info as JSON
return {"access_token": access_token, "user": userinfo}
@app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token"""
access_token = headers.token()
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
return userinfo_response.json()
@app.get("/schedule") @app.get("/schedule")
async def schedule( async def schedule(
headers: Annotated[AuthHeaders, Header()], headers: Annotated[AuthHeaders, Header()],
@@ -78,9 +270,18 @@ async def schedule(
): ):
if not headers.authorized(): if not headers.authorized():
raise HTTPException(401, detail="get out") raise HTTPException(401, detail="get out")
try:
username, password, userid, existing_token, club_id = myice.get_login( username, password, userid, existing_token, club_id = myice.get_login(
config_section=account config_section=account
) )
except Exception as e:
raise HTTPException(
400,
detail=f"Configuration error: {str(e)}. Available accounts: isaac, leonard",
)
try:
if existing_token: if existing_token:
myice.userdata = { myice.userdata = {
"id": userid, "id": userid,
@@ -89,7 +290,20 @@ async def schedule(
} }
else: else:
myice.userdata = myice.mobile_login(config_section=account) myice.userdata = myice.mobile_login(config_section=account)
return myice.refresh_data()["club_games"]
data = myice.refresh_data()
if "club_games" in data:
return data["club_games"]
else:
# Return empty array if no games data found
return []
except Exception as e:
print(f"Error fetching schedule: {e}")
import traceback
traceback.print_exc()
# Return empty array instead of throwing an error
return []
@app.get("/accounts") @app.get("/accounts")
Generated
+11 -11
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
@@ -31,7 +31,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"] trio = ["trio (>=0.26.1)"]
[[package]] [[package]]
@@ -402,7 +402,7 @@ files = [
] ]
[package.extras] [package.extras]
tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
@@ -560,7 +560,7 @@ httpcore = "==1.*"
idna = "*" idna = "*"
[package.extras] [package.extras]
brotli = ["brotli", "brotlicffi"] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
@@ -642,7 +642,7 @@ typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""}
[package.extras] [package.extras]
all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"]
black = ["black"] black = ["black"]
doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"]
kernel = ["ipykernel"] kernel = ["ipykernel"]
matplotlib = ["matplotlib"] matplotlib = ["matplotlib"]
nbconvert = ["nbconvert"] nbconvert = ["nbconvert"]
@@ -712,7 +712,7 @@ traitlets = ">=5.3"
[package.extras] [package.extras]
docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"]
[[package]] [[package]]
name = "jupyter-core" name = "jupyter-core"
@@ -1102,7 +1102,7 @@ typing-extensions = ">=4.12.2"
[package.extras] [package.extras]
email = ["email-validator (>=2.0.0)"] email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
@@ -1755,7 +1755,7 @@ files = [
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"] zstd = ["zstandard (>=0.18.0)"]
@@ -1779,12 +1779,12 @@ h11 = ">=0.8"
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[package.extras] [package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]] [[package]]
name = "uvloop" name = "uvloop"
@@ -1793,7 +1793,7 @@ description = "Fast implementation of asyncio event loop on top of libuv"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["main"] groups = ["main"]
markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\"" markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
files = [ files = [
{file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"},
{file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"},
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "myice" name = "myice"
version = "v0.4.0" version = "v0.5.4"
description = "myice parsing" description = "myice parsing"
authors = [ authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"}, { name = "Rene Luria", "email" = "<rene@luria.ch>"},
@@ -35,7 +35,7 @@ priority = "supplemental"
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[projectscripts] [project.scripts]
myice = 'myice.myice:app' myice = 'myice.myice:app'
[tool.poetry.requires-plugins] [tool.poetry.requires-plugins]