You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [06b48801] Implement extensible URL foundation (#145) * Implement extensible URL foundation Co-authored-by: tommy * Make query params non-identity by default Co-authored-by: tommy * Fix external href helper test scope Co-authored-by: tommy * Avoid broken external previews for blocked hosts Co-authored-by: tommy * Clarify external empty state copy Co-authored-by: tommy * Add on-demand GitHub external resolver Co-authored-by: tommy * Add fenced JSON bodies for GitHub resolver Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side A — unified diff (full patch): diff --git a/agents.md b/agents.md index 59bd2174f4d2f972a45123fe10d408aa5881ee93..c66f33789441eea193b4354fce4c03b7fffdd639 100644 --- a/agents.md +++ b/agents.md @@ -55,6 +55,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma | Ingests, grants, rooms, identity tokens, agent binds, redactions, etc. | **JSONL** | Appended in `server/src/api/rpc.rs`, `server/src/api/auth.rs` (and related paths) before updating `ReducerState` | | **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). | | **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) | +| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons. Resolver results themselves are durable synthetic `Ingest` events in `events.jsonl`. | | **Reducer projection** | **Derived** | Rebuilt from log on startup; not separately persisted | If you add a new ephemeral map or start persisting something that was RAM-only, **update this table and the code comments** (`server/src/state.rs` is a good anchor). diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index 7cbda3876451687aa7a55547fc06bbe86ac9d260..cd501ba6d4d656eabad03afed4583efdf885695d 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -18,14 +18,15 @@ use crate::{ rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete}, }, canonical_path::canonicalize_tag, + external_resolver::resolve_github_children, + html::vote_compare_post_success_js, html::{ - fragment_new_thread_slot, login_to_post_hint_markup, - parse_html_ui_from_form, room_members_section_markup, thread_feed_html, - thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, - thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, - user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav, + fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, + room_members_section_markup, thread_feed_html, thread_feed_html_for_room, + thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, + thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room, + HtmlUiAction, JsBuilder, ThreadNav, }, - html::vote_compare_post_success_js, reducer::{scope_from_room_wire, ScopeId}, state::AppState, }; @@ -104,26 +105,35 @@ async fn dispatch_ui_action( ) .into_response(); } - match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await { - Ok(RpcResult::PostOk { .. }) => { - post_success_response( - state, - &room, - &thread_tag, - error_target.as_ref(), - form_id.as_ref(), - Some(session.username.as_str()), - ) - .await - .into_response() - } + match rpc_post_with_bearer( + state, + &session.bearer, + room.clone(), + thread_tag.clone(), + text, + ) + .await + { + Ok(RpcResult::PostOk { .. }) => post_success_response( + state, + &room, + &thread_tag, + error_target.as_ref(), + form_id.as_ref(), + Some(session.username.as_str()), + ) + .await + .into_response(), Ok(_) => form_js_error( error_target.as_ref(), "unexpected response", "Post did not return PostOk.", ) .into_response(), - Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Err((msg, hint)) => { + form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::CheckIngest { @@ -150,9 +160,19 @@ async fn dispatch_ui_action( return js_clear_errors(&form_error_target(error_target.as_ref())).into_response(); } match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await { - Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(), - Ok(_) => form_js_error(error_target.as_ref(), "unexpected response", "Check did not return CheckOk.").into_response(), - Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Ok(RpcResult::CheckOk { .. }) => { + js_clear_errors(&form_error_target(error_target.as_ref())).into_response() + } + Ok(_) => form_js_error( + error_target.as_ref(), + "unexpected response", + "Check did not return CheckOk.", + ) + .into_response(), + Err((msg, hint)) => { + form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::VoteComparePost { @@ -167,7 +187,11 @@ async fn dispatch_ui_action( form_action, } => { if form_action != "/ui" { - return (StatusCode::BAD_REQUEST, "invalid vote_compare_post form_action").into_response(); + return ( + StatusCode::BAD_REQUEST, + "invalid vote_compare_post form_action", + ) + .into_response(); } let Some(session) = session else { return js_redirect("/login").into_response(); @@ -195,23 +219,15 @@ async fn dispatch_ui_action( let left_id = match crate::path_types::ItemId::parse(left_item.trim()) { Some(i) => i.normalized_storage(), None => { - return form_js_error( - err_tgt.as_ref(), - "bad item", - "Invalid left item path.", - ) - .into_response(); + return form_js_error(err_tgt.as_ref(), "bad item", "Invalid left item path.") + .into_response(); } }; let right_id = match crate::path_types::ItemId::parse(right_item.trim()) { Some(i) => i.normalized_storage(), None => { - return form_js_error( - err_tgt.as_ref(), - "bad item", - "Invalid right item path.", - ) - .into_response(); + return form_js_error(err_tgt.as_ref(), "bad item", "Invalid right item path.") + .into_response(); } }; let mut rl = ratio_left.trim().parse::().unwrap_or(0).max(0); @@ -231,7 +247,15 @@ async fn dispatch_ui_action( right_id.as_str() ); - match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await { + match rpc_post_with_bearer( + state, + &session.bearer, + room.clone(), + thread_tag.clone(), + text, + ) + .await + { Ok(RpcResult::PostOk { post_id, post_index, @@ -277,7 +301,10 @@ async fn dispatch_ui_action( "Post did not return PostOk.", ) .into_response(), - Err((msg, hint)) => form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(), + Err((msg, hint)) => { + form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or("")) + .into_response() + } } } HtmlUiAction::SetGardenPin { @@ -288,7 +315,11 @@ async fn dispatch_ui_action( form_action, } => { if form_action != "/ui" { - return (StatusCode::BAD_REQUEST, "invalid set_garden_pin form_action").into_response(); + return ( + StatusCode::BAD_REQUEST, + "invalid set_garden_pin form_action", + ) + .into_response(); } let next_path = sanitize_garden_pin_next(&next); use crate::html::{encode_pin_cookie_value, GARDEN_PIN_COOKIE}; @@ -301,7 +332,11 @@ async fn dispatch_ui_action( if room.is_empty() { return (StatusCode::BAD_REQUEST, "missing room").into_response(); } - let Some(raw) = item_storage.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else { + let Some(raw) = item_storage + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + else { return (StatusCode::BAD_REQUEST, "missing item").into_response(); }; let Some(item) = ItemId::parse(&raw) else { @@ -309,16 +344,65 @@ async fn dispatch_ui_action( }; let item = item.normalized_storage(); let val = encode_pin_cookie_value(&room, item.as_str()); - let cookie = format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000"); + let cookie = + format!("{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000"); redirect_with_pin_cookie(&cookie, &next_path) } + HtmlUiAction::ResolveExternal { + room_wire, + item_storage, + mode, + next, + form_action, + } => { + if form_action != "/ui" { + return ( + StatusCode::BAD_REQUEST, + "invalid resolve_external form_action", + ) + .into_response(); + } + let Some(session) = session else { + return js_redirect("/login").into_response(); + }; + let room = room_wire.trim(); + if room.is_empty() { + return ui_js_warn("missing room").into_response(); + } + let reduced = state.reduced.read().await; + if matches!(scope_from_room_wire(room), ScopeId::Room(_)) { + if !user_can_post_room(&reduced, room, &session.username) { + drop(reduced); + return ui_js_warn("forbidden").into_response(); + } + } + drop(reduced); + + let Some(item) = crate::path_types::ItemId::parse(item_storage.trim()) else { + return ui_js_warn("bad item").into_response(); + }; + let target = if mode.trim() == "siblings" { + match item.parent() { + Some(parent) => parent.normalized_storage(), + None => return ui_js_warn("no parent to resolve siblings").into_response(), + } + } else { + item.normalized_storage() + }; + match resolve_github_children(state, room, &target).await { + Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(), + Err(msg) => ui_js_warn(&msg).into_response(), + } + } HtmlUiAction::RedactPost { post_id } => { let Some(session) = session else { return js_redirect("/login").into_response(); }; let h = headers_from_bearer(&session.bearer); match rpc_post_redact(state, &h, post_id).await { - Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(), + Ok(RpcResult::RedactPostOk {}) => { + redact_success_response(state).await.into_response() + } Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(), Err((msg, hint)) => { let detail = hint.as_deref().unwrap_or(""); @@ -326,7 +410,10 @@ async fn dispatch_ui_action( } } } - HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => { + HtmlUiAction::SetRoomMembersExpanded { + room_wire, + expanded, + } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); @@ -365,7 +452,10 @@ async fn dispatch_ui_action( } } } - HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => { + HtmlUiAction::SetNewThreadComposeExpanded { + room_wire, + expanded, + } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); @@ -614,7 +704,9 @@ async fn post_success_response( }; builder }) - .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location)); + .if_current_path_not_matches(&thread_location, |builder| { + builder.redirect(&thread_location) + }); builder.into_response() } @@ -625,4 +717,3 @@ async fn redact_success_response(state: &AppState) -> Response { .morph_selector("#thread-feed", feed_markup) .into_response() } - diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs index 700c3174b0e980d46ebf4aa94e8ffe2f8dedd723..3168956e7dce1cadf50c275904b550bf81500687 100644 --- a/server/src/api/write_actor.rs +++ b/server/src/api/write_actor.rs @@ -54,7 +54,11 @@ fn content_for_room<'a>(reduced: &'a ReducerState, room: &str) -> &'a crate::red } async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str) { - let feed_id = if room_key == "public" { "thread-feed" } else { "room-thread-feed" }; + let feed_id = if room_key == "public" { + "thread-feed" + } else { + "room-thread-feed" + }; let thread_url = if room_key == "public" { format!("/t/{thread_id}") } else if let Some(seg) = room_route_segment(room_key) { @@ -86,7 +90,10 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str "/".to_string() }]; path_prefixes.push(thread_url.clone()); - let _ = state.js_tx.send(crate::state::JsSnippet { code: js, path_prefixes }); + let _ = state.js_tx.send(crate::state::JsSnippet { + code: js, + path_prefixes, + }); } fn compute_scope_rank_changes( @@ -104,7 +111,13 @@ fn compute_scope_rank_changes( for comp in &rankings.component_rankings { let total = comp.ranked.len(); for (i, item) in comp.ranked.iter().enumerate() { - map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total })); + map.insert( + item.item.clone(), + Some(RankPosition { + rank: i + 1, + of: total, + }), + ); } } for item in &rankings.unranked_items { @@ -158,7 +171,11 @@ fn compute_scope_rank_changes( }) } -async fn redeem_invite_grant(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> { +async fn redeem_invite_grant( + state: &AppState, + invite_token: &str, + grantee_username: &str, +) -> Result<(), String> { let now = now_ms(); let ga = { let mut invites = state.invites.write().await; @@ -220,14 +237,14 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let delegate: Option = match delegate_opt { None => None, Some(ref s) if s.trim().is_empty() => None, Some(s) => Some( - parse_agent(&s).map_err(|msg| (format!("invalid delegate format"), Some(msg)))?, + parse_agent(&s) + .map_err(|msg| (format!("invalid delegate format"), Some(msg)))?, ), }; @@ -241,11 +258,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { )); } - let v = validate_ingest_document(&reduced, &text, &scope) - .map_err(|(st, m, h)| { + let v = validate_ingest_document(&reduced, &text, &scope).map_err( + |(st, m, h)| { let _ = st; (m, h) - })?; + }, + )?; if is_private { use crate::events::ThreadCapability; @@ -276,7 +294,10 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { if let Some(ref d) = delegate { match reduced.agent_bindings.get(d) { Some(u) if u != &principal => { - return Err(("delegate already bound to another user".into(), None)); + return Err(( + "delegate already bound to another user".into(), + None, + )); } _ => {} } @@ -427,6 +448,80 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let _ = reply.send(out); } + WriteCmd::SystemIngest { + room, + thread_tag, + text, + principal, + reply, + } => { + let out = async { + let (room_key, thread_id) = normalize_room_and_thread(&room, &thread_tag); + let scope = scope_from_room_wire(&room_key); + let is_private = !matches!(scope, ScopeId::Public); + let mut reduced = state.reduced.write().await; + if is_private && !reduced.rooms.contains(&room_key) { + return Err(( + "unknown room".into(), + Some(format!("room `{}` does not exist", room_key)), + )); + } + + let v = validate_ingest_document(&reduced, &text, &scope).map_err( + |(st, m, h)| { + let _ = st; + (m, h) + }, + )?; + + let new_post_id = uuid::Uuid::new_v4().to_string(); + let ingest_event = Event::Ingest(Ingest { + ts: v.ts, + id: new_post_id.clone(), + raw: v.raw_text.clone(), + principal: principal.clone(), + delegate: None, + room_id: room_key.clone(), + thread_tag: thread_id.clone(), + }); + + state + .event_log + .append(&ingest_event) + .await + .map_err(|e| (format!("{e}"), None))?; + reduced.apply_event(ingest_event); + drop(reduced); + + use crate::canonical_path::canonicalize_tag; + let thread_tag_canon = canonicalize_tag(&thread_id); + broadcast_web_refresh(&state, &room_key, &thread_tag_canon).await; + let post_index = { + let reduced = state.reduced.read().await; + reduced.try_thread_post_index_chronological( + &scope_from_room_wire(&room_key), + &thread_tag_canon, + &new_post_id, + ) + }; + + Ok(RpcResult::PostOk { + events_appended: 1, + post_id: Some(new_post_id), + post_index, + ranking_changes: None, + threads: vec![format!("#{}", thread_id)], + next: slug_types::NextMoves { + pair: "npx slugsocial public garden pair".to_string(), + rank: "npx slugsocial public garden rank".to_string(), + web: ForumThreadUrl::from_room_tag(&room_key, &thread_id), + }, + }) + } + .await; + let _ = reply.send(out); + } + WriteCmd::Redact { post_id, bearer, @@ -434,8 +529,7 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { } => { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let post_id = post_id.trim().to_string(); let Some(ing) = reduced.ingests_by_id.get(&post_id).cloned() else { return Err(("post not found".into(), None)); @@ -450,7 +544,11 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let scope = scope_from_room_wire(&room_key); if matches!(scope, ScopeId::Room(_)) && (!reduced.rooms.contains(&room_key) - || !reduced.user_has_cap(&room_key, &principal, crate::events::ThreadCapability::View)) + || !reduced.user_has_cap( + &room_key, + &principal, + crate::events::ThreadCapability::View, + )) { return Err(("room not found".into(), None)); } @@ -484,14 +582,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { } => { let out = async { let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let slug = slug.trim().to_lowercase(); if slug.is_empty() || slug.len() > 64 { return Err(("slug must be 1-64 characters".into(), None)); } if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - return Err(("slug must be lowercase alphanumeric with hyphens".into(), None)); + return Err(( + "slug must be lowercase alphanumeric with hyphens".into(), + None, + )); } let short_id = loop { let id = gen_short_id(); @@ -539,13 +639,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let _ = reply.send(out); } - WriteCmd::RoomDelete { room, bearer, reply } => { + WriteCmd::RoomDelete { + room, + bearer, + reply, + } => { let out = async { use crate::events::ThreadCapability; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; let room = room.trim().to_string(); if !reduced.rooms.contains(&room) { return Err(("unknown room".into(), None)); @@ -589,15 +692,15 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { use crate::identity::parse_username; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) { return Err(("requires Manage capability".into(), None)); } if capabilities.is_empty() { return Err(("capabilities must not be empty".into(), None)); } - let target = parse_username(&username).map_err(|msg| ("invalid username".into(), Some(msg)))?; + let target = parse_username(&username) + .map_err(|msg| ("invalid username".into(), Some(msg)))?; if !reduced.users_by_provider.values().any(|u| u == &target) { return Err((format!("user @{target} not found"), None)); } @@ -637,12 +740,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { use crate::identity::parse_username; let mut reduced = state.reduced.write().await; - let principal = - verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; + let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?; if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) { return Err(("requires Manage capability".into(), None)); } - let target = parse_username(&username).map_err(|msg| ("invalid username".into(), Some(msg)))?; + let target = parse_username(&username) + .map_err(|msg| ("invalid username".into(), Some(msg)))?; if !reduced.users_by_provider.values().any(|u| u == &target) { return Err((format!("user @{target} not found"), None)); } diff --git a/server/src/dsl.rs b/server/src/dsl.rs index def8b497c71567016a522648b9630d7038163e41..4203c2f59dd8825d7a91513c4023f3ccd37f1efc 100644 --- a/server/src/dsl.rs +++ b/server/src/dsl.rs @@ -450,8 +450,8 @@ fn parse_block_prefixed_statement( } fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result { - let (item1, j) = - parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?; + let (item1, j) = parse_item_name_at(stripped, 0) + .ok_or_else(|| DslError::Parse("invalid item name".to_string()))?; let i = skip_ws(stripped, j); if i >= stripped.len() { @@ -632,6 +632,19 @@ mod tests { ); } + #[test] + fn parse_item_with_fenced_json_body_preserves_braces() { + let input = "~/item/in/url ```json\n{\"test\": true}\n```"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Item { + title: "~/item/in/url".to_string(), + body: Some("```json\n{\"test\": true}\n```".to_string()), + }] + ); + } + #[test] fn parse_vote_ratio_and_symbols() { let d1 = parse_full("{because}\n~/a 3:1 ~/b").unwrap(); diff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs index 13f06b922a685f110d269f7397da807cf8600fe1..8481f1fbff1c4265da182a225f539f97d6829002 100644 --- a/server/src/external_resolver.rs +++ b/server/src/external_resolver.rs @@ -1,6 +1,26 @@ use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::oneshot; -use crate::path_types::ItemId; +use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd}; + +const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver"; +const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000; + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedChild { + pub url: String, + pub title: String, + pub body: Option, +} #[async_trait] pub trait ExternalResolver: Send + Sync { @@ -14,7 +34,290 @@ pub trait ExternalResolver: Send + Sync { async fn fetch_body(&self, item: &ItemId) -> Result; } -/// Placeholder until domain-specific resolvers exist. +#[derive(Clone)] +pub struct GitHubResolver { + client: reqwest::Client, + api_base_url: String, + token: Option, +} + +impl GitHubResolver { + pub fn from_env() -> Self { + let api_base_url = std::env::var("SLUG_GITHUB_API_BASE_URL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://api.github.com".to_string()); + let token = std::env::var("SLUG_GITHUB_TOKEN") + .ok() + .filter(|s| !s.trim().is_empty()); + Self { + client: reqwest::Client::new(), + api_base_url: api_base_url.trim_end_matches('/').to_string(), + token, + } + } + + pub fn can_resolve_children(&self, item: &ItemId) -> bool { + github_segments(item).is_some() + } + + pub async fn list_children(&self, item: &ItemId) -> Result, String> { + let segments = github_segments(item).ok_or_else(|| "not a GitHub URL".to_string())?; + match segments.as_slice() { + [] => Ok(vec![]), + [owner] => self.list_repos(owner).await, + [owner, repo] => Ok(github_repo_sections(owner, repo)), + [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await, + [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await, + _ => Ok(vec![]), + } + } + + async fn get_json(&self, path: &str) -> Result { + let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/')); + let mut req = self + .client + .get(url) + .header(reqwest::header::USER_AGENT, "slugsocial-github-resolver"); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + let resp = req + .send() + .await + .map_err(|e| format!("GitHub request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + return Err(format!("GitHub request returned {status}")); + } + resp.json::() + .await + .map_err(|e| format!("GitHub response JSON failed: {e}")) + } + + async fn list_repos(&self, owner: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/users/{owner}/repos?per_page=100&sort=updated&type=owner" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub repos response was not an array".to_string())?; + let mut out = Vec::new(); + for repo in arr { + let name = repo + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + let full_name = repo + .get("full_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| format!("{owner}/{name}").to_ascii_lowercase()); + out.push(ResolvedChild { + url: format!("https://github.com/{full_name}"), + title: full_name.clone(), + body: Some(github_json_body(repo)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/repos/{owner}/{repo}/issues?state=open&per_page=100" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub issues response was not an array".to_string())?; + let mut out = Vec::new(); + for issue in arr { + if issue.get("pull_request").is_some() { + continue; + } + let Some(number) = issue.get("number").and_then(|v| v.as_i64()) else { + continue; + }; + let title = issue + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled issue"); + out.push(ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/issues/{number}"), + title: format!("#{number} {title}"), + body: Some(github_json_body(issue)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> { + let value = self + .get_json(&format!( + "/repos/{owner}/{repo}/pulls?state=open&per_page=100" + )) + .await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub pulls response was not an array".to_string())?; + let mut out = Vec::new(); + for pull in arr { + let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else { + continue; + }; + let title = pull + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled pull request"); + out.push(ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/pulls/{number}"), + title: format!("#{number} {title}"), + body: Some(github_json_body(pull)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } +} + +fn github_segments(item: &ItemId) -> Option> { + let url = url::Url::parse(item.as_str()).ok()?; + if url.host_str()?.eq_ignore_ascii_case("github.com") { + Some( + url.path_segments() + .map(|segments| { + segments + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect::>() + }) + .unwrap_or_default(), + ) + } else { + None + } +} + +fn github_repo_sections(owner: &str, repo: &str) -> Vec { + [ + ("issues", "GitHub issues for this repository."), + ("pulls", "GitHub pull requests for this repository."), + ("commits", "GitHub commits for this repository."), + ("releases", "GitHub releases for this repository."), + ] + .into_iter() + .map(|(section, body)| ResolvedChild { + url: format!("https://github.com/{owner}/{repo}/{section}"), + title: section.to_string(), + body: Some(body.to_string()), + }) + .collect() +} + +fn resolver_thread_tag(item: &ItemId) -> String { + let tail = item + .display_path() + .trim_start_matches("-/") + .replace('/', ":") + .replace('?', ":"); + format!("import:{tail}") +} + +fn sanitize_body(s: &str) -> String { + s.replace('{', "(") + .replace('}', ")") + .replace("```", "` ` `") + .chars() + .take(4_000) + .collect() +} + +fn github_json_body(value: &Value) -> String { + let json = serde_json::to_string_pretty(value) + .unwrap_or_else(|_| value.to_string()) + .replace("```", "` ` `"); + format!("```json\n{json}\n```") +} + +fn children_to_dsl(children: &[ResolvedChild]) -> String { + let mut out = String::new(); + for child in children { + let body = child + .body + .as_deref() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(child.title.as_str()); + if body.trim_start().starts_with("```") { + out.push_str(&format!("{} {}\n\n", child.url, body.trim())); + } else { + out.push_str(&format!( + "{} {{\n{}\n}}\n\n", + child.url, + sanitize_body(body) + )); + } + } + out +} + +pub async fn resolve_github_children( + state: &AppState, + room: &str, + item: &ItemId, +) -> Result { + if !state.github_resolver.can_resolve_children(item) { + return Err("no GitHub resolver for this item".to_string()); + } + + let key = format!("github:{}:{}", room.trim(), item.as_str()); + let now = now_ms(); + { + let mut runs = state.resolver_runs.write().await; + if let Some(last) = runs.get(&key) { + let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last); + if remaining > 0 { + return Err(format!( + "GitHub resolver cooldown: try again in {}s", + (remaining + 999) / 1000 + )); + } + } + runs.insert(key, now); + } + + let children = state.github_resolver.list_children(item).await?; + if children.is_empty() { + return Ok(0); + } + let text = children_to_dsl(&children); + let thread_tag = resolver_thread_tag(item); + let (tx, rx) = oneshot::channel(); + state + .write_tx + .send(WriteCmd::SystemIngest { + room: room.to_string(), + thread_tag, + text, + principal: GITHUB_SYSTEM_PRINCIPAL.to_string(), + reply: tx, + }) + .await + .map_err(|_| "writer unavailable".to_string())?; + rx.await + .map_err(|_| "writer dropped".to_string())? + .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?; + Ok(children.len()) +} + +/// Placeholder until other domain-specific resolvers exist. pub struct DefaultExternalResolver; #[async_trait] @@ -31,3 +334,51 @@ impl ExternalResolver for DefaultExternalResolver { Err("external fetch not implemented".to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_segments_parse_normalized_url() { + let item = ItemId::parse("https://github.com/Sortersocial/Slug/issues").unwrap(); + assert_eq!( + github_segments(&item), + Some(vec![ + "sortersocial".to_string(), + "slug".to_string(), + "issues".to_string() + ]) + ); + } + + #[test] + fn repo_sections_are_direct_children() { + let sections = github_repo_sections("sortersocial", "slug"); + let urls: Vec = sections.into_iter().map(|c| c.url).collect(); + assert!(urls.contains(&"https://github.com/sortersocial/slug/issues".to_string())); + assert!(urls.contains(&"https://github.com/sortersocial/slug/pulls".to_string())); + } + + #[test] + fn children_to_dsl_contains_item_bodies() { + let dsl = children_to_dsl(&[ResolvedChild { + url: "https://github.com/o/r/issues/1".into(), + title: "#1 title".into(), + body: Some("body with {braces}".into()), + }]); + assert!(dsl.contains("https://github.com/o/r/issues/1")); + assert!(dsl.contains("body with (braces)")); + } + + #[test] + fn children_to_dsl_preserves_fenced_json_bodies() { + let dsl = children_to_dsl(&[ResolvedChild { + url: "https://github.com/o/r/issues/1".into(), + title: "#1 title".into(), + body: Some("```json\n{\"test\": true}\n```".into()), + }]); + assert!(dsl.contains("https://github.com/o/r/issues/1 ```json")); + assert!(dsl.contains("{\"test\": true}")); + } +} diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 4c03640334a524babe39c02289989739b1ad0ae9..b28271e0316738eae245855b24aeb76486860554 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -9,18 +9,21 @@ use serde::Deserialize; use serde_json::json; use std::collections::HashSet; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE, Engine as _}; use crate::{ api::optional_principal, canonical_path::{canonicalize_item, canonicalize_tag}, - middleware::canonical_view_url, events::ThreadCapability, form_template::template_json_compact, - html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room}, + html::{ui_action::UI_RPC_FIELD, user_can_post_room, HtmlUiAction, JsBuilder}, + middleware::canonical_view_url, path_types::ItemId, reducer::{ContentState, ReducerState, ScopeId}, - scope_rank::{ChildrenRankings, build_children_rankings}, + scope_rank::{ + build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive, + ChildrenRankings, + }, state::AppState, timeago, }; @@ -128,7 +131,11 @@ fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 { let l = ratio_left.max(0) as f64; let r = ratio_right.max(0) as f64; let sum = l + r; - if sum <= 0.0 { 0.5 } else { l / sum } + if sum <= 0.0 { + 0.5 + } else { + l / sum + } } /// Stronger preference for **`page_left` first**; ties **newer first**. @@ -864,6 +871,7 @@ struct ItemPageViewModel { /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply. item_has_parent: bool, child_rankings: ChildrenRankings, + child_depth: usize, rank_history: Vec, /// Forum threads that mention or vote on this item. threads: Vec, @@ -1018,13 +1026,20 @@ fn build_item_page_view_model( reduced: &crate::reducer::ReducerState, scope: &ScopeId, item: &str, + child_depth: usize, ) -> ItemPageViewModel { let content = content_for_garden_view(reduced, scope); let item_key = ItemId::parse(item) .unwrap_or_else(|| ItemId::parse("~/").unwrap()) .normalized_storage(); let item_has_parent = item_key.parent().is_some(); - let child_rankings = build_children_rankings(content, &item_key); + let child_depth = child_depth.clamp(1, 5); + let child_rankings = if child_depth > 1 { + let items = resolve_scope_recursive(content, &[item_key.as_str().to_string()], child_depth); + build_rankings_for_item_set(content, &items) + } else { + build_children_rankings(content, &item_key) + }; let sibling_nav = build_sibling_nav(reduced, scope, &item_key); let rank_history = build_rank_history(reduced, scope, item_key.as_str()); @@ -1046,11 +1061,111 @@ fn build_item_page_view_model( sibling_nav, item_has_parent, child_rankings, + child_depth, rank_history, threads, } } +fn child_depth_from_uri(uri: &Uri) -> usize { + uri.query() + .into_iter() + .flat_map(|q| q.split('&')) + .filter_map(|pair| pair.split_once('=')) + .find_map(|(k, v)| { + if k == "depth" { + v.parse::().ok() + } else { + None + } + }) + .unwrap_or(1) + .clamp(1, 5) +} + +fn external_source_href(item: &str) -> String { + let Ok(mut url) = url::Url::parse(item) else { + return item.to_string(); + }; + let is_youtube = url + .host_str() + .map(|h| h.eq_ignore_ascii_case("www.youtube.com")) + .unwrap_or(false); + if is_youtube { + let segments: Vec = url + .path_segments() + .map(|s| s.map(|seg| seg.to_string()).collect()) + .unwrap_or_default(); + if segments.len() == 3 && segments[0] == "watch" && segments[1] == "v" { + let id = segments[2].clone(); + url.set_path("/watch"); + url.set_query(None); + url.query_pairs_mut().append_pair("v", &id); + return url.to_string(); + } + } + item.to_string() +} + +fn external_frame_allowed(item: &str) -> bool { + let Ok(url) = url::Url::parse(item) else { + return false; + }; + let host = url.host_str().unwrap_or_default(); + !matches!(host, "github.com" | "www.github.com") +} + +fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option { + let item_id = ItemId::parse(item)?.normalized_storage(); + let url = url::Url::parse(item_id.as_str()).ok()?; + if !url + .host_str() + .map(|h| h.eq_ignore_ascii_case("github.com")) + .unwrap_or(false) + { + return None; + } + let children_rpc = template_json_compact(&HtmlUiAction::ResolveExternal { + room_wire: nav.room_wire.clone(), + item_storage: item_id.as_str().to_string(), + mode: "children".to_string(), + next: next.to_string(), + form_action: "/ui".to_string(), + }) + .ok()?; + let siblings_rpc = item_id.parent().and_then(|_| { + template_json_compact(&HtmlUiAction::ResolveExternal { + room_wire: nav.room_wire.clone(), + item_storage: item_id.as_str().to_string(), + mode: "siblings".to_string(), + next: next.to_string(), + form_action: "/ui".to_string(), + }) + .ok() + }); + + Some(html! { + section id="external-resolver-panel" class="ont-tab-panel ont-external-resolver" { + h3 { "GitHub resolver" } + p class="muted" { + "Import GitHub neighbors on demand. Results are saved as system ingests." + } + div class="resolver-actions" { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(children_rpc); + button type="submit" data-testid="github-resolve-children" { "Load children from GitHub" } + } + @if let Some(rpc) = siblings_rpc { + form method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc); + button type="submit" data-testid="github-resolve-siblings" { "Load siblings from GitHub" } + } + } + } + } + }) +} + async fn render_scope_view( state: AppState, browse: GardenBrowsePath, @@ -1061,11 +1176,14 @@ async fn render_scope_view( let scope = nav.scope(); let pin_ref = pinned_item_from_jar(&jar); let reduced = state.reduced.read().await; - let model = build_item_page_view_model(&reduced, &scope, browse.item()); + let child_depth = child_depth_from_uri(&uri); + let model = build_item_page_view_model(&reduced, &scope, browse.item(), child_depth); let scope_content = content_for_garden_view(&reduced, &scope); let thread_href = |tag: &str| nav.thread_url(tag); let external_empty_body = browse.is_external() && model.body.is_none(); let cli_path_arg = item_display_path(&model.item); + let external_href = external_source_href(&model.item); + let external_can_embed = external_frame_allowed(&model.item); let (garden_room, garden_prefix) = garden_layout_meta(&nav); let next_for_pin = uri .path_and_query() @@ -1104,7 +1222,24 @@ async fn render_scope_view( div class="ont-item-content ont-external-empty" { p { "This is an external scope." } p class="muted" { - button type="button" disabled { "Kick off an Agent Run to import and rank items" } + "This scope has no imported body yet. Open the source page directly." + } + p { + a href=(external_href.as_str()) target="_blank" rel="noopener noreferrer" { + "Open external URL" + } + } + @if external_can_embed { + p class="muted" { "Best-effort embedded preview:" } + iframe + class="ont-external-frame" + src=(external_href.as_str()) + sandbox="" + loading="lazy" + referrerpolicy="no-referrer" + style="width:100%;height:360px;border:1px solid currentColor;" {} + } @else { + p class="muted" { "Embedded preview is unavailable for this host." } } } } @else { @@ -1112,6 +1247,10 @@ async fn render_scope_view( } } + @if let Some(markup) = github_resolver_controls(&model.item, &nav, &next_for_pin) { + (markup) + } + @if !model.rank_history.is_empty() { details class="ont-rank-history" { summary { @@ -1182,7 +1321,13 @@ async fn render_scope_view( } section class="ont-tab-panel ont-tab-panel-children" { - h3 { "ranked child groups" } + h3 { + "ranked child groups" + @if model.child_depth > 1 { + " " + span class="muted" { (format!("(depth {})", model.child_depth)) } + } + } @if model.child_rankings.component_rankings.is_empty() { p class="muted" { "no voted pairs yet in this scope" } } @else { @@ -1415,7 +1560,7 @@ async fn vote_compare_inner( #[cfg(test)] mod tests { - use super::build_item_page_view_model; + use super::{build_item_page_view_model, external_source_href}; use crate::{ events::{Event, Ingest}, reducer::{ReducerState, ScopeId}, @@ -1516,7 +1661,7 @@ mod tests { ~/topic/b {beta}\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); assert_eq!(model.body.as_deref(), Some("alpha")); assert!(model.sibling_nav.is_some()); assert!(model.child_rankings.component_rankings.is_empty()); @@ -1536,7 +1681,7 @@ mod tests { {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); let nav = model.sibling_nav.expect("expected sibling nav"); assert_eq!(nav.groups.len(), 2); assert_eq!(nav.groups[0].links.len(), 2); @@ -1558,7 +1703,7 @@ mod tests { {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); let nav = model.sibling_nav.expect("expected sibling nav"); assert_eq!(nav.groups.len(), 3); assert_eq!(nav.groups[0].links.len(), 2); @@ -1582,7 +1727,7 @@ mod tests { ~/topic/kid1/leaf {leaf}\n", ); - let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic"); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 1); assert_eq!(model.child_rankings.component_rankings.len(), 1); assert_eq!(model.child_rankings.component_rankings[0].pairs, 1); let names: Vec<&str> = model.child_rankings.component_rankings[0] @@ -1625,6 +1770,7 @@ mod tests { &reduced, &ScopeId::Room("9ab12cd/my-room".to_string()), root.as_str(), + 1, ); assert!(!model.item_has_parent); assert_eq!(model.child_rankings.unranked_items.len(), 2); @@ -1655,6 +1801,7 @@ mod tests { &reduced, &ScopeId::Room("9ab12cd/my-room".to_string()), root.as_str(), + 1, ); assert_eq!(model.child_rankings.component_rankings.len(), 1); assert_eq!(model.child_rankings.component_rankings[0].pairs, 1); @@ -1680,11 +1827,56 @@ mod tests { "@00000000-0000-0000-0000-000000000000:test:local/test\n~/x {x}\n", ); let model = - build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/"); + build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/", 1); assert_eq!(model.child_rankings.unranked_items.len(), 1); assert_eq!( model.child_rankings.unranked_items[0].as_str(), "https://slug.social/~/x" ); } + + #[test] + fn item_page_model_depth_includes_descendants() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/a/leaf {leaf}\n\ + ~/topic/b {beta}\n", + ); + + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 2); + let items: std::collections::HashSet<&str> = model + .child_rankings + .unranked_items + .iter() + .map(|u| u.as_str()) + .collect(); + assert!(items.contains("https://slug.social/~/topic/a")); + assert!(items.contains("https://slug.social/~/topic/a/leaf")); + assert!(items.contains("https://slug.social/~/topic/b")); + } + + #[test] + fn external_source_href_maps_youtube_path_identity_back_to_watch_url() { + assert_eq!( + external_source_href("https://www.youtube.com/watch/v/dQw4w9WgXcQ"), + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ); + assert_eq!( + external_source_href("https://github.com/sortersocial/slug"), + "https://github.com/sortersocial/slug" + ); + } + + #[test] + fn external_frame_allowed_skips_known_blocked_hosts() { + assert!(!super::external_frame_allowed( + "https://github.com/sortersocial/slug" + )); + assert!(super::external_frame_allowed("https://example.com/path")); + } } diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs index 874fe729865c8b6179c94745221d8b79d625f642..8ab6f2924c3c66f826f38c487c48d6140eee4671 100644 --- a/server/src/html/ui_action.rs +++ b/server/src/html/ui_action.rs @@ -66,10 +66,17 @@ pub enum HtmlUiAction { #[serde(default = "default_ui_form_action")] form_action: String, }, - /// Author redacts own post via `POST /ui`. - RedactPost { - post_id: String, + /// Resolve children or siblings for an external garden item. + ResolveExternal { + room_wire: String, + item_storage: String, + mode: String, + next: String, + #[serde(default = "default_ui_form_action")] + form_action: String, }, + /// Author redacts own post via `POST /ui`. + RedactPost { post_id: String }, /// Morph `#room-members-section` — members list open or collapsed (server-rendered). SetRoomMembersExpanded { room_wire: String, @@ -77,9 +84,7 @@ pub enum HtmlUiAction { expanded: bool, }, /// Delete the private room (Manage only); redirects to `/` on success. - DeleteRoom { - room: String, - }, + DeleteRoom { room: String }, /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). SetNewThreadComposeExpanded { room_wire: String, @@ -117,13 +122,14 @@ pub enum HtmlUiParseError { } /// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize. -pub fn parse_html_ui_from_form(form: &HashMap) -> Result { - let template = form - .get(UI_RPC_FIELD) - .ok_or(HtmlUiParseError::MissingRpc)?; +pub fn parse_html_ui_from_form( + form: &HashMap, +) -> Result { + let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?; let mut hole_map = form.clone(); hole_map.remove(UI_RPC_FIELD); - let v: Value = fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; + let v: Value = + fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; serde_json::from_value(v).map_err(HtmlUiParseError::Action) } diff --git a/server/src/lib.rs b/server/src/lib.rs index b902094653cf9319efd14e67b91436e269362441..84e94bbec144eae77de68482385941cd2c5845eb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,11 +3,11 @@ pub mod paths; pub mod api; pub mod canonical_path; pub mod dsl; +pub mod event_log; +pub mod events; pub mod external_resolver; pub mod form_template; pub mod html; -pub mod event_log; -pub mod events; pub mod identity; pub mod middleware; pub mod path_types; @@ -50,16 +50,18 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { js_tx, write_tx, views, + resolver_runs: Arc::new(RwLock::new(HashMap::new())), + github_resolver: Arc::new(crate::external_resolver::GitHubResolver::from_env()), }; - tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone())); + tokio::spawn(crate::api::write_actor::writer_actor( + write_rx, + state.clone(), + )); state } /// Spawn the serialized writer. Used by integration tests after seeding reducer state in-process. -pub fn spawn_writer_actor_for_test( - state: AppState, - rx: tokio::sync::mpsc::Receiver, -) { +pub fn spawn_writer_actor_for_test(state: AppState, rx: tokio::sync::mpsc::Receiver) { tokio::spawn(crate::api::write_actor::writer_actor(rx, state)); } @@ -89,10 +91,7 @@ pub fn create_app(state: AppState) -> Router { .route("/-", get(crate::html::external_garden_index)) .route("/-/*path", get(crate::html::external_ontology_path)) .route("/r/:room_key/~", get(crate::html::room_garden_index)) - .route( - "/r/:room_key/~/*path", - get(crate::html::room_ontology_path), - ) + .route("/r/:room_key/~/*path", get(crate::html::room_ontology_path)) .route( "/r/:room_key/-", get(crate::html::room_external_garden_index), diff --git a/server/src/state.rs b/server/src/state.rs index 13831e868031edc19fda0f347376e0b17e8b99b7..8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; -use crate::{event_log::EventLog, events::ThreadCapability, reducer::ReducerState, write_cmd::WriteCmd}; +use crate::{ + event_log::EventLog, events::ThreadCapability, external_resolver::GitHubResolver, + reducer::ReducerState, write_cmd::WriteCmd, +}; /// Ephemeral invite link (24h TTL, in-memory only; not written to the event log). #[derive(Debug, Clone)] @@ -24,7 +27,7 @@ pub struct PendingSession { pub provider_id: Option, /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`]. pub redeem_invite: Option, - pub complete: Option<(String /*username*/, String /*bearer*/ )>, + pub complete: Option<(String /*username*/, String /*bearer*/)>, } /// An SSE event broadcast to all live stream subscribers when an ingest occurs. @@ -68,6 +71,9 @@ pub struct AppState { /// All durable writes and reducer mutations are serialized through this channel. pub write_tx: mpsc::Sender, pub views: crate::views::ViewStore, + /// Ephemeral resolver cooldowns keyed by `room + item`: not persisted; durable results are JSONL ingests. + pub resolver_runs: Arc>>, + pub github_resolver: Arc, } impl AppState { @@ -94,7 +100,8 @@ impl AppState { js_tx, write_tx, views, + resolver_runs: Arc::new(RwLock::new(HashMap::new())), + github_resolver: Arc::new(GitHubResolver::from_env()), } } } - diff --git a/server/src/write_cmd.rs b/server/src/write_cmd.rs index 494aaeadf7bb919768788e101c3c1ffa12da8503..094a1b80b64caa327af523dc36c618ea04551cdc 100644 --- a/server/src/write_cmd.rs +++ b/server/src/write_cmd.rs @@ -14,6 +14,13 @@ pub enum WriteCmd { bearer: String, reply: oneshot::Sender, }, + SystemIngest { + room: String, + thread_tag: String, + text: String, + principal: String, + reply: oneshot::Sender, + }, Redact { post_id: String, bearer: String, diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj new file mode 100644 index 0000000000000000000000000000000000000000..1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775 --- /dev/null +++ b/test/browser_github_resolver.clj @@ -0,0 +1,133 @@ +(ns test.browser-github-resolver + "Playwright coverage for on-demand GitHub external resolver imports." + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [com.blockether.spel.core :as core] + [com.blockether.spel.locator :as locator] + [com.blockether.spel.page :as page] + [org.httpkit.server :as http] + [test.common :as common] + [test.oauth :as oauth])) + +(defn- wait-for-text [pg selector expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (locator/text-content (page/locator pg selector))] + (if (and (string? text) (str/includes? text expected)) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + +(defn- start-mock-github [port] + (let [!paths (atom []) + handler (fn [req] + (swap! !paths conj (:uri req)) + (case (:uri req) + "/users/octo/repos" + {:status 200 + :headers {"Content-Type" "application/json"} + :body (json/generate-string + [{:name "hello" + :full_name "octo/hello" + :description "Hello repo"} + {:name "other" + :full_name "octo/other" + :description "Other repo"}])} + + "/repos/octo/hello/issues" + {:status 200 + :headers {"Content-Type" "application/json"} + :body (json/generate-string + [{:number 42 + :title "Seeded issue" + :body "Already pasted"} + {:number 43 + :title "Imported sibling" + :body "Loaded from mock GitHub"} + {:number 99 + :title "PR should be filtered" + :pull_request {}}])} + + {:status 404 :body "not found"})) + stop-fn (http/run-server handler {:port port})] + {:stop-fn stop-fn :paths !paths})) + +(defn github-resolver-flow! [] + (println "\n━━━ browser GitHub resolver: on-demand children and siblings ━━━\n") + + (common/letlocals + (bind build (common/run-cargo-build-release! ["slugsocial-server"])) + (is (zero? (:exit build)) "cargo build succeeds") + (bind server-bin "target/release/slugsocial-server") + + (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-browser-github-resolver-"}))) + (bind slug-port (common/pick-port)) + (bind google-port (common/pick-port)) + (bind github-port (common/pick-port)) + (bind base-url (str "http://127.0.0.1:" slug-port)) + (bind google-url (str "http://127.0.0.1:" google-port)) + (bind github-url (str "http://127.0.0.1:" github-port)) + + (bind !server (atom nil)) + (bind !google (atom nil)) + (bind !github (atom nil)) + (bind server-env (assoc (common/slug-server-env tmp-dir base-url google-url slug-port) + "SLUG_GITHUB_API_BASE_URL" github-url)) + (try + (reset! !google (oauth/start-mock-google google-port + :google-users ["google-user-alice"])) + (reset! !github (start-mock-github github-port)) + (reset! !server (common/start-server server-bin server-env)) + (is (common/wait-for-server base-url 10000) "server responds to /healthz") + + (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice") + seed-resp (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"Post" {"room" "public" + "thread_tag" "github-resolver-seed" + "text" "https://github.com/octo/hello/issues/42 {Seed pasted issue}" + "return_rank_diff" false}}] + :headers {"Authorization" (str "Bearer " alice-token)}) + seed-json (json/parse-string (:body seed-resp) false)] + (is (true? (get-in seed-json ["results" 0 "ok"])) "seed pasted GitHub issue") + + (core/with-playwright [pw] + (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] + (core/with-context [ctx (core/new-context browser)] + (core/with-page [pg (core/new-page-from-context ctx)] + (page/navigate pg (str base-url "/login")) + (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") + + (page/navigate pg (str base-url "/-/github.com/octo")) + (is (wait-for-text pg "#external-resolver-panel" "GitHub resolver" 15000) + "user page shows resolver panel") + (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) + (is (wait-for-text pg "body" "-/github.com/octo/other" 15000) + "user resolver imports repos") + + (page/navigate pg (str base-url "/-/github.com/octo/hello")) + (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) + (is (wait-for-text pg "body" "-/github.com/octo/hello/pulls" 15000) + "repo resolver imports structural children") + + (page/navigate pg (str base-url "/-/github.com/octo/hello/issues/42")) + (locator/click (page/locator pg "[data-testid=\"github-resolve-siblings\"]")) + (page/navigate pg (str base-url "/-/github.com/octo/hello/issues")) + (is (wait-for-text pg "body" "-/github.com/octo/hello/issues/43" 15000) + "issue sibling resolver imports issue siblings"))))) + + (is (some #{"/users/octo/repos"} @(:paths @!github)) + "mock GitHub saw user repos request") + (is (some #{"/repos/octo/hello/issues"} @(:paths @!github)) + "mock GitHub saw repo issues request")) + + (finally + (when-some [s @!server] (common/kill-server s)) + (when-some [g @!google] ((:stop-fn g))) + (when-some [g @!github] ((:stop-fn g))))))) + +(deftest browser-github-resolver-test + (github-resolver-flow!)) diff --git a/test/browser_ui_morph.clj b/test/browser_ui_morph.clj index 1dad7eb142728e14f0fe1a77747c6f443192cd03..7740a44546de12731833f048a19c35af8c6f2d1e 100644 --- a/test/browser_ui_morph.clj +++ b/test/browser_ui_morph.clj @@ -73,8 +73,8 @@ (is (wait-for-text pg "nav.breadcrumb" "repo" 5000) "breadcrumb includes path segment repo") (is (wait-for-text pg ".ont-external-empty" "This is an external scope." 10000) "external item shows empty-state copy") - (is (wait-for-text pg ".ont-external-empty" "Kick off an Agent Run to import and rank items" 5000) - "external empty state shows disabled agent-run button label") + (is (wait-for-text pg ".ont-external-empty" "Open external URL" 5000) + "external empty state links to source URL") (page/navigate pg (str base-url "/t/" thread-tag)) (is (wait-for-text pg "#thread-feed-region" "[show full post]" 15000) "truncated card shows expand link") diff --git a/tests.edn b/tests.edn index 103d38a1769a8f63754f08fb1e42a24c80ee37dc..5e2b0433a66c51806d08ef38d691259cfccd1fe0 100644 --- a/tests.edn +++ b/tests.edn @@ -20,7 +20,8 @@ "^test\\.browser-public-garden$" "^test\\.browser-redact-thread-index$" "^test\\.browser-garden-pin$" - "^test\\.browser-vote-compare$"] + "^test\\.browser-vote-compare$" + "^test\\.browser-github-resolver$"] :kaocha.filter/skip-meta [:skip] :parallel? false}] :plugins [:kaocha.plugin/junit-xml] diff --git a/types/src/paths.rs b/types/src/paths.rs index 8669f6872b9fa71d72a3f5c346945427bc00ac5a..791a44a0850b54b2e6baf16b47726bde507194c6 100644 --- a/types/src/paths.rs +++ b/types/src/paths.rs @@ -361,13 +361,14 @@ mod tests { SLUG_TILDE_ONTOLOGY_ROOT.to_string() ); assert_eq!( - ItemId::parse("https://slug.social/~/") - .unwrap() - .as_str(), + ItemId::parse("https://slug.social/~/").unwrap().as_str(), SLUG_TILDE_ONTOLOGY_ROOT ); let legacy = ItemId::opaque("https://slug.social/~/".to_string()); - assert_eq!(legacy.normalized_storage().as_str(), SLUG_TILDE_ONTOLOGY_ROOT); + assert_eq!( + legacy.normalized_storage().as_str(), + SLUG_TILDE_ONTOLOGY_ROOT + ); } #[test] @@ -423,7 +424,8 @@ mod tests { #[test] fn garden_private_room_prefixes_ontology() { assert_eq!( - GardenItemUrl::from_storage_str("https://slug.social/~/topic/x", "9ab12cd/my-room").as_str(), + GardenItemUrl::from_storage_str("https://slug.social/~/topic/x", "9ab12cd/my-room") + .as_str(), "https://slug.social/r/9ab12cdmy-room/~/topic/x" ); } @@ -465,11 +467,11 @@ mod tests { fn canonicalize_youtube_short_links() { assert_eq!( canonicalize_item("https://youtu.be/dQw4w9WgXcQ"), - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "https://youtu.be/dQw4w9WgXcQ" ); assert_eq!( canonicalize_item("-/youtu.be/dQw4w9WgXcQ"), - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "https://youtu.be/dQw4w9WgXcQ" ); } @@ -487,12 +489,29 @@ mod tests { } #[test] - fn item_id_parent_external_strips_last_segment() { - let c = ItemId::parse("https://spotify.com/track/1").unwrap(); + fn canonicalize_external_url_strips_fragment_and_query_params() { + assert_eq!( + canonicalize_item("https://example.com/path?utm_source=newsletter&b=2#section"), + "https://example.com/path" + ); assert_eq!( - c.parent().unwrap().as_str(), - "https://spotify.com/track" + canonicalize_item("-/example.com/path?FbClId=abc&a=1"), + "https://example.com/path" ); + } + + #[test] + fn canonicalize_github_external_url_normalizes_repo_identity() { + assert_eq!( + canonicalize_item("https://github.com/ORG/REPO.git?tab=issues&q=is%3Aopen"), + "https://github.com/org/repo" + ); + } + + #[test] + fn item_id_parent_external_strips_last_segment() { + let c = ItemId::parse("https://spotify.com/track/1").unwrap(); + assert_eq!(c.parent().unwrap().as_str(), "https://spotify.com/track"); assert_eq!( ItemId::parse("https://github.com/iss/1") .unwrap() @@ -501,7 +520,10 @@ mod tests { .as_str(), "https://github.com/iss" ); - assert!(ItemId::parse("https://github.com").unwrap().parent().is_none()); + assert!(ItemId::parse("https://github.com") + .unwrap() + .parent() + .is_none()); } #[test] diff --git a/types/src/url_normalize.rs b/types/src/url_normalize.rs index 999baa4f3367b632763b44ddc77b894451207c50..b655f4d834dd5a482aa1b5adad25b33461dbb76e 100644 --- a/types/src/url_normalize.rs +++ b/types/src/url_normalize.rs @@ -1,7 +1,9 @@ //! Normalization for external `http(s)://` item identity (not slug tilde ontology). //! //! Policy (intentional, extend here as new domains need treatment): -//! - Query pairs sorted lexicographically by **lowercased** key, then value. +//! - Query pairs are navigation/filter state by default and are stripped from identity. +//! - Known query-primary resources may canonicalize to path-based short links (YouTube `youtu.be/:id`). +//! - Fragments are stripped from identity. //! - YouTube family → stable `www.youtube.com` shapes where possible. use url::Url; @@ -23,8 +25,10 @@ pub fn normalize_http_identity_url(s: &str) -> Option { if !matches!(u.scheme(), "http" | "https") { return None; } + u.set_fragment(None); rewrite_youtube(&mut u); - sort_query_pairs(&mut u); + normalize_github(&mut u); + u.set_query(None); Some(u.to_string()) } @@ -45,23 +49,18 @@ fn rewrite_youtube(u: &mut Url) { match base.as_str() { "youtu.be" => { - let id = path.trim_start_matches('/').split('/').next().unwrap_or("").to_string(); + let id = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; } "youtube.com" | "m.youtube.com" => { @@ -80,20 +79,9 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) - else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; return; } @@ -109,25 +97,21 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/watch?v={id}")) - else { + let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - if k.eq_ignore_ascii_case("v") { - continue; - } - q.append_pair(&k, &v); - } - } *u = out; return; } if path.starts_with("/watch") { let _ = u.set_host(Some("www.youtube.com")); + if let Some((_, id)) = u.query_pairs().find(|(k, _)| k.eq_ignore_ascii_case("v")) { + if !id.is_empty() { + if let Ok(out) = Url::parse(&format!("https://youtu.be/{id}")) { + *u = out; + } + } + } return; } if path.starts_with("/shorts/") { @@ -142,17 +126,9 @@ fn rewrite_youtube(u: &mut Url) { if id.is_empty() { return; } - let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - let Ok(mut out) = Url::parse(&format!("https://www.youtube.com/shorts/{id}")) - else { + let Ok(out) = Url::parse(&format!("https://www.youtube.com/shorts/{id}")) else { return; }; - { - let mut q = out.query_pairs_mut(); - for (k, v) in saved { - q.append_pair(&k, &v); - } - } *u = out; return; } @@ -163,25 +139,32 @@ fn rewrite_youtube(u: &mut Url) { } } -fn sort_query_pairs(u: &mut Url) { - let pairs: Vec<(String, String)> = u.query_pairs().into_owned().collect(); - if pairs.is_empty() { - u.set_query(None); +fn normalize_github(u: &mut Url) { + let Some(host_raw) = u.host_str() else { + return; + }; + if host_raw.to_ascii_lowercase() != "github.com" { return; } - let mut pairs = pairs; - pairs.sort_by(|a, b| { - a.0.to_ascii_lowercase() - .cmp(&b.0.to_ascii_lowercase()) - .then_with(|| a.1.cmp(&b.1)) - }); - u.set_query(None); - { - let mut q = u.query_pairs_mut(); - for (k, v) in pairs { - q.append_pair(&k, &v); + + let Some(segments) = u.path_segments() else { + return; + }; + let mut segments: Vec = segments + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect(); + if let Some(repo) = segments.get_mut(1) { + if let Some(stripped) = repo.strip_suffix(".git") { + *repo = stripped.to_string(); } } + + if segments.is_empty() { + u.set_path("/"); + } else { + u.set_path(&format!("/{}", segments.join("/"))); + } } #[cfg(test)] @@ -192,19 +175,19 @@ mod tests { fn youtube_youtu_be_to_watch() { assert_eq!( normalize_http_identity_url("https://youtu.be/dQw4w9WgXcQ").as_deref(), - Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + Some("https://youtu.be/dQw4w9WgXcQ") ); } #[test] - fn youtube_watch_query_sorted() { + fn youtube_watch_v_query_becomes_path_child() { assert_eq!( normalize_http_identity_url("https://youtube.com/watch?v=Z&a=1&b=2").as_deref(), - Some("https://www.youtube.com/watch?a=1&b=2&v=Z") + Some("https://youtu.be/Z") ); assert_eq!( normalize_http_identity_url("https://youtube.com/watch?b=2&a=1&v=Z").as_deref(), - Some("https://www.youtube.com/watch?a=1&b=2&v=Z") + Some("https://youtu.be/Z") ); } @@ -212,7 +195,7 @@ mod tests { fn youtube_embed_to_watch() { assert_eq!( normalize_http_identity_url("https://www.youtube.com/embed/dQw4w9WgXcQ").as_deref(), - Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + Some("https://youtu.be/dQw4w9WgXcQ") ); } @@ -225,10 +208,56 @@ mod tests { } #[test] - fn arbitrary_query_sorted() { + fn arbitrary_query_is_stripped() { assert_eq!( normalize_http_identity_url("https://example.com/x?z=1&a=2").as_deref(), - Some("https://example.com/x?a=2&z=1") + Some("https://example.com/x") + ); + } + + #[test] + fn fragment_is_stripped_from_identity() { + assert_eq!( + normalize_http_identity_url("https://example.com/path?b=2#a-section").as_deref(), + Some("https://example.com/path") + ); + } + + #[test] + fn all_generic_query_params_are_stripped() { + assert_eq!( + normalize_http_identity_url( + "https://example.com/x?z=1&utm_source=newsletter&FbClId=abc&ref=share&a=2" + ) + .as_deref(), + Some("https://example.com/x") + ); + } + + #[test] + fn stripping_tracking_params_removes_empty_query() { + assert_eq!( + normalize_http_identity_url("https://example.com/x?utm_medium=email&si=share") + .as_deref(), + Some("https://example.com/x") + ); + } + + #[test] + fn youtube_identity_query_is_promoted_before_query_cleanup() { + assert_eq!( + normalize_http_identity_url("https://youtu.be/dQw4w9WgXcQ?si=share&v=ignored&t=12") + .as_deref(), + Some("https://youtu.be/dQw4w9WgXcQ") + ); + } + + #[test] + fn github_repo_suffix_and_noise_query_are_normalized() { + assert_eq!( + normalize_http_identity_url("https://github.com/ORG/REPO.git?tab=readme&q=is%3Aopen") + .as_deref(), + Some("https://github.com/org/repo") ); } } @@ -324,7 +353,10 @@ mod url_identity_tests { fn empty_path_vs_slash_only_path_may_differ() { let root = Url::parse("https://example.com").unwrap(); let slash = Url::parse("https://example.com/").unwrap(); - assert_eq!(root, slash, "root and trailing-slash-only merge for this parser"); + assert_eq!( + root, slash, + "root and trailing-slash-only merge for this parser" + ); } #[test] @@ -353,6 +385,9 @@ mod url_identity_tests { let plus = Url::parse("https://example.com/?q=a+b").unwrap(); let encoded = Url::parse("https://example.com/?q=a%20b").unwrap(); - assert_ne!(plus, encoded, "space as + vs %20 — different keys unless normalized"); + assert_ne!( + plus, encoded, + "space as + vs %20 — different keys unless normalized" + ); } } Side B — contributor: tommy-mor Side B — commit message: [1efb7222] multi-repository git discovery Make contribution discovery deterministic across repository and branch DAGs, with replayable attribution and adversarial coverage. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/constitution.py b/constitution.py index b535faad658c553b3b9046e2401333618e212150..4bee9f83663ab7fb36db95129b92b64b4ef57258 100644 --- a/constitution.py +++ b/constitution.py @@ -6,7 +6,7 @@ # "uvicorn", # "httpx", # "tenacity", -# "evaleval>=0.2.6", +# "evaleval==0.2.7", # "authlib", # "itsdangerous", # "starlette", @@ -29,7 +29,7 @@ from datetime import datetime, timezone from fastapi import FastAPI, Request, Response from fastapi.responses import PlainTextResponse, HTMLResponse from starlette.middleware.sessions import SessionMiddleware -import json, time, os, asyncio, httpx, pathlib +import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl import sympy as sp # type: ignore[reportMissingImports] from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential from evaleval import ( @@ -118,11 +118,35 @@ JSONL_PATH = pathlib.Path(os.environ.get("JSONL_PATH", "/data/ledger.jsonl")) GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "") GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "") -REPO = os.environ.get("REPO", "tommy-mor/slug") OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "") OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai").rstrip("/") -GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com").rstrip("/") + +# Repositories, branches, and contributor identities are constitutional inputs. +# A ref pattern matches a complete Git ref: * stays within one path component, +# while ** crosses slashes, so refs/heads/** includes branches on branches. +# Environment overrides exist for deterministic integration tests and deployments +# using the exact same source; their normalized values are committed to every +# discovery event. +DEFAULT_REPOSITORIES = [ + { + "id": "slug", + "url": "https://github.com/tommy-mor/slug.git", + "refs": ["refs/heads/**"], + }, +] +DEFAULT_CONTRIBUTORS = { + "tommy-mor": ["thmorriss@gmail.com"], +} + +REPOSITORIES = json.loads( + os.environ.get("REPOSITORIES_JSON", json.dumps(DEFAULT_REPOSITORIES)) +) +CONTRIBUTORS = json.loads( + os.environ.get("CONTRIBUTORS_JSON", json.dumps(DEFAULT_CONTRIBUTORS)) +) +GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git")) +GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120")) # Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list. SLUG_SOCIAL_BASE_URL = os.environ.get("SLUG_SOCIAL_BASE_URL", "https://slug.social").rstrip("/") @@ -146,6 +170,7 @@ class Emission: distributions: dict # author -> amount str ranking: dict # author -> score str models_used: list + discovery_snapshot_id: str = "" # empty only for pre-discovery ledger history @event @@ -163,6 +188,20 @@ class Redemption: amount: str +@event +class GitDiscovery: + schema_version: int + epoch: int + snapshot_id: str + timestamp_ms: int + config_digest: str + initial_snapshot: bool + configuration: dict + repositories: list + observations: list + commits: list + + store = JsonlStore(JSONL_PATH) @@ -532,44 +571,426 @@ Side B — unified diffs (full patches): return json.loads(content) -def _github_headers(): - h = {"Accept": "application/vnd.github+json"} - tok = os.environ.get("GITHUB_TOKEN", "") - if tok: - h["Authorization"] = f"Bearer {tok}" - return h +# =========================================================================== +# §4b. GIT DISCOVERY — immutable reachability snapshots across repositories +# =========================================================================== +# +# Git timestamps cannot prove when a branch first reached a commit. The first +# snapshot therefore bootstraps history by committer time at GENESIS_MS. Every +# later snapshot uses the stronger rule: a commit enters exactly once, when it +# first becomes reachable from the union of configured refs. +# +# OIDs are deduplicated globally, then equivalent cherry-picks are deduplicated +# by Git's stable patch identity. Merges and empty commits are graph structure, +# not separately priced contributions. Discovery is all-or-nothing: if any +# repository cannot be mirrored and verified, no snapshot is appended. + +GIT_DISCOVERY_SCHEMA_VERSION = 1 +PATCH_IDENTITY_VERSION = "git-patch-id-stable-v1" +_DISCOVERY_LOCK = asyncio.Lock() + + +def _normalized_discovery_config() -> dict: + repositories = [] + seen_ids = set() + for raw in REPOSITORIES: + repo_id = str(raw.get("id", "")) + url = str(raw.get("url", "")) + refs = sorted(set(str(x) for x in raw.get("refs", []))) + if not re.fullmatch(r"[A-Za-z0-9._-]+", repo_id): + raise ValueError(f"invalid repository id: {repo_id!r}") + if repo_id in seen_ids: + raise ValueError(f"duplicate repository id: {repo_id}") + if not url or not refs or any(not r.startswith("refs/") for r in refs): + raise ValueError(f"repository {repo_id} requires a URL and full ref patterns") + seen_ids.add(repo_id) + repositories.append({"id": repo_id, "url": url, "refs": refs}) + + email_to_contributor = {} + contributors = {} + for contributor, emails in sorted(CONTRIBUTORS.items()): + contributor = str(contributor) + normalized = sorted(set(str(e).strip().lower() for e in emails)) + if not contributor or not normalized: + raise ValueError("contributors require an id and at least one email") + for email in normalized: + if email in email_to_contributor: + raise ValueError(f"email belongs to multiple contributors: {email}") + email_to_contributor[email] = contributor + contributors[contributor] = normalized + + repositories.sort(key=lambda r: r["id"]) + return {"repositories": repositories, "contributors": contributors} + + +def _config_digest(config: dict) -> str: + encoded = json.dumps(config, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _ref_pattern_regex(pattern: str) -> re.Pattern: + out = "" + i = 0 + while i < len(pattern): + if pattern[i:i + 2] == "**": + out += ".*" + i += 2 + elif pattern[i] == "*": + out += "[^/]*" + i += 1 + elif pattern[i] == "?": + out += "[^/]" + i += 1 + else: + out += re.escape(pattern[i]) + i += 1 + return re.compile(f"^{out}$") + + +def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None) -> bytes: + command = [ + "git", + "--no-replace-objects", + "-c", "core.quotepath=true", + "-c", "core.attributesFile=/dev/null", + "-c", "diff.external=", + "-c", "diff.renames=false", + "-c", "diff.algorithm=myers", + "-c", "diff.context=3", + ] + if repo is not None: + command += ["-C", str(repo)] + command += list(args) + try: + result = subprocess.run( + command, + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={ + **os.environ, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", + "LC_ALL": "C", + "TZ": "UTC", + }, + timeout=GIT_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"git command timed out: {args[0]}") from exc + if result.returncode: + error = result.stderr.decode("utf-8", "replace").strip() + raise RuntimeError(f"git {args[0]} failed: {error}") + return result.stdout + + +def _ensure_mirror(repo: dict) -> pathlib.Path: + GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True) + mirror = GIT_MIRROR_DIR / f"{repo['id']}.git" + if not mirror.exists(): + _git(None, "clone", "--mirror", "--", repo["url"], str(mirror)) + else: + actual_url = _git(mirror, "remote", "get-url", "origin").decode().strip() + if actual_url != repo["url"]: + raise RuntimeError( + f"mirror URL mismatch for {repo['id']}: {actual_url!r}" + ) + _git(mirror, "fetch", "--prune", "origin", "+refs/*:refs/*") + _git(mirror, "fsck", "--connectivity-only", "--no-dangling") + return mirror + + +def _matching_refs(mirror: pathlib.Path, patterns: list[str]) -> list[dict]: + regexes = [_ref_pattern_regex(p) for p in patterns] + lines = _git( + mirror, "for-each-ref", "--format=%(refname)%00%(objectname)" + ).decode("utf-8", "replace").splitlines() + selected = [] + for line in lines: + if not line: + continue + ref_name, direct_oid = line.split("\x00", 1) + if not any(r.fullmatch(ref_name) for r in regexes): + continue + commit_oid = _git( + mirror, "rev-parse", "--verify", f"{ref_name}^{{commit}}" + ).decode().strip() + selected.append({ + "name": ref_name, + "direct_oid": direct_oid, + "commit_oid": commit_oid, + }) + if not selected: + raise RuntimeError(f"no refs matched patterns {patterns!r}") + return sorted(selected, key=lambda r: r["name"]) + + +def _commit_metadata(mirror: pathlib.Path, oid: str) -> dict: + raw = _git( + mirror, + "show", + "-s", + "--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B", + oid, + ).decode("utf-8", "replace") + fields = raw.split("\x00", 9) + if len(fields) != 10: + raise RuntimeError(f"could not parse commit metadata for {oid}") + return { + "oid": fields[0], + "tree_oid": fields[1], + "parent_oids": fields[2].split() if fields[2] else [], + "author_name": fields[3], + "author_email": fields[4].strip().lower(), + "author_timestamp_ms": int(fields[5]) * 1000, + "committer_name": fields[6], + "committer_email": fields[7].strip().lower(), + "committer_timestamp_ms": int(fields[8]) * 1000, + "message": fields[9].rstrip("\n"), + } -def unified_diff_from_commit_payload(payload: dict) -> str: - parts = [] - for f in payload.get("files") or []: - name = f.get("filename", "?") - patch = f.get("patch") - if patch: - parts.append(f"--- {name}\n{patch}") - else: - parts.append(f"--- {name}\n[no textual patch: binary, submodule, or too large]\n") - return "\n\n".join(parts) if parts else "[no files in API response]" +def _commit_patch(mirror: pathlib.Path, metadata: dict) -> tuple[str, str | None]: + parents = metadata["parent_oids"] + if len(parents) > 1: + return "", None + if parents: + args = ("diff", "--patch", "--binary", "--full-index", "--no-renames", + "--no-ext-diff", "--no-textconv", "--src-prefix=a/", + "--dst-prefix=b/", parents[0], metadata["oid"], "--") + else: + args = ("diff-tree", "--root", "--patch", "--binary", "--full-index", + "--no-renames", "--no-ext-diff", "--no-textconv", + "--src-prefix=a/", "--dst-prefix=b/", "--no-commit-id", + metadata["oid"], "--") + patch_bytes = _git(mirror, *args) + if not patch_bytes.strip(): + return "", None + # Run patch-id outside the repository so SHA-1 and SHA-256 repositories use + # the same canonical patch hash algorithm. + patch_id_out = _git( + None, "patch-id", "--stable", input_bytes=patch_bytes + ).decode().strip() + if patch_id_out: + stable_id = patch_id_out.split()[0] + else: + stable_id = hashlib.sha256(patch_bytes).hexdigest() + return patch_bytes.decode("utf-8", "replace"), f"{PATCH_IDENTITY_VERSION}:{stable_id}" + + +def _replayed_discovery_state(events: list) -> tuple[set[str], dict[str, str]]: + seen_oids = set() + seen_patches = {} + for event_ in events: + if not isinstance(event_, GitDiscovery): + continue + for observation in event_.observations: + seen_oids.add(observation["oid"]) + patch_identity = observation.get("patch_identity") + canonical_oid = observation.get("canonical_patch_oid") + if patch_identity and canonical_oid: + seen_patches.setdefault(patch_identity, canonical_oid) + return seen_oids, seen_patches + + +def _build_discovery(epoch_n: int, boundary_ms: int, events: list) -> GitDiscovery: + config = _normalized_discovery_config() + digest = _config_digest(config) + prior_discoveries = [e for e in events if isinstance(e, GitDiscovery)] + initial = not prior_discoveries + seen_oids, seen_patches = _replayed_discovery_state(events) + email_to_contributor = { + email: contributor + for contributor, emails in config["contributors"].items() + for email in emails + } + repository_rows = [] + locations: dict[str, list[tuple[str, str, pathlib.Path, str]]] = {} + for repo in config["repositories"]: + mirror = _ensure_mirror(repo) + object_format = _git( + mirror, "rev-parse", "--show-object-format" + ).decode().strip() + refs = _matching_refs(mirror, repo["refs"]) + repo_reachable = set() + for ref in refs: + oids = _git(mirror, "rev-list", ref["commit_oid"]).decode().splitlines() + for oid in oids: + qualified = f"{object_format}:{oid}" + repo_reachable.add(qualified) + locations.setdefault(qualified, []).append( + (repo["id"], ref["name"], mirror, oid) + ) + repository_rows.append({ + "id": repo["id"], + "url": repo["url"], + "object_format": object_format, + "refs": refs, + "reachable_commit_count": len(repo_reachable), + "reachable_set_sha256": hashlib.sha256( + "\n".join(sorted(repo_reachable)).encode() + ).hexdigest(), + }) -async def fetch_commits_since(since_ms): - since_iso = datetime.fromtimestamp(since_ms / 1000, tz=timezone.utc).isoformat() - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{GITHUB_API_BASE_URL}/repos/{REPO}/commits", - params={"since": since_iso, "per_page": 100}, - headers=_github_headers(), + new_oids = sorted(set(locations) - seen_oids) + pending = [] + for qualified_oid in new_oids: + source_rows = sorted({ + (repo_id, ref_name) for repo_id, ref_name, _, _ in locations[qualified_oid] + }) + canonical_location = min( + locations[qualified_oid], key=lambda x: (x[0], x[1]) ) - return resp.json() - + object_hashes = { + hashlib.sha256(_git(m, "cat-file", "commit", raw_oid)).hexdigest() + for _, _, m, raw_oid in locations[qualified_oid] + } + if len(object_hashes) != 1: + raise RuntimeError(f"conflicting Git objects share OID {qualified_oid}") + _, _, mirror, oid = canonical_location + metadata = _commit_metadata(mirror, oid) + patch, patch_identity = _commit_patch(mirror, metadata) + pending.append({ + **metadata, + "oid": qualified_oid, + "commit_object_sha256": next(iter(object_hashes)), + "tree_oid": f"{qualified_oid.split(':', 1)[0]}:{metadata['tree_oid']}", + "parent_oids": [ + f"{qualified_oid.split(':', 1)[0]}:{p}" + for p in metadata["parent_oids"] + ], + "patch": patch, + "patch_sha256": hashlib.sha256(patch.encode()).hexdigest() if patch else None, + "patch_identity_version": PATCH_IDENTITY_VERSION, + "patch_identity": patch_identity, + "first_sources": [ + {"repository_id": repo_id, "ref_name": ref_name} + for repo_id, ref_name in source_rows + ], + "contributor": email_to_contributor.get(metadata["author_email"]), + }) -async def fetch_commit_unified_diff(client: httpx.AsyncClient, sha: str) -> str: - resp = await client.get( - f"{GITHUB_API_BASE_URL}/repos/{REPO}/commits/{sha}", - headers=_github_headers(), + # Select patch representatives independently of repository/ref iteration order. + # Every observed patch consumes its identity, even when it predates genesis or + # has no registered contributor: copying already-observed work later must not + # turn it into a newly payable contribution. + pending.sort(key=lambda c: (c["committer_timestamp_ms"], c["oid"])) + observations = [] + commits = [] + for commit in pending: + reason = None + canonical_patch_oid = None + patch_identity = commit["patch_identity"] + duplicate_patch = False + if patch_identity: + if patch_identity in seen_patches: + canonical_patch_oid = seen_patches[patch_identity] + duplicate_patch = True + else: + canonical_patch_oid = commit["oid"] + seen_patches[patch_identity] = canonical_patch_oid + + if initial and commit["committer_timestamp_ms"] < GENESIS_MS: + reason = "before_genesis" + elif len(commit["parent_oids"]) > 1: + reason = "merge_commit" + elif not patch_identity: + reason = "empty_commit" + elif duplicate_patch: + reason = "duplicate_patch" + else: + if commit["contributor"] is None: + reason = "unknown_contributor" + + eligible = reason is None + observation = { + "oid": commit["oid"], + "first_sources": commit["first_sources"], + "committer_timestamp_ms": commit["committer_timestamp_ms"], + "patch_identity": patch_identity, + "canonical_patch_oid": canonical_patch_oid, + "eligible": eligible, + "exclusion_reason": reason, + } + observations.append(observation) + if eligible: + commits.append(commit) + + snapshot_material = { + "schema_version": GIT_DISCOVERY_SCHEMA_VERSION, + "epoch": epoch_n, + "timestamp_ms": boundary_ms, + "config_digest": digest, + "repositories": repository_rows, + "observations": observations, + } + snapshot_id = hashlib.sha256( + json.dumps(snapshot_material, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return GitDiscovery( + schema_version=GIT_DISCOVERY_SCHEMA_VERSION, + epoch=epoch_n, + snapshot_id=snapshot_id, + timestamp_ms=boundary_ms, + config_digest=digest, + initial_snapshot=initial, + configuration=config, + repositories=repository_rows, + observations=observations, + commits=commits, ) - resp.raise_for_status() - return unified_diff_from_commit_payload(resp.json()) + + +def _acquire_discovery_file_lock(): + GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True) + lock_file = (GIT_MIRROR_DIR / ".discovery.lock").open("a+b") + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + return lock_file + + +def _release_discovery_file_lock(lock_file) -> None: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() + + +async def discover_repositories(epoch_n: int, boundary_ms: int) -> GitDiscovery: + async with _DISCOVERY_LOCK: + lock_file = await asyncio.to_thread(_acquire_discovery_file_lock) + try: + events = store.read() + existing = next( + ( + e for e in events + if isinstance(e, GitDiscovery) and e.epoch == epoch_n + ), + None, + ) + if existing: + return existing + candidate = await asyncio.to_thread( + _build_discovery, epoch_n, boundary_ms, events + ) + + def append_if_new(current_events): + if any( + isinstance(e, GitDiscovery) and e.epoch == epoch_n + for e in current_events + ): + return None + return candidate + + appended = await store.atomic(append_if_new) + if appended: + return appended + return next( + e for e in store.read() + if isinstance(e, GitDiscovery) and e.epoch == epoch_n + ) + finally: + await asyncio.to_thread(_release_discovery_file_lock, lock_file) SSE_CLIENTS = [] @@ -581,28 +1002,27 @@ async def broadcast_js(js: str): await queue.put(js) -async def rank_commits(since_ms): - commits = await fetch_commits_since(since_ms) +async def rank_commits(commits: list[dict]): if not commits: - return {} - - async with httpx.AsyncClient() as gh: - commit_diffs = await asyncio.gather(*[fetch_commit_unified_diff(gh, c["sha"]) for c in commits]) + return {}, [] models = await fetch_top_models(n=3) + contributors = sorted(set(c["contributor"] for c in commits)) + if len(contributors) > 1 and not models: + raise RuntimeError("no council models available for contributor ranking") await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-council", f"Council: {', '.join(models)} — {len(commits)} commits"] ])) - authors = list(set(c["commit"]["author"]["name"] for c in commits)) + authors = contributors author_idx = {a: i for i, a in enumerate(authors)} author_commits = {a: [] for a in authors} - for c, diff_text in zip(commits, commit_diffs, strict=True): - author_commits[c["commit"]["author"]["name"]].append({ - "message": c["commit"]["message"], - "sha": c["sha"][:8], - "diff": diff_text, + for c in sorted(commits, key=lambda row: row["oid"]): + author_commits[c["contributor"]].append({ + "message": c["message"], + "sha": c["oid"].split(":", 1)[1][:8], + "diff": c["patch"], }) # TODO do we want ot coagulate the commits into a single block? or rank the many commits @@ -622,9 +1042,14 @@ async def rank_commits(since_ms): for model in models: try: result = await llm_pairwise_compare(model, author_side_for_llm(a1), author_side_for_llm(a2)) + if result["winner"] not in {"A", "B"}: + raise ValueError("winner must be A or B") w, l = (i, j) if result["winner"] == "A" else (j, i) ratio = result["ratio"].split(":") - results.append((w, l, float(ratio[0]), float(ratio[1]))) + winner_weight, loser_weight = float(ratio[0]), float(ratio[1]) + if winner_weight <= 0 or loser_weight <= 0: + raise ValueError("ratio weights must be positive") + results.append((w, l, winner_weight, loser_weight)) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-vote", ["span.model", model], " — ", @@ -637,6 +1062,7 @@ async def rank_commits(since_ms): await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-error", f"⚠ {model}: {e}"] ])) + raise RuntimeError(f"council model failed: {model}") from e return results async def progress_fn(ev): @@ -651,7 +1077,8 @@ async def rank_commits(since_ms): pairs = await pairwise_rank(len(authors), compare_fn, progress_fn) if not pairs: - return {authors[0]: Decimal("1")} if authors else {} + ranking = {authors[0]: Decimal("1")} if authors else {} + return ranking, models scores = rank_centrality(pairs) ranking = {authors[i]: Decimal(str(scores[i])) for i in range(len(authors))} @@ -662,7 +1089,7 @@ async def rank_commits(since_ms): *[["span.rank-entry", f"{a} {float(s):.3f} "] for a, s in ranking_rows], ] ])) - return ranking + return ranking, models # =========================================================================== @@ -681,35 +1108,48 @@ async def run_emission(epoch_n, boundary_ms): ["div.log-start", f"⚡ Epoch {epoch_n} emission started"] ])) - pool = pool_remaining(store.read()) - emission = pool * DECAY_RATE - await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ - ["div.log-amount", - f"Pool {pool:.4f} → emit {emission:.4f} → {pool - emission:.4f}"] - ])) - - prev_boundary_ms = epoch_boundary(epoch_n - 1) if epoch_n > 0 else GENESIS_MS - ranking = await rank_commits(prev_boundary_ms) + discovery = await discover_repositories(epoch_n, boundary_ms) + ranking, models = await rank_commits(discovery.commits) def make_emission(events): if epoch_n in {e.epoch for e in events if isinstance(e, Emission)}: return None pool_now = pool_remaining(events) - emission_now = pool_now * DECAY_RATE + emission_now = pool_now * DECAY_RATE if ranking else Decimal("0") + normalized_ranking = {} + distributions = {} + if ranking: + score_total = sum(ranking.values()) + normalized_ranking = { + contributor: score / score_total + for contributor, score in sorted(ranking.items()) + } + contributors = list(normalized_ranking) + allocated = Decimal("0") + for contributor in contributors[:-1]: + amount = emission_now * normalized_ranking[contributor] + distributions[contributor] = amount + allocated += amount + distributions[contributors[-1]] = emission_now - allocated return Emission( epoch=epoch_n, timestamp_ms=boundary_ms, + discovery_snapshot_id=discovery.snapshot_id, pool_before=str(pool_now), total_emitted=str(emission_now), pool_after=str(pool_now - emission_now), decay_rate=str(DECAY_RATE), - distributions={a: str(emission_now * s) for a, s in ranking.items()}, - ranking={a: str(s) for a, s in ranking.items()}, - models_used=[], + distributions={a: str(amount) for a, amount in distributions.items()}, + ranking={a: str(s) for a, s in normalized_ranking.items()}, + models_used=models, ) entry = await store.atomic(make_emission) if entry: + await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ + ["div.log-amount", + f"Pool {entry.pool_before} → emit {entry.total_emitted} → {entry.pool_after}"] + ])) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-complete", ["b", f"✓ Epoch {entry.epoch} complete — emitted {entry.total_emitted} SLUG"]] @@ -815,20 +1255,40 @@ async def get_contributor(github_username: str): @app.get("/api/halvening") async def get_halvening(): - half_life = sp.Rational(str(HALF_LIFE_YEARS)) - boundary = genesis_ms - elapsed = sp.Integer(0) - epoch_years = sp.Rational(1, 12) - for e in range(300): - epoch_dur = tropical_epoch_ms(boundary) - if elapsed + epoch_years >= half_life: - fraction = (half_life - elapsed) / epoch_years - jubilee_ms = round_sympy_ms(boundary + fraction * epoch_dur) - dt = datetime.fromtimestamp(jubilee_ms / 1000, tz=timezone.utc) - return {"jubilee_ms": jubilee_ms, "jubilee_utc": dt.isoformat(), - "epoch": e + float(fraction), "half_life_years": str(HALF_LIFE_YEARS)} - elapsed += epoch_years - boundary += epoch_dur + # Iterating symbolic rationals recursively causes expression-size explosion + # after hundreds of epochs. Fifty-digit Decimal arithmetic is far beyond the + # millisecond precision exposed by this endpoint and remains deterministic. + boundary = Decimal(GENESIS_MS) + j2000 = Decimal(946728000000) + century = Decimal(36525 * 86400 * 1000) + day = Decimal(86400000) + + def decimal_epoch_ms(at_ms: Decimal) -> Decimal: + T = (at_ms - j2000) / century + days = ( + Decimal("365.2421896698") + + Decimal("-6.15359e-6") * T + + Decimal("-7.29e-10") * T**2 + + Decimal("2.64e-10") * T**3 + ) + return days * day / Decimal(12) + + total_epochs = HALF_LIFE_YEARS * Decimal(12) + whole_epochs = int(total_epochs) + fraction = total_epochs - whole_epochs + for _ in range(whole_epochs): + boundary += decimal_epoch_ms(boundary) + jubilee_ms = int( + (boundary + fraction * decimal_epoch_ms(boundary)) + .to_integral_value(rounding="ROUND_HALF_UP") + ) + dt = datetime.fromtimestamp(jubilee_ms / 1000, tz=timezone.utc) + return { + "jubilee_ms": jubilee_ms, + "jubilee_utc": dt.isoformat(), + "epoch": float(total_epochs), + "half_life_years": str(HALF_LIFE_YEARS), + } @app.post("/test/emit") diff --git a/pyproject.toml b/pyproject.toml index 46f0b6fd922995045008a5e935c5178e9519ac7c..65092f570614a9a5473fea04150a9871d5e0490c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "slug-constitution" version = "0.1.0" requires-python = ">=3.11" dependencies = [ - "evaleval>=0.2.6", + "evaleval==0.2.7", "numpy", "httpx", "fastapi", @@ -12,6 +12,10 @@ dependencies = [ "starlette", "itsdangerous", "pytest", + "hypothesis", + "sympy", + "authlib", + "python-multipart", ] [tool.pytest.ini_options] diff --git a/tests/integration.clj b/tests/integration.clj index fdc0931e937bcf5f0e1bea8c5b8c632ec99eab17..0d34e3b237b4037a28f5e99350e8d447455665de 100644 --- a/tests/integration.clj +++ b/tests/integration.clj @@ -1,7 +1,7 @@ #!/usr/bin/env bb (ns test.integration "Integration tests for constitution.py. - Starts the server, mocks OpenRouter + GitHub APIs, seeds a JSONL, + Starts the server, mocks OpenRouter, creates local Git remotes, seeds a JSONL, verifies API endpoints, SSE stream, and replay determinism." (:require [babashka.process :as p] [clojure.string :as str] @@ -96,6 +96,47 @@ (throw (ex-info "bad HTTP status" {:code code :body body}))) (json/parse-string body true)))) +(defn- command! + [argv opts] + (let [r @(p/process argv (merge {:out :string :err :string} opts))] + (when-not (zero? (:exit r)) + (throw (ex-info (str "command failed: " (pr-str argv) "\n" (:err r)) + {:argv argv :result r}))) + (str/trim (:out r)))) + +(defn- git! + [dir & args] + (let [base-env (into {} (System/getenv)) + env (merge base-env + {"GIT_CONFIG_NOSYSTEM" "1" + "GIT_AUTHOR_NAME" "Integration" + "GIT_AUTHOR_EMAIL" "integration@example.test" + "GIT_COMMITTER_NAME" "Integration" + "GIT_COMMITTER_EMAIL" "integration@example.test"})] + (command! (into ["git" "-C" dir] args) {:env env}))) + +(defn- make-git-repository! + [tmp-dir id contributor email filename] + (let [remote (str tmp-dir "/" id ".git") + work (str tmp-dir "/" id "-work")] + (command! ["git" "init" "--bare" remote] {}) + (command! ["git" "init" "-b" "main" work] {}) + (git! work "config" "user.name" contributor) + (git! work "config" "user.email" email) + (spit (str work "/" filename) (str contributor " contribution\n")) + (git! work "add" "--" filename) + (let [env (merge (into {} (System/getenv)) + {"GIT_CONFIG_NOSYSTEM" "1" + "GIT_AUTHOR_NAME" contributor + "GIT_AUTHOR_EMAIL" email + "GIT_COMMITTER_NAME" contributor + "GIT_COMMITTER_EMAIL" email})] + (command! ["git" "-C" work "commit" "-m" (str "contribution by " contributor)] + {:env env})) + (git! work "remote" "add" "origin" remote) + (git! work "push" "origin" "main") + {:id id :url remote :refs ["refs/heads/**"]})) + ;; --------------------------------------------------------------------------- ;; mock OpenRouter server ;; --------------------------------------------------------------------------- @@ -199,6 +240,7 @@ (let [entry {:type "emission" :epoch 0 :timestamp_ms 1700000000000 + :discovery_snapshot_id "seeded-discovery" :pool_before "175824" :total_emitted "572.1423838308" :pool_after "175251.857616169" @@ -239,8 +281,8 @@ (str/trim (second m))))) (defn- test-live-slug-model-council! [root] - (if (= "1" (System/getenv "SKIP_LIVE_SLUG_SOCIAL")) - (println "\n━━━ live slug.social checks skipped (SKIP_LIVE_SLUG_SOCIAL=1) ━━━\n") + (if-not (= "1" (System/getenv "RUN_LIVE_SLUG_SOCIAL")) + (println "\n━━━ live slug.social checks skipped (set RUN_LIVE_SLUG_SOCIAL=1) ━━━\n") (letlocals (println "\n━━━ live slug.social: /api/v0 + fetch_top_models ━━━\n") (bind rank-url (str slug-live-base "/api/v0/rank?parent=" @@ -307,7 +349,7 @@ (when (str/starts-with? line "data:") (let [raw (str/trim (subs line 5))] (when-not (str/blank? raw) - (swap! events conj (json/parse-string raw true))))) + (swap! events conj raw)))) (recur)) nil))) (finally @@ -329,7 +371,6 @@ (bind jsonl-path (str tmp-dir "/ledger.jsonl")) (bind server-port (pick-port)) (bind or-port (pick-port)) ; openrouter mock - (bind gh-port (pick-port)) ; github mock (bind base-url (str "http://127.0.0.1:" server-port)) ;; genesis ~1 month ago so we're at epoch 1+ @@ -337,38 +378,47 @@ (bind root (project-root)) (test-live-slug-model-council! root) + (bind repo-a (make-git-repository! tmp-dir "repo-a" "alice" + "alice@example.test" "alice.txt")) + (bind repo-b (make-git-repository! tmp-dir "repo-b" "bob" + "bob@example.test" "bob.txt")) + (bind repositories-json + (json/generate-string + [(update repo-a :refs vec) (update repo-b :refs vec)])) + (bind contributors-json + (json/generate-string + {"alice" ["alice@example.test"] + "bob" ["bob@example.test"]})) (bind server-env {"SESSION_SECRET" "test-secret" "GENESIS_MS" genesis-ms "JSONL_PATH" jsonl-path + "GIT_MIRROR_DIR" (str tmp-dir "/mirrors") + "REPOSITORIES_JSON" repositories-json + "CONTRIBUTORS_JSON" contributors-json "OPENROUTER_API_KEY" "mock-key" "GITHUB_CLIENT_ID" "mock-gh-id" "GITHUB_CLIENT_SECRET" "mock-gh-secret" - "GITHUB_TOKEN" "mock-gh-token" - "REPO" "tommy-mor/slug" "PORT" (str server-port) "DISABLE_EPOCH_LOOP" "1" "ALLOW_TEST_TRIGGERS" "1" "OPENROUTER_BASE_URL" (str "http://127.0.0.1:" or-port) - "GITHUB_API_BASE_URL" (str "http://127.0.0.1:" gh-port) "SLUG_MODEL_RANK_PARENT" "" "PATH" (get (into {} (System/getenv)) "PATH" "")}) (bind !server (atom nil)) (bind !server2 (atom nil)) (bind !or-mock (atom nil)) - (bind !gh-mock (atom nil)) (try (letlocals - ;; 1. start mock servers - (println "starting mock OpenRouter and GitHub API servers…") + ;; 1. start mock model server; Git discovery uses local bare remotes + (println "starting mock OpenRouter server and local Git remotes…") (bind or-mock (start-mock-openrouter or-port)) (reset! !or-mock or-mock) - (bind gh-mock (start-mock-github gh-port)) - (reset! !gh-mock gh-mock) (assert! (some? (:stop-fn or-mock)) "mock OpenRouter started") - (assert! (some? (:stop-fn gh-mock)) "mock GitHub started") + (assert! (fs/exists? (:url repo-a)) "first bare Git remote exists") + (assert! (fs/exists? (:url repo-b)) "second bare Git remote exists") ;; 2. seed ledger so pool_remaining has history to read (seed-ledger jsonl-path) @@ -422,7 +472,8 @@ (println "\nchecking /sse initial event…") (bind sse-events (read-sse-events (str base-url "/sse") 1 5000)) (assert! (= 1 (count sse-events)) "received 1 SSE event") - (assert! (int? (:epoch (first sse-events))) "initial SSE event has epoch") + (assert! (not (str/blank? (first sse-events))) + "initial SSE event contains executable audit data") ;; 10. POST /test/emit — full ranking pipeline hits mocks (println "\ntriggering /test/emit (epoch 1)…") @@ -430,16 +481,25 @@ (assert! (= "emission" (:type emit-resp)) "emit response type is emission") (assert! (= 1 (:epoch emit-resp)) "emit is epoch 1 after seeded epoch 0") (assert! (pos? (count (:distributions emit-resp))) "emit has distributions") + (assert! (string? (:discovery_snapshot_id emit-resp)) + "emission records discovery snapshot") + (assert! (= 3 (count (:models_used emit-resp))) + "emission records all council models") (bind or-state @(:state or-mock)) - (bind gh-state @(:state gh-mock)) (assert! (pos? (:model-requests or-state)) "OpenRouter /models was called") (assert! (>= (:compare-requests or-state) 3) "at least 3 pairwise LLM calls (2 authors × 3 models)") - (assert! (pos? (:commit-list-requests gh-state)) "GitHub commits list was called") - (assert! (>= (:commit-detail-requests gh-state) 2) "GitHub per-SHA fetches for each commit") (bind ledger2 (get-json base-url "/api/ledger")) - (assert! (= 2 (count ledger2)) "ledger has 2 entries after emit") + (assert! (= 3 (count ledger2)) + "ledger has seed, discovery, and emission entries") + (bind discovery-entry (second ledger2)) + (assert! (= "gitdiscovery" (:type discovery-entry)) + "discovery is persisted before emission") + (assert! (= 2 (count (:repositories discovery-entry))) + "discovery records both repositories") + (assert! (= 2 (count (:commits discovery-entry))) + "discovery admits one contribution from each repository") (bind rank-after (get-json base-url "/api/ranking")) (assert! (= 1 (:epoch rank-after)) "latest ranking is epoch 1") @@ -458,7 +518,8 @@ "restarted server responds to /api/epoch") (bind replayed-ledger (get-json base-url "/api/ledger")) - (assert! (= 2 (count replayed-ledger)) "ledger still has 2 entries after replay") + (assert! (= 3 (count replayed-ledger)) + "ledger still has 3 entries after replay") (bind replayed-rank (get-json base-url "/api/ranking")) (assert! (= (get-in rank-after [:ranking :alice]) (get-in replayed-rank [:ranking :alice])) @@ -473,7 +534,6 @@ (.destroyForcibly (:proc s)) (deref s)) (when-some [m @!or-mock] ((:stop-fn m))) - (when-some [m @!gh-mock] ((:stop-fn m))) (fs/delete-tree tmp-dir))) (let [{:keys [pass fail]} @counts] diff --git a/tests/test_git_discovery.py b/tests/test_git_discovery.py new file mode 100644 index 0000000000000000000000000000000000000000..0dd31bc42a19bc8c59842dc61f193c595c474659 --- /dev/null +++ b/tests/test_git_discovery.py @@ -0,0 +1,467 @@ +"""Real-Git tests for the constitutional multi-repository discovery rules.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import constitution as c + + +def git(repo: Path | None, *args: str, env: dict | None = None) -> str: + command = ["git"] + if repo is not None: + command += ["-C", str(repo)] + command += list(args) + merged_env = os.environ.copy() + merged_env.update({ + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Test Author", + "GIT_AUTHOR_EMAIL": "author@example.test", + "GIT_COMMITTER_NAME": "Test Committer", + "GIT_COMMITTER_EMAIL": "author@example.test", + }) + if env: + merged_env.update(env) + result = subprocess.run( + command, + env=merged_env, + text=True, + input="", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def make_remote(tmp_path: Path, name: str) -> tuple[Path, Path]: + remote = tmp_path / f"{name}.git" + work = tmp_path / f"{name}-work" + git(None, "init", "--bare", str(remote)) + git(None, "init", "-b", "main", str(work)) + git(work, "config", "user.name", "Test Author") + git(work, "config", "user.email", "author@example.test") + git(work, "remote", "add", "origin", str(remote)) + return remote, work + + +def commit_file( + work: Path, + name: str, + content: str | bytes, + message: str, + timestamp: int, + *, + email: str = "author@example.test", +) -> str: + path = work / name + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content) + git(work, "add", "--", name) + date = f"@{timestamp} +0000" + return git( + work, + "commit", + "-m", + message, + env={ + "GIT_AUTHOR_EMAIL": email, + "GIT_COMMITTER_EMAIL": email, + "GIT_AUTHOR_DATE": date, + "GIT_COMMITTER_DATE": date, + }, + ) and git(work, "rev-parse", "HEAD") + + +def push(work: Path, *refs: str) -> None: + git(work, "push", "--force", "origin", *refs) + + +@pytest.fixture +def discovery_config(tmp_path, monkeypatch): + monkeypatch.setattr(c, "GIT_MIRROR_DIR", tmp_path / "mirrors") + monkeypatch.setattr(c, "GENESIS_MS", 1_000_000) + monkeypatch.setattr(c, "CONTRIBUTORS", { + "alice": ["author@example.test"], + "bob": ["bob@example.test"], + }) + return tmp_path + + +def configure(monkeypatch, repositories): + monkeypatch.setattr(c, "REPOSITORIES", repositories) + + +def test_ref_patterns_distinguish_nested_branches(): + shallow = c._ref_pattern_regex("refs/heads/*") + recursive = c._ref_pattern_regex("refs/heads/**") + assert shallow.fullmatch("refs/heads/main") + assert not shallow.fullmatch("refs/heads/team/topic") + assert recursive.fullmatch("refs/heads/team/topic") + + +def test_manifest_order_does_not_change_digest(monkeypatch): + repos = [ + {"id": "b", "url": "/b", "refs": ["refs/heads/z", "refs/heads/a"]}, + {"id": "a", "url": "/a", "refs": ["refs/heads/**"]}, + ] + configure(monkeypatch, repos) + monkeypatch.setattr(c, "CONTRIBUTORS", {"alice": ["A@EXAMPLE.TEST"]}) + first = c._config_digest(c._normalized_discovery_config()) + configure(monkeypatch, list(reversed(repos))) + second = c._config_digest(c._normalized_discovery_config()) + assert first == second + + +@pytest.mark.parametrize( + "repositories", + [ + [{"id": "../escape", "url": "/x", "refs": ["refs/heads/main"]}], + [{"id": "x", "url": "", "refs": ["refs/heads/main"]}], + [{"id": "x", "url": "/x", "refs": []}], + [ + {"id": "x", "url": "/x", "refs": ["refs/heads/main"]}, + {"id": "x", "url": "/y", "refs": ["refs/heads/main"]}, + ], + ], +) +def test_invalid_manifests_fail(repositories, monkeypatch): + configure(monkeypatch, repositories) + monkeypatch.setattr(c, "CONTRIBUTORS", {"alice": ["a@example.test"]}) + with pytest.raises(ValueError): + c._normalized_discovery_config() + + +def test_bootstrap_genesis_nested_refs_and_exclusions(discovery_config, monkeypatch): + remote, work = make_remote(discovery_config, "one") + old_oid = commit_file(work, "old.txt", "old", "before genesis", 999) + new_oid = commit_file(work, "new.txt", "new", "after genesis", 1001) + git(work, "branch", "team/topic") + push(work, "main", "team/topic") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/**"], + }]) + + event = c._build_discovery(0, c.GENESIS_MS, []) + observations = {row["oid"].split(":", 1)[1]: row for row in event.observations} + assert observations[old_oid]["exclusion_reason"] == "before_genesis" + assert observations[new_oid]["eligible"] + assert {source["ref_name"] for source in observations[new_oid]["first_sources"]} == { + "refs/heads/main", "refs/heads/team/topic", + } + assert event.commits[0]["contributor"] == "alice" + + +def test_first_reachable_ignores_old_timestamp_after_bootstrap( + discovery_config, monkeypatch +): + remote, work = make_remote(discovery_config, "one") + commit_file(work, "base.txt", "base", "base", 1001) + push(work, "main") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + first = c._build_discovery(0, c.GENESIS_MS, []) + + git(work, "checkout", "-b", "hidden") + old_dated_oid = commit_file(work, "late.txt", "late", "old dated", 500) + git(work, "checkout", "main") + git(work, "merge", "--ff-only", "hidden") + push(work, "main") + + second = c._build_discovery(1, c.GENESIS_MS + 1, [first]) + row = next( + row for row in second.observations + if row["oid"].endswith(old_dated_oid) + ) + assert row["eligible"] + + +def test_force_push_removal_and_reintroduction_never_reattributes( + discovery_config, monkeypatch +): + remote, work = make_remote(discovery_config, "one") + base = commit_file(work, "base.txt", "base", "base", 1001) + extra = commit_file(work, "extra.txt", "extra", "extra", 1002) + push(work, "main") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + first = c._build_discovery(0, c.GENESIS_MS, []) + + git(work, "reset", "--hard", base) + push(work, "main") + second = c._build_discovery(1, c.GENESIS_MS + 1, [first]) + assert second.observations == [] + + git(work, "reset", "--hard", extra) + push(work, "main") + third = c._build_discovery(2, c.GENESIS_MS + 2, [first, second]) + assert third.observations == [] + + +def test_global_oid_and_cherry_pick_patch_dedup(discovery_config, monkeypatch): + remote_a, work_a = make_remote(discovery_config, "a") + commit_file(work_a, "base.txt", "base", "base", 1001) + shared = commit_file(work_a, "feature.txt", "feature\n", "feature", 1002) + push(work_a, "main") + + remote_b = discovery_config / "b.git" + git(None, "clone", "--bare", str(remote_a), str(remote_b)) + work_b = discovery_config / "b-work" + git(None, "clone", str(remote_b), str(work_b)) + git(work_b, "config", "user.name", "Bob") + git(work_b, "config", "user.email", "bob@example.test") + + configure(monkeypatch, [ + {"id": "a", "url": str(remote_a), "refs": ["refs/heads/main"]}, + {"id": "b", "url": str(remote_b), "refs": ["refs/heads/main"]}, + ]) + first = c._build_discovery(0, c.GENESIS_MS, []) + shared_rows = [r for r in first.observations if r["oid"].endswith(shared)] + assert len(shared_rows) == 1 + assert len(shared_rows[0]["first_sources"]) == 2 + + git(work_b, "checkout", "-b", "copy", f"{shared}^") + git( + work_b, + "cherry-pick", + shared, + env={ + "GIT_COMMITTER_NAME": "Bob", + "GIT_COMMITTER_EMAIL": "bob@example.test", + "GIT_COMMITTER_DATE": "@1003 +0000", + }, + ) + copied = git(work_b, "rev-parse", "HEAD") + git(work_b, "branch", "-f", "main", copied) + push(work_b, "main") + + second = c._build_discovery(1, c.GENESIS_MS + 1, [first]) + copied_row = next(r for r in second.observations if r["oid"].endswith(copied)) + assert copied_row["exclusion_reason"] == "duplicate_patch" + assert copied_row["canonical_patch_oid"].endswith(shared) + assert second.commits == [] + + +def test_merge_empty_unknown_and_binary_are_explicit(discovery_config, monkeypatch): + remote, work = make_remote(discovery_config, "one") + commit_file(work, "base.txt", "base", "base", 1001) + git(work, "checkout", "-b", "feature") + commit_file(work, "binary.bin", b"\x00\x01\xff", "binary", 1002) + git(work, "checkout", "main") + commit_file(work, "main.txt", "main", "main", 1003) + git( + work, + "merge", + "--no-ff", + "feature", + "-m", + "merge", + env={"GIT_AUTHOR_DATE": "@1004 +0000", "GIT_COMMITTER_DATE": "@1004 +0000"}, + ) + git( + work, + "commit", + "--allow-empty", + "-m", + "empty", + env={"GIT_AUTHOR_DATE": "@1005 +0000", "GIT_COMMITTER_DATE": "@1005 +0000"}, + ) + commit_file( + work, "unknown.txt", "unknown", "unknown", 1006, + email="unknown@example.test", + ) + push(work, "main") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + + event = c._build_discovery(0, c.GENESIS_MS, []) + reasons = {row["exclusion_reason"] for row in event.observations} + assert {"merge_commit", "empty_commit", "unknown_contributor"} <= reasons + binary = next(row for row in event.commits if row["message"] == "binary") + assert binary["patch_identity"] + assert binary["patch_sha256"] == hashlib.sha256( + binary["patch"].encode() + ).hexdigest() + + +def test_repository_failure_does_not_return_partial_snapshot( + discovery_config, monkeypatch +): + remote, work = make_remote(discovery_config, "good") + commit_file(work, "file.txt", "ok", "ok", 1001) + push(work, "main") + configure(monkeypatch, [ + {"id": "good", "url": str(remote), "refs": ["refs/heads/main"]}, + { + "id": "missing", + "url": str(discovery_config / "missing.git"), + "refs": ["refs/heads/main"], + }, + ]) + with pytest.raises(RuntimeError): + c._build_discovery(0, c.GENESIS_MS, []) + + +def test_git_replace_refs_cannot_falsify_discovered_commit( + discovery_config, monkeypatch +): + remote, work = make_remote(discovery_config, "one") + original = commit_file(work, "file.txt", "original", "original", 1001) + replacement = commit_file(work, "file.txt", "replacement", "replacement", 1002) + git(work, "replace", original, replacement) + git(work, "reset", "--hard", original) + push(work, "main", f"refs/replace/{original}") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + + event = c._build_discovery(0, c.GENESIS_MS, []) + discovered = next(row for row in event.commits if row["oid"].endswith(original)) + assert discovered["message"] == "original" + assert "original" in discovered["patch"] + assert "replacement" not in discovered["patch"] + + +def test_replay_is_idempotent_and_path_independent(discovery_config, monkeypatch): + remote, work = make_remote(discovery_config, "one") + commit_file(work, "file.txt", "ok", "ok", 1001) + push(work, "main") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + first = c._build_discovery(0, c.GENESIS_MS, []) + second = c._build_discovery(1, c.GENESIS_MS + 1, [first]) + assert second.observations == [] + assert second.commits == [] + assert first.repositories[0]["reachable_set_sha256"] == ( + second.repositories[0]["reachable_set_sha256"] + ) + + +def test_git_timeout_is_reported_without_partial_result(monkeypatch, tmp_path): + def timeout(*_args, **_kwargs): + raise subprocess.TimeoutExpired(["git", "fetch"], 1) + + monkeypatch.setattr(c.subprocess, "run", timeout) + with pytest.raises(RuntimeError, match="timed out"): + c._git(tmp_path, "fetch") + + +def test_concurrent_same_epoch_discovery_appends_once( + discovery_config, monkeypatch +): + remote, work = make_remote(discovery_config, "one") + commit_file(work, "file.txt", "ok", "ok", 1001) + push(work, "main") + configure(monkeypatch, [{ + "id": "one", "url": str(remote), "refs": ["refs/heads/main"], + }]) + ledger_path = discovery_config / "ledger.jsonl" + monkeypatch.setattr(c, "store", c.JsonlStore(ledger_path)) + + async def run_both(): + return await asyncio.gather( + c.discover_repositories(0, c.GENESIS_MS), + c.discover_repositories(0, c.GENESIS_MS), + ) + + left, right = asyncio.run(run_both()) + assert left.snapshot_id == right.snapshot_id + discoveries = [ + event for event in c.store.read() if isinstance(event, c.GitDiscovery) + ] + assert len(discoveries) == 1 + + +def test_empty_epoch_records_zero_emission_without_burning_pool( + discovery_config, monkeypatch +): + monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) + + async def discover(_epoch, _boundary): + return SimpleNamespace(commits=[], snapshot_id="empty-snapshot") + + async def rank(_commits): + return {}, [] + + monkeypatch.setattr(c, "discover_repositories", discover) + monkeypatch.setattr(c, "rank_commits", rank) + entry = asyncio.run(c.run_emission(0, c.GENESIS_MS)) + assert c.Decimal(entry.total_emitted) == 0 + assert entry.pool_before == entry.pool_after + assert entry.distributions == {} + + +def test_emission_distribution_sums_exactly_to_total( + discovery_config, monkeypatch +): + monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) + + async def discover(_epoch, _boundary): + return SimpleNamespace(commits=[{"x": 1}], snapshot_id="ranked-snapshot") + + async def rank(_commits): + return { + "alice": c.Decimal("0.33333333333333333333333333333333333333333333333333"), + "bob": c.Decimal("0.66666666666666666666666666666666666666666666666667"), + }, ["model"] + + monkeypatch.setattr(c, "discover_repositories", discover) + monkeypatch.setattr(c, "rank_commits", rank) + entry = asyncio.run(c.run_emission(0, c.GENESIS_MS)) + distributed = sum(c.Decimal(x) for x in entry.distributions.values()) + assert distributed == c.Decimal(entry.total_emitted) + assert entry.discovery_snapshot_id == "ranked-snapshot" + + +def test_single_contributor_ranking_is_total_and_uses_no_pairwise_votes( + monkeypatch, +): + async def models(n=3): + return [] + + monkeypatch.setattr(c, "fetch_top_models", models) + ranking, used = asyncio.run(c.rank_commits([{ + "contributor": "alice", + "oid": "sha1:" + "a" * 40, + "message": "one contribution", + "patch": "patch", + }])) + assert ranking == {"alice": c.Decimal("1")} + assert used == [] + + +def test_any_council_failure_aborts_ranking(monkeypatch): + async def models(n=3): + return ["broken"] + + async def compare(*_args): + raise RuntimeError("model unavailable") + + monkeypatch.setattr(c, "fetch_top_models", models) + monkeypatch.setattr(c, "llm_pairwise_compare", compare) + commits = [ + { + "contributor": contributor, + "oid": "sha1:" + char * 40, + "message": contributor, + "patch": "patch", + } + for contributor, char in [("alice", "a"), ("bob", "b")] + ] + with pytest.raises(RuntimeError, match="council model failed"): + asyncio.run(c.rank_commits(commits)) diff --git a/tests/test_git_discovery_stateful.py b/tests/test_git_discovery_stateful.py new file mode 100644 index 0000000000000000000000000000000000000000..e41041fdbc84fff441c85422c23eaa3df53b094b --- /dev/null +++ b/tests/test_git_discovery_stateful.py @@ -0,0 +1,161 @@ +"""State-machine checks for discovery under changing real Git refs.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from hypothesis import settings +from hypothesis.stateful import ( + RuleBasedStateMachine, + invariant, + precondition, + rule, +) + +import constitution as c + + +def git(repo: Path | None, *args: str, timestamp: int | None = None) -> str: + command = ["git"] + if repo is not None: + command += ["-C", str(repo)] + command += list(args) + env = os.environ.copy() + env.update({ + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "State Machine", + "GIT_AUTHOR_EMAIL": "state@example.test", + "GIT_COMMITTER_NAME": "State Machine", + "GIT_COMMITTER_EMAIL": "state@example.test", + }) + if timestamp is not None: + env["GIT_AUTHOR_DATE"] = f"@{timestamp} +0000" + env["GIT_COMMITTER_DATE"] = f"@{timestamp} +0000" + result = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + check=False, + ) + if result.returncode: + raise AssertionError(result.stderr) + return result.stdout.strip() + + +class GitDiscoveryMachine(RuleBasedStateMachine): + def __init__(self): + super().__init__() + self.root = Path(tempfile.mkdtemp(prefix="constitution-stateful-")) + self.remote = self.root / "remote.git" + self.work = self.root / "work" + git(None, "init", "--bare", str(self.remote)) + git(None, "init", "-b", "main", str(self.work)) + git(self.work, "config", "user.name", "State Machine") + git(self.work, "config", "user.email", "state@example.test") + git(self.work, "remote", "add", "origin", str(self.remote)) + + self.original = ( + c.REPOSITORIES, c.CONTRIBUTORS, c.GIT_MIRROR_DIR, c.GENESIS_MS, + ) + c.REPOSITORIES = [{ + "id": "state", + "url": str(self.remote), + "refs": ["refs/heads/main"], + }] + c.CONTRIBUTORS = {"state": ["state@example.test"]} + c.GIT_MIRROR_DIR = self.root / "mirrors" + c.GENESIS_MS = 1_000_000 + + self.counter = 0 + self.events = [] + self.removed_tip: str | None = None + self._commit_and_push() + + def teardown(self): + ( + c.REPOSITORIES, c.CONTRIBUTORS, c.GIT_MIRROR_DIR, c.GENESIS_MS, + ) = self.original + shutil.rmtree(self.root, ignore_errors=True) + + def _commit_and_push(self): + self.counter += 1 + path = self.work / f"file-{self.counter}.txt" + path.write_text(f"value {self.counter}\n") + git(self.work, "add", "--", path.name) + git( + self.work, + "commit", + "-m", + f"commit {self.counter}", + timestamp=1000 + self.counter, + ) + git(self.work, "push", "--force", "origin", "main") + + @rule() + def advance_branch(self): + if self.removed_tip is None: + self._commit_and_push() + + @rule() + def scan(self): + event = c._build_discovery( + len(self.events), c.GENESIS_MS + len(self.events), self.events + ) + self.events.append(event) + + @precondition(lambda self: self.removed_tip is None) + @rule() + def force_push_back(self): + if self.counter < 2: + return + self.removed_tip = git(self.work, "rev-parse", "HEAD") + git(self.work, "reset", "--hard", "HEAD^") + git(self.work, "push", "--force", "origin", "main") + + @precondition(lambda self: self.removed_tip is not None) + @rule() + def restore_force_pushed_tip(self): + git(self.work, "reset", "--hard", self.removed_tip) + git(self.work, "push", "--force", "origin", "main") + self.removed_tip = None + + @invariant() + def observations_are_monotonic_and_unique(self): + observed = [ + row["oid"] + for event in self.events + for row in event.observations + ] + assert len(observed) == len(set(observed)) + + @invariant() + def patch_classes_are_ranked_at_most_once(self): + patch_ids = [ + commit["patch_identity"] + for event in self.events + for commit in event.commits + ] + assert len(patch_ids) == len(set(patch_ids)) + + @invariant() + def replay_matches_accumulated_state(self): + seen_oids, seen_patches = c._replayed_discovery_state(self.events) + expected_oids = { + row["oid"] for event in self.events for row in event.observations + } + assert seen_oids == expected_oids + assert all(oid in seen_oids for oid in seen_patches.values()) + + +TestGitDiscoveryStateMachine = GitDiscoveryMachine.TestCase +TestGitDiscoveryStateMachine.settings = settings( + max_examples=8, + stateful_step_count=12, + deadline=None, +) diff --git a/uv.lock b/uv.lock index 9cf3e067adf2f39d9eb160631c3a4b3c237ec26a..bab8e115a73870fd236bd46a1bf773b44fb5d3ee 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -42,6 +55,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -63,6 +174,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "evaleval" version = "0.2.7" @@ -125,6 +292,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.156.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/654d7533861cfdc291e55d5c335fdff2958dfd4833f7ff17c17de063b036/hypothesis-6.156.7.tar.gz", hash = "sha256:a646061075d13ebeb763eeafe3a68604483678b2c0eed90dde2ab8ff21abb62a", size = 476259, upload-time = "2026-07-18T12:16:31.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/d5/d52b00c6b7a059695beb1a68cfb76f9e9b50cbc3655278a671243d447961/hypothesis-6.156.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:cb227db6ab96667b44ace873cb5d6e9b1819c362d5ad30267ec3f279be6059dd", size = 748085, upload-time = "2026-07-18T12:15:39.252Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0e/7d9408e51862774b8ea09da12d3159efd0d4e33d27416e3139de40f26383/hypothesis-6.156.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8706f34f3c6d84db5d5a857920da27412e598a134a935070f2466f1cd32cf598", size = 742726, upload-time = "2026-07-18T12:15:28.506Z" }, + { url = "https://files.pythonhosted.org/packages/72/9f/12584e813b8e1d807344b02976a98808cc9a41fe2c64aedf1d86b0ed8dd2/hypothesis-6.156.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac49a158abcad440513b463b7a1e8471a0ff2c3c31f8f01a61aae1e9dac19756", size = 1070224, upload-time = "2026-07-18T12:16:04.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/7d/036e0ba919c592d375ca82fc452c9f1a7bd0a32092fabf4e0849c18fc206/hypothesis-6.156.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aec915de6c6ee5adaa05a22602d1ed521e1b187e6b23d416741a0aa9dea75923", size = 1121701, upload-time = "2026-07-18T12:15:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/de/5c/bec7b48fc78c3683ee01e9111262a6871a9a8c571d9a2698d5646cba63a4/hypothesis-6.156.7-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:317b828b52fb42184541c56c501b14f19cdf478e5cc27f0ff327b08ceb8048bc", size = 1111143, upload-time = "2026-07-18T12:15:15.496Z" }, + { url = "https://files.pythonhosted.org/packages/76/b1/23803fd70851bf10eeb1b1ad747b813a94ff1ff479721870658a2fef366d/hypothesis-6.156.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f1d9b2b80a961745e8603983e43c79721bd1e08c7b11e59782fbe5d999f2e9e", size = 1244920, upload-time = "2026-07-18T12:16:12.689Z" }, + { url = "https://files.pythonhosted.org/packages/09/01/6c2826eb077cb2815e4b6b53261650af959f94a0fd7019ea5681bcf0bc07/hypothesis-6.156.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90709a411250637a4a3e7e025f5c5934ce26000566b0a9c257311972185f1574", size = 1288673, upload-time = "2026-07-18T12:15:14.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e2/1acf3718536ed6ed96bdb8d034dcb28c8370be2382c954e7dd143cf2b298/hypothesis-6.156.7-cp310-abi3-win32.whl", hash = "sha256:6a5ddbb137b56829b743420865788947a333289b2809cbb5cc3396f22f497ae0", size = 635183, upload-time = "2026-07-18T12:15:41.851Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/8fb902c649444357b5732a316dbb7f7b6cee65440e469db45578d60d7ae7/hypothesis-6.156.7-cp310-abi3-win_amd64.whl", hash = "sha256:84d876ca599d8b5131b218050708750c848e5c0513210423e49f3518035eda3f", size = 640977, upload-time = "2026-07-18T12:15:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b97e666503ff04af8d8a4bebac26c685f05b70394f0466d71a648ab3719b/hypothesis-6.156.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b486272b3fbef0adea2f2b9f929642f7aaa454447a39bb988bb404372537cbff", size = 748550, upload-time = "2026-07-18T12:15:57.109Z" }, + { url = "https://files.pythonhosted.org/packages/db/3a/574b8476607e6dbdbde56e1732c3b73fa63486babbd9676f677d6d2e7fea/hypothesis-6.156.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cf115f228cfefe7651a66fd25d601783b11366aaa336d826a6820f5d4a111967", size = 743341, upload-time = "2026-07-18T12:16:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/21/c2/55d18d5fd99254307b776f340390930ba5548c9e41999aa6c5a4f7a76a28/hypothesis-6.156.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e373bfbeccd0b4ea181e1076ca31b6252f70d95fd735e867959074d5d9638eaa", size = 1070864, upload-time = "2026-07-18T12:15:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/cd/07/4170c3000ba06b6d3fd0e4434d49ce69044dcc161038ee36010018fb9d9e/hypothesis-6.156.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5efbc419e7b2774a5c66e7349851a58a69bc56138ee70d543ec1d4833aa8883", size = 1122080, upload-time = "2026-07-18T12:16:25.889Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d5/fff0db43896163f9a8a115e942c60f2d07fe75fb8d339ed42aec83dfcffb/hypothesis-6.156.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:080439d4930600742e4f7878929e71a3c4f0e31f4ac99812d675ed95086a221b", size = 1245772, upload-time = "2026-07-18T12:16:07.801Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/d449714acd4a4cad5590d74f4644f2a1c163b8055eebbe6a6278400fdaab/hypothesis-6.156.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43e2aef269045af0531f4facd0b93f1c7c6d50147ce69a9b0a8b0285b0f13638", size = 1289045, upload-time = "2026-07-18T12:16:30.36Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/3872f8fbe9b2035b7a44c0911cc52b40bed75388e99b5c298077b75c00b1/hypothesis-6.156.7-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1679aa75ab8452767cd8af1a91de0b8615b2057134079f78628faf9f49bfb", size = 640786, upload-time = "2026-07-18T12:15:17.963Z" }, + { url = "https://files.pythonhosted.org/packages/10/46/65e5ec7694b88af0e4b79cd0972ef4bc9b6b1ef4e5a7de98c45cc63a19f0/hypothesis-6.156.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a8ec2634571c1048d6c69a620aacae0a43533ea2fe36aff52a0d27720f2f8017", size = 748950, upload-time = "2026-07-18T12:15:44.372Z" }, + { url = "https://files.pythonhosted.org/packages/21/59/f00a5e5a7504d6c999042d0ea98b4fb4c7fa45523379b5c1233f667b1425/hypothesis-6.156.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f122fc5db966d4f66b61c37c8e69ed38290891dc11aee4ca0ac1ac07d31cc6b", size = 741468, upload-time = "2026-07-18T12:15:08.797Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/649c22c1ac2d273293b91977a10adb40ad59663bf23496a4c224eb5d150b/hypothesis-6.156.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa2e4958fd25622303824b023721f4199110473cf34e29d331db255568ee9931", size = 1069808, upload-time = "2026-07-18T12:15:40.644Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bf/d8053ff8f0d9506098ba5373543be4f84eba14543f506281bbc2d5da481f/hypothesis-6.156.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9841c485148830f2f4755923b4ef3648c42792bf9d8896b3a530d6d7b7999eef", size = 1121058, upload-time = "2026-07-18T12:16:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/fa89af5dd25a181cdc9bf5c6dd706ced8b2bf886f6537151c06a25b65a0e/hypothesis-6.156.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3fdcba3697df6981c4d9916b1cb03229cb92235db27e410447db5fe564b9598", size = 1244097, upload-time = "2026-07-18T12:15:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/c95a954fbd6c2059141be356afbefda6a7a7955f104d0d3cdc6a2b5415ac/hypothesis-6.156.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7fd38f6879808194d1e196b26d0f335fb9fd717ce3fe3ab2ab79562d7d22cd17", size = 1287646, upload-time = "2026-07-18T12:16:15.853Z" }, + { url = "https://files.pythonhosted.org/packages/39/ff/db93ab090e7a05fda97900f4290a3cab67e777bce47ca5d3cf59a68f02bc/hypothesis-6.156.7-cp312-cp312-win_amd64.whl", hash = "sha256:6bd58e06628863212ecb46c80546ba6bb6ab8d018b6d3f5cf3ad0ed639c5a3cd", size = 638291, upload-time = "2026-07-18T12:15:58.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/c7/80313bf239c6431f05b5e98cf6d4241cd6d1f570e4f0953a09ba0e034a5c/hypothesis-6.156.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:bc0688c17802bad5dc77c5d56f2d2f57bdcde52350f1858bd44f156608d29311", size = 749321, upload-time = "2026-07-18T12:15:46.929Z" }, + { url = "https://files.pythonhosted.org/packages/97/60/54693423e0b0df8005532fa0f70daa31ef023e2d974acb465e87e520cf0e/hypothesis-6.156.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd3aa1bc6994ffd9b7c484dc6ecdd07455dabaa171cc1665b3541309c2203721", size = 741778, upload-time = "2026-07-18T12:15:48.359Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/6090f8f47d2a5d95ee7863bccc71f448a58001ad4b556fa47f8ec588018f/hypothesis-6.156.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:160c74f54576e566922055730c578c61b421069f5aeec21fbe3759284071aed6", size = 1070014, upload-time = "2026-07-18T12:15:53.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/0f/7d46320495f9ddb42ea0e99652a49c118a4c289a46ce2e72ea729e056a8c/hypothesis-6.156.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8b52ac55d0a8e09254840f81787a3a727f771cb7af46dd854139b438ab4d7e1", size = 1121225, upload-time = "2026-07-18T12:16:20.907Z" }, + { url = "https://files.pythonhosted.org/packages/1f/99/7dfbed6ee39b8899197c8dd68adbc364a0d6594d07a3bf14880cbf25ba31/hypothesis-6.156.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ad5f3250ed768b2b98088d3fd3c902fadb4edf2282ed1751e18e712b55fbd060", size = 1244459, upload-time = "2026-07-18T12:16:22.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/63/3d2ce8803616bc4528e253a744477ca8ba61e6c5ae9ce0bfb751a16d3892/hypothesis-6.156.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79c90a535ca3910af614b568a487545b4ce20ee3ddcdd3c25a27628e355e9470", size = 1287988, upload-time = "2026-07-18T12:16:14.248Z" }, + { url = "https://files.pythonhosted.org/packages/3c/92/d8454244f55ad18b0392cc44e982111b8595348ff7c704db07f65a8df28e/hypothesis-6.156.7-cp313-cp313-win_amd64.whl", hash = "sha256:5f7b92b8aa2803881e9aed3511cb2fb88e1aa9dc615dd1e4ce68d51a5035e86b", size = 638511, upload-time = "2026-07-18T12:15:45.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/09/cf6e1eef6d56abd2b87551bcbe926e1ddf2f099b98bd8f0aa31e4cecd8a3/hypothesis-6.156.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9c3ef9b0370e393617266d645a574fbebcbd25ead0c9058a17e2278790f7bdf7", size = 749397, upload-time = "2026-07-18T12:15:51.213Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f4/a451d9220635f4f6c9de7d28247ec2ca8cc8162abf4cbcc8785831e0b058/hypothesis-6.156.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ecec3ab386f1f4f4d8607dcecc9e3ff6924a9c8cf2d8f5f5dcde37d99fae08d2", size = 741909, upload-time = "2026-07-18T12:16:27.361Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/40e3c78289619fc65412b2fb2bb43efd61c87cd97d29b4b397f365b20fbb/hypothesis-6.156.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24e09a82fb80166a9e92a8579537fae2858946dcf364de21bbb6fa529d5b4848", size = 1070292, upload-time = "2026-07-18T12:16:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1b/ad83da2595b71d078f252d034aa49c396fcdd94a15a4535407fe106f0629/hypothesis-6.156.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9e3d6120bb4069e63b555b1b3e6eac6ef662acfb3146cd2579bc226c9c92aee", size = 1121520, upload-time = "2026-07-18T12:15:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d6/318524efa95ac9868a7589dce676c5241b56d2c6ad39b0b6e2a6d0669eff/hypothesis-6.156.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:992b3c8424e9d8daf27107bbb14fb194f9155edcea28f0c6efd66b007fe7cfa3", size = 1244807, upload-time = "2026-07-18T12:15:35.541Z" }, + { url = "https://files.pythonhosted.org/packages/10/da/8225a4e51c2ba06152cf5e89566cb5d1eef7c16132cfe134774355677d22/hypothesis-6.156.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:783033b5d398272b332705306612fd6280b23ba7fa818221cf68c08604a3a532", size = 1288261, upload-time = "2026-07-18T12:15:49.82Z" }, + { url = "https://files.pythonhosted.org/packages/51/6d/1722c49b2eaf728c08591e3b1a7c77a06664e8227acda5a23e323fa47969/hypothesis-6.156.7-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d780b494c9caa06cb17b69fa87af688fc9944203d7fa050d41fba2db370bf03a", size = 586675, upload-time = "2026-07-18T12:16:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d5/ec1da06da07ee1fe7fc2ca31fdc9f94653f02b684485fb0972e330e80a5f/hypothesis-6.156.7-cp314-cp314-win_amd64.whl", hash = "sha256:a612ed61dc2341d42f282caca98787e85534613381ba0d9cc2829858d293985d", size = 638331, upload-time = "2026-07-18T12:16:19.062Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f7/d0c4fdc42d1c70c76ebeaf51295fedb1401457bd4bfef033d76a22594c8d/hypothesis-6.156.7-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:847a07059a1029ae64c5fd1480737d3615326424cfe91c89e47e4ea3a0b543e7", size = 747280, upload-time = "2026-07-18T12:16:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/15fd29960f31ec95cc7300f21e74f0a54a68e0c118ca5822e817929ef70b/hypothesis-6.156.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b933f54a8176d5f0e7fdf73ff933154ec5551ec0996081ae01bec75ecea1ef26", size = 740552, upload-time = "2026-07-18T12:15:43.098Z" }, + { url = "https://files.pythonhosted.org/packages/14/0e/22564a3e479850c86fcf835eaea8551da1f79e5f687fe1f84b7c24c40bfa/hypothesis-6.156.7-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:540933cdb53d0d08ee3fd47af3d78ed6b785db434afe12f08dc9f4b56400c5c9", size = 1069465, upload-time = "2026-07-18T12:15:32.314Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e0/add146dfd54d4b8616053823da30385f4c959a80903f9b6825fc39a8a687/hypothesis-6.156.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53f53c665a82e75da49a78e33a353dd654afa96176d7c396ea59c3574c0ed3f6", size = 1120529, upload-time = "2026-07-18T12:15:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/4d9d4c57933b98df397a6a0d5f3700c2389358580d780661cfccbd43c836/hypothesis-6.156.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a33a1e82149916c4662811e48870aa0dab45ecca0dd5a76e6bae39067e6f4cff", size = 1243233, upload-time = "2026-07-18T12:15:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/c476ba0ba65e8640e38ce3971d0a63d492d612ab3fcc724a5662395f89b6/hypothesis-6.156.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:96553167deab92c5d02d3854d865ae8e66850491eedda45f8306e88fe906e03f", size = 1287045, upload-time = "2026-07-18T12:16:11.136Z" }, + { url = "https://files.pythonhosted.org/packages/de/75/43e4dec9ccd56ed665f9dd3e78398453e16b41dac33ed56ea2efa92e3024/hypothesis-6.156.7-cp314-cp314t-win_amd64.whl", hash = "sha256:7884cea7ebe616690f976eae26cd7562dbda6030602ba32e0666681f3134d37a", size = 638419, upload-time = "2026-07-18T12:15:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/8932195b775d9c558dc0841b6ee25f774484ffed8f61bdb0552187cd1e81/hypothesis-6.156.7-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bfd514af542ca7b7a6c6f1ecaf825f074996a712e14a29382b83b9233981ba34", size = 749266, upload-time = "2026-07-18T12:15:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a69882dc126c01f04c68bb1d2b7c24e972959f2b363de4c4db32d6b1bc7b/hypothesis-6.156.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f8542dccbad2592c7e6eabfa8957987bc65b6b51862df63bbc6ef75e36734287", size = 743982, upload-time = "2026-07-18T12:15:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/2937c75a015564e75ca2ea8ab287f82bdf5f954ff523cd1c35c27315d0e9/hypothesis-6.156.7-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e964b04ec22b2273e0646b47686682f0df5a81c122c48da8acac2e0d17b8da", size = 1071135, upload-time = "2026-07-18T12:15:11.819Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/f49de6cd6d154683c3d5e5daf89eae8a1ebee394021fe7a20515bc1293bf/hypothesis-6.156.7-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d97075ab6d97dd62e940cb75f26bc6a23a3e3376324dd754df506bcfbafcab6", size = 1122685, upload-time = "2026-07-18T12:16:17.345Z" }, + { url = "https://files.pythonhosted.org/packages/21/6f/cfc4a6ca25c086f3622d21ef5d334de3633d5b8692a3165e16b12686ebb3/hypothesis-6.156.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cfd381e3d32a5e85fda2038a06fcfafea953e53217ca2d4b090ca576eab70c38", size = 641377, upload-time = "2026-07-18T12:15:19.719Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -152,6 +380,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/c6/b1cac0280f8efc57626ea8804866b37099f23cae11b1485a42b213245e31/joserfc-1.7.3.tar.gz", hash = "sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf", size = 233821, upload-time = "2026-07-08T12:41:42.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f5/650b59d1b74f5befb7a7a7e7d7c92a26b94256df3541e2b4914152cd177a/joserfc-1.7.3-py3-none-any.whl", hash = "sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075", size = 70982, upload-time = "2026-07-08T12:41:41.521Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "numpy" version = "2.4.3" @@ -249,6 +498,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -386,35 +644,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "slug-constitution" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "authlib" }, { name = "evaleval" }, { name = "fastapi" }, { name = "httpx" }, + { name = "hypothesis" }, { name = "itsdangerous" }, { name = "numpy" }, { name = "pytest" }, + { name = "python-multipart" }, { name = "starlette" }, + { name = "sympy" }, { name = "tenacity" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ - { name = "evaleval", specifier = ">=0.2.6" }, + { name = "authlib" }, + { name = "evaleval", specifier = "==0.2.7" }, { name = "fastapi" }, { name = "httpx" }, + { name = "hypothesis" }, { name = "itsdangerous" }, { name = "numpy" }, { name = "pytest" }, + { name = "python-multipart" }, { name = "starlette" }, + { name = "sympy" }, { name = "tenacity" }, { name = "uvicorn" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -428,6 +712,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tenacity" version = "9.1.4"