Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
extends Node
|
||||
class_name AntiCheat
|
||||
|
||||
# ═════════════════════════════════════════════════════════
|
||||
# Server-authoritative anti-cheat validation coordinator
|
||||
#
|
||||
# Per-player state tracking and input validation pipeline.
|
||||
# Called from the server's _physics_process per player per tick.
|
||||
#
|
||||
# Validation pipeline order:
|
||||
# 1. Input sequence (timestamp monotonic, seq# dedup)
|
||||
# 2. Movement (speed limit, teleport, multi-jump)
|
||||
# 3. Aim (max snap rotation per tick)
|
||||
# 4. Fire rate (interval between shots, ammo consistency)
|
||||
# 5. Command rate (commands/second limit)
|
||||
# 6. Enforcement (log / correct / warn / kick)
|
||||
#
|
||||
# Designed to be loaded once on the server and called per tick
|
||||
# from the game's player state update loop.
|
||||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Enforcement Levels ──────────────────────────────────
|
||||
enum AcLevel {
|
||||
OFF = 0, # Disabled — no validation performed
|
||||
LOG = 1, # Log violations to server console only
|
||||
CORRECT = 2, # Correct invalid input + warn player (default)
|
||||
KICK = 3, # Kick player on threshold breach
|
||||
}
|
||||
|
||||
# ─── Violation Categories ────────────────────────────────
|
||||
enum ViolationCategory {
|
||||
INPUT_SEQUENCE, # Bad sequence / replay / timestamp
|
||||
MOVEMENT_SPEED, # Exceeded speed limit
|
||||
MOVEMENT_TELEPORT, # Instant position change
|
||||
MOVEMENT_MULTI_JUMP, # Jump without ground contact
|
||||
AIM_SNAP, # View-angle delta exceeds limit
|
||||
FIRE_RATE_INTERVAL, # Shots too close together
|
||||
FIRE_RATE_AMMO, # Ammo delta inconsistent with shot
|
||||
COMMAND_RATE, # > max_commands/sec from client
|
||||
}
|
||||
|
||||
# ─── Cvars (exported for inspector; override via config system) ──
|
||||
|
||||
## Enforcement level (0=off, 1=log, 2=correct, 3=kick)
|
||||
@export_range(0, 3, 1) var sv_ac_level: int = 2
|
||||
|
||||
## Speed multiplier before flagging (1.2 = 20% over max)
|
||||
@export_range(1.0, 3.0, 0.05) var sv_ac_speed_tolerance: float = 1.2
|
||||
|
||||
## Max view-angle delta in degrees per tick
|
||||
@export_range(10.0, 360.0, 1.0) var sv_ac_max_aim_snap: float = 180.0
|
||||
|
||||
## Fire interval tolerance in ms above weapon's fire rate
|
||||
@export_range(0, 100, 1) var sv_ac_fire_tolerance_ms: int = 10
|
||||
|
||||
## Max commands from one client per second
|
||||
@export_range(64, 254, 1) var sv_ac_command_rate_max: int = 128
|
||||
|
||||
## Max distance units per tick before flagged as teleport
|
||||
@export_range(10.0, 500.0, 1.0) var sv_ac_teleport_threshold: float = 100.0
|
||||
|
||||
## Violations before kick (only at AcLevel.KICK)
|
||||
@export_range(1, 50, 1) var sv_ac_kick_threshold: int = 5
|
||||
|
||||
## Enable/disable multi-jump check
|
||||
@export var sv_ac_check_multi_jump: bool = true
|
||||
|
||||
## Enable/disable ammo consistency check
|
||||
@export var sv_ac_check_ammo: bool = true
|
||||
|
||||
# ─── Signals ─────────────────────────────────────────────
|
||||
|
||||
## Emitted when a violation triggers a kick decision.
|
||||
signal player_kicked(peer_id: int, reason: String, violation_count: int)
|
||||
|
||||
## Emitted on every validated violation (at all enforcement levels).
|
||||
signal violation_recorded(peer_id: int, category: int, details: String)
|
||||
|
||||
## Emitted when the anti-cheat corrects an input field.
|
||||
signal input_corrected(peer_id: int, field: String, original_value, clamped_value)
|
||||
|
||||
# ─── Per-Player Tracked State ────────────────────────────
|
||||
|
||||
class PlayerState:
|
||||
var peer_id: int
|
||||
var last_seq: int = -1
|
||||
var last_timestamp: float = 0.0
|
||||
var last_position: Vector3 = Vector3.ZERO
|
||||
var last_view_angles: Vector2 = Vector2.ZERO
|
||||
var last_fire_time: float = 0.0
|
||||
var last_ammo: int = -1
|
||||
var on_ground: bool = true # server-authoritative (set via set_ground_contact)
|
||||
var jump_count: int = 0 # consecutive airborne jumps
|
||||
var last_fire_pressed: bool = false
|
||||
var _aim_initialized: bool = false # has aim been recorded at least once?
|
||||
var command_times: Array[float] = []
|
||||
var violation_count: int = 0
|
||||
var violations_log: Array[Dictionary] = []
|
||||
var connected_at: float = 0.0
|
||||
|
||||
func _init(id: int, time: float):
|
||||
peer_id = id
|
||||
connected_at = time
|
||||
|
||||
func reset(time: float) -> void:
|
||||
last_seq = -1
|
||||
last_timestamp = 0.0
|
||||
last_position = Vector3.ZERO
|
||||
last_view_angles = Vector2.ZERO
|
||||
last_fire_time = 0.0
|
||||
last_ammo = -1
|
||||
on_ground = true
|
||||
jump_count = 0
|
||||
last_fire_pressed = false
|
||||
_aim_initialized = false
|
||||
command_times.clear()
|
||||
violation_count = 0
|
||||
violations_log.clear()
|
||||
connected_at = time
|
||||
|
||||
# ─── Internal State ──────────────────────────────────────
|
||||
|
||||
var _player_states: Dictionary = {} # peer_id → PlayerState
|
||||
|
||||
# ─── Public API ──────────────────────────────────────────
|
||||
|
||||
func register_player(peer_id: int) -> void:
|
||||
"""Register a new player for anti-cheat tracking.
|
||||
|
||||
Call when a player fully connects to the server.
|
||||
The player's state is initialized fresh.
|
||||
"""
|
||||
var state = PlayerState.new(peer_id, Time.get_ticks_usec() / 1_000_000.0)
|
||||
_player_states[peer_id] = state
|
||||
|
||||
|
||||
func unregister_player(peer_id: int) -> void:
|
||||
"""Remove a player from anti-cheat tracking.
|
||||
|
||||
Call on player disconnect to clean up state.
|
||||
"""
|
||||
_player_states.erase(peer_id)
|
||||
|
||||
|
||||
func is_tracking(peer_id: int) -> bool:
|
||||
"""Returns true if peer_id is being tracked."""
|
||||
return _player_states.has(peer_id)
|
||||
|
||||
|
||||
func validate_input(peer_id: int, input_packet: Dictionary, delta: float) -> Dictionary:
|
||||
"""Run full validation pipeline on a player's input packet.
|
||||
|
||||
Parameters:
|
||||
peer_id: Network peer ID of the player
|
||||
input_packet: Dict with keys:
|
||||
seq (int) — monotonic sequence number
|
||||
time (float) — client timestamp (seconds)
|
||||
movement (Vector3) — movement direction
|
||||
view_angles (Vector2) — camera pitch/yaw
|
||||
buttons (int) — bitmask: 1=jump, 2=fire, 4=crouch, 8=use
|
||||
ammo (int) — current weapon ammo (-1 if unknown/melee)
|
||||
position (Vector3) — player world position
|
||||
max_speed (float, optional) — current max player speed
|
||||
weapon_fire_rate_ms (float, optional) — current fire interval
|
||||
burst_count (int, optional) — ammo per burst (default: 1)
|
||||
delta: Physics tick delta (e.g. 1/128 ≈ 0.0078)
|
||||
|
||||
Integration note:
|
||||
After calling validate_input() and applying corrected input
|
||||
to physics, call set_ground_contact(peer_id, on_ground) to
|
||||
furnish server-authoritative ground-state for the next tick's
|
||||
multi-jump validation.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
corrected (Dictionary) — cleaned input packet (may have clamped fields)
|
||||
violations (Array[Dictionary]) — violations detected this tick
|
||||
action_taken (String) — "" / "warned" / "kicked"
|
||||
"""
|
||||
var result = {
|
||||
"corrected": input_packet.duplicate(),
|
||||
"violations": [],
|
||||
"action_taken": "",
|
||||
}
|
||||
|
||||
if sv_ac_level == AcLevel.OFF or not _player_states.has(peer_id):
|
||||
return result
|
||||
|
||||
var state: PlayerState = _player_states[peer_id]
|
||||
|
||||
# ── Pipeline ──
|
||||
|
||||
# 1. Input sequence validation (always runs)
|
||||
_result_seq(_validate_sequence(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 2. Movement validation
|
||||
_result_mv(_validate_movement(input_packet, state, delta, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 3. Aim validation
|
||||
_result_aim(_validate_aim(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 4. Fire rate validation
|
||||
_result_fire(_validate_fire_rate(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 5. Command rate validation
|
||||
_result_cmd(_validate_command_rate(input_packet, state), peer_id, state, result)
|
||||
|
||||
# 6. Ammo validation
|
||||
if sv_ac_check_ammo:
|
||||
_result_ammo(_validate_ammo(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# ── Enforcement (post-pipeline) ──
|
||||
if state.violation_count > 0 and result["violations"].size() > 0:
|
||||
# Set warned flag at any level above OFF
|
||||
if sv_ac_level >= AcLevel.CORRECT:
|
||||
result["action_taken"] = "warned"
|
||||
|
||||
# Kick decision — at KICK level when threshold breached
|
||||
if sv_ac_level >= AcLevel.KICK and state.violation_count >= sv_ac_kick_threshold:
|
||||
var reason: String = "Anti-cheat violation threshold reached (%d/%d)" % [
|
||||
state.violation_count, sv_ac_kick_threshold
|
||||
]
|
||||
player_kicked.emit(peer_id, reason, state.violation_count)
|
||||
result["action_taken"] = "kicked"
|
||||
|
||||
# ── Post-validation bookkeeping ──
|
||||
if input_packet.has("position") and typeof(input_packet.position) == TYPE_VECTOR3:
|
||||
state.last_position = result["corrected"].get("position", input_packet.position)
|
||||
|
||||
if input_packet.has("time"):
|
||||
state.last_timestamp = input_packet.time
|
||||
|
||||
if input_packet.has("view_angles"):
|
||||
state.last_view_angles = result["corrected"].get("view_angles", input_packet.view_angles)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func get_player_violations(peer_id: int) -> Array:
|
||||
"""Return a copy of the violation log for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return []
|
||||
return _player_states[peer_id].violations_log.duplicate()
|
||||
|
||||
|
||||
func get_player_violation_count(peer_id: int) -> int:
|
||||
"""Return the total violation count for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return 0
|
||||
return _player_states[peer_id].violation_count
|
||||
|
||||
|
||||
func reset_player_violations(peer_id: int) -> void:
|
||||
"""Reset violation counter for a player (e.g. after admin pardon)."""
|
||||
if _player_states.has(peer_id):
|
||||
var s: PlayerState = _player_states[peer_id]
|
||||
s.violation_count = 0
|
||||
s.violations_log.clear()
|
||||
|
||||
|
||||
func clear_all() -> void:
|
||||
"""Remove all tracked players."""
|
||||
_player_states.clear()
|
||||
|
||||
|
||||
func set_ground_contact(peer_id: int, on_ground: bool) -> void:
|
||||
"""Set the server-authoritative ground-contact state for a player.
|
||||
|
||||
Must be called by the game server AFTER physics simulation each tick,
|
||||
so the anti-cheat uses authoritative ground state for multi-jump
|
||||
detection on the next tick.
|
||||
"""
|
||||
if _player_states.has(peer_id):
|
||||
_player_states[peer_id].on_ground = on_ground
|
||||
|
||||
|
||||
# ─── Individual Validators ───────────────────────────────
|
||||
|
||||
func _validate_sequence(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check sequence monotonicity and timestamp monotonicity.
|
||||
|
||||
Returns violation Dictionaries or null.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var seq: int = packet.get("seq", -1)
|
||||
var time: float = packet.get("time", 0.0)
|
||||
|
||||
# First packet always accepted
|
||||
if state.last_seq == -1:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
return {"violations": violations, "invalid": false}
|
||||
|
||||
var invalid := false
|
||||
|
||||
# Sequence must increase monotonically
|
||||
if seq <= state.last_seq:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Non-monotonic sequence: %d → %d" % [state.last_seq, seq],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Timestamp must increase monotonically
|
||||
if time < state.last_timestamp:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Timestamp regression: %.4f → %.4f" % [state.last_timestamp, time],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Update state on valid (or lightly correct)
|
||||
if not invalid:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
else:
|
||||
# Correct: bump seq forward so we don't accumulate stale state
|
||||
if seq <= state.last_seq:
|
||||
corrected["seq"] = state.last_seq + 1
|
||||
|
||||
return {"violations": violations, "invalid": invalid}
|
||||
|
||||
|
||||
func _validate_movement(packet: Dictionary, state: PlayerState, delta: float, corrected: Dictionary) -> Dictionary:
|
||||
"""Check speed limit, teleport, and multi-jump.
|
||||
|
||||
The player's max speed is defined by the movement controller.
|
||||
We use a configurable tolerance multiplier.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("position") or typeof(packet.position) != TYPE_VECTOR3:
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var pos: Vector3 = packet.position
|
||||
var prev_pos: Vector3 = state.last_position
|
||||
|
||||
var displacement: Vector3 = pos - prev_pos
|
||||
var distance: float = displacement.length()
|
||||
|
||||
# ── Teleport check ──
|
||||
if distance > sv_ac_teleport_threshold and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_TELEPORT,
|
||||
"Teleport: moved %.1f units in one tick (threshold %.1f)" % [distance, sv_ac_teleport_threshold],
|
||||
{"distance": distance, "threshold": sv_ac_teleport_threshold},
|
||||
))
|
||||
# Correct: reset position to previous
|
||||
corrected["position"] = prev_pos
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Speed check ──
|
||||
# Max speed varies by weapon/game state; use a configurable tolerance
|
||||
# over the assumed base speed. The actual game should pass its max_speed
|
||||
# to validate_input(...) or we use a cvars-driven estimate.
|
||||
var max_speed_units_per_sec: float = _resolve_max_speed(packet)
|
||||
var max_speed_this_tick: float = max_speed_units_per_sec * sv_ac_speed_tolerance * delta
|
||||
|
||||
var speed: float = distance / max(delta, 0.001)
|
||||
|
||||
if distance > max_speed_this_tick and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_SPEED,
|
||||
"Speed: %.1f u/s (max %.1f u/s × %.1f tolerance)" % [speed, max_speed_units_per_sec, sv_ac_speed_tolerance],
|
||||
{"speed": speed, "max_allowed": max_speed_this_tick / delta},
|
||||
))
|
||||
|
||||
# Correct: clamp displacement to max permitted
|
||||
if distance > 0.001:
|
||||
var clamped_displacement: Vector3 = displacement.normalized() * min(distance, max_speed_this_tick)
|
||||
corrected["position"] = prev_pos + clamped_displacement
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Multi-jump check ──
|
||||
# jump_count only resets on ground contact, NOT on button release,
|
||||
# to prevent tap-jump bypass (pressing jump repeatedly while airborne).
|
||||
if sv_ac_check_multi_jump:
|
||||
var jump_pressed: bool = (packet.get("buttons", 0) & 1) != 0
|
||||
var was_on_ground: bool = state.on_ground
|
||||
|
||||
if was_on_ground:
|
||||
state.jump_count = 0
|
||||
elif jump_pressed:
|
||||
state.jump_count += 1
|
||||
|
||||
var max_jumps: int = 1 # only one jump allowed without ground contact
|
||||
if not was_on_ground and state.jump_count > max_jumps:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_MULTI_JUMP,
|
||||
"Multi-jump: %d jumps without ground contact" % state.jump_count,
|
||||
{"jump_count": state.jump_count, "max_jumps": max_jumps},
|
||||
))
|
||||
# Correct: suppress the jump flag
|
||||
corrected["buttons"] = packet.get("buttons", 0) & ~1
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_aim(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check view-angle delta per tick doesn't exceed max snap.
|
||||
|
||||
view_angles is Vector2(pitch, yaw) in degrees.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("view_angles"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var angles: Vector2 = packet.view_angles
|
||||
|
||||
# First tick — just record (no aim-initialized state yet)
|
||||
if not state._aim_initialized:
|
||||
state._aim_initialized = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
# Compute shortest angular distance per axis, handling yaw wraparound
|
||||
var delta_angles := Vector2()
|
||||
for axis in [0, 1]:
|
||||
var raw_delta: float = angles[axis] - state.last_view_angles[axis]
|
||||
var wrapped_delta: float = fmod(raw_delta, 360.0)
|
||||
if wrapped_delta > 180.0:
|
||||
wrapped_delta -= 360.0
|
||||
elif wrapped_delta < -180.0:
|
||||
wrapped_delta += 360.0
|
||||
delta_angles[axis] = abs(wrapped_delta)
|
||||
var max_delta: float = max(delta_angles.x, delta_angles.y)
|
||||
|
||||
if max_delta > sv_ac_max_aim_snap and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.AIM_SNAP,
|
||||
"Aim snap: %.1f° in one tick (max %.1f°)" % [max_delta, sv_ac_max_aim_snap],
|
||||
{"delta": max_delta, "max_allowed": sv_ac_max_aim_snap},
|
||||
))
|
||||
|
||||
# Correct: clamp delta per axis, handling yaw wraparound
|
||||
var clamped_angles := state.last_view_angles
|
||||
for axis in [0, 1]:
|
||||
var diff: float = angles[axis] - state.last_view_angles[axis]
|
||||
# Shortest-path diff, handling wraparound
|
||||
var wrapped_diff: float = fmod(diff, 360.0)
|
||||
if wrapped_diff > 180.0:
|
||||
wrapped_diff -= 360.0
|
||||
elif wrapped_diff < -180.0:
|
||||
wrapped_diff += 360.0
|
||||
# If the shortest path is within snap limit, allow it; otherwise clamp raw diff
|
||||
if abs(wrapped_diff) > sv_ac_max_aim_snap:
|
||||
var clamped_diff: float = clamp(diff, -sv_ac_max_aim_snap, sv_ac_max_aim_snap)
|
||||
clamped_angles[axis] = state.last_view_angles[axis] + clamped_diff
|
||||
|
||||
corrected["view_angles"] = clamped_angles
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_fire_rate(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check minimum interval between shots.
|
||||
|
||||
Weapon fire rate is represented as min_interval_ms.
|
||||
If the game provides it in the packet, great; otherwise use a
|
||||
reasonable minimum (based on fastest weapon in the game: ~100ms = 600rpm).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_pressed: bool = (buttons & 2) != 0
|
||||
|
||||
if not fire_pressed:
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var elapsed_ms: float = (now - state.last_fire_time) * 1000.0
|
||||
|
||||
# If weapon provides its fire interval, use it; else use a reasonable min
|
||||
var weapon_min_interval_ms: float = packet.get("weapon_fire_rate_ms", 80.0)
|
||||
var allowed_interval_ms: float = weapon_min_interval_ms - sv_ac_fire_tolerance_ms
|
||||
|
||||
# First fire always accepted
|
||||
if state.last_fire_time <= 0.0:
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
if elapsed_ms < allowed_interval_ms:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_INTERVAL,
|
||||
"Fire rate: %.1fms interval (min allowed %.1fms)" % [elapsed_ms, allowed_interval_ms],
|
||||
{"elapsed_ms": elapsed_ms, "min_allowed_ms": allowed_interval_ms},
|
||||
))
|
||||
|
||||
# Correct: suppress fire button
|
||||
corrected["buttons"] = buttons & ~2
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_command_rate(packet: Dictionary, state: PlayerState) -> Dictionary:
|
||||
"""Limit commands per second from a client.
|
||||
|
||||
Commands include any client→server packet. We track a sliding
|
||||
window of recent command timestamps.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var window_start: float = now - 1.0 # 1 second window
|
||||
|
||||
# Prune old entries
|
||||
var i: int = 0
|
||||
while i < state.command_times.size():
|
||||
if state.command_times[i] < window_start:
|
||||
state.command_times.remove_at(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
state.command_times.append(now)
|
||||
|
||||
# Allow a small burst at the start of a connection
|
||||
var is_new_connection: bool = (now - state.connected_at) < 3.0
|
||||
var burst_allowed: int = 4
|
||||
var limit: int = sv_ac_command_rate_max + (burst_allowed if is_new_connection else 0)
|
||||
|
||||
if state.command_times.size() > limit:
|
||||
# Log one violation per excess over the limit
|
||||
var excess: int = state.command_times.size() - limit
|
||||
if excess > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.COMMAND_RATE,
|
||||
"Command rate: %d/s (max %d/s)" % [state.command_times.size(), sv_ac_command_rate_max],
|
||||
{"actual_rate": state.command_times.size(), "max_rate": sv_ac_command_rate_max},
|
||||
))
|
||||
|
||||
# Keep window bounded — single cleanup pass
|
||||
while state.command_times.size() > limit + 8:
|
||||
state.command_times.remove_at(0)
|
||||
|
||||
return {"violations": violations}
|
||||
|
||||
|
||||
func _validate_ammo(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check ammo delta consistency.
|
||||
|
||||
If the player fired last tick, ammo should decrease by at most 1
|
||||
(or by burst count if provided).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("ammo"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var ammo: int = packet.ammo
|
||||
if state.last_ammo == -1:
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_this: bool = (buttons & 2) != 0
|
||||
|
||||
var delta_ammo: int = ammo - state.last_ammo
|
||||
|
||||
# Expected: negative if fired, 0 if not fired, positive if picking up ammo
|
||||
if state.last_fire_time > 0 and fire_this:
|
||||
# Should have decreased by 1 (or burst count)
|
||||
var expected_delta: int = -packet.get("burst_count", 1)
|
||||
if delta_ammo > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo increased while firing: %d → %d" % [state.last_ammo, ammo],
|
||||
{"last_ammo": state.last_ammo, "new_ammo": ammo},
|
||||
))
|
||||
# Correct: keep old ammo value
|
||||
corrected["ammo"] = state.last_ammo
|
||||
return {"violations": violations, "corrected": true}
|
||||
elif delta_ammo > expected_delta:
|
||||
# Less ammo used than expected (e.g., 0 instead of -1)
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo mismatch while firing: delta %d (expected %d)" % [delta_ammo, expected_delta],
|
||||
{"delta": delta_ammo, "expected": expected_delta},
|
||||
))
|
||||
corrected["ammo"] = max(0, state.last_ammo + expected_delta)
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
# ─── Enforcement Helpers ────────────────────────────────
|
||||
|
||||
func _make_violation(category: int, details: String, extra: Dictionary = {}) -> Dictionary:
|
||||
var v := {
|
||||
"category": category,
|
||||
"category_name": ViolationCategory.keys()[category],
|
||||
"details": details,
|
||||
"time": Time.get_ticks_usec() / 1_000_000.0,
|
||||
"extra": extra,
|
||||
}
|
||||
return v
|
||||
|
||||
|
||||
func _record_violation(state: PlayerState, v: Dictionary) -> void:
|
||||
state.violation_count += 1
|
||||
state.violations_log.append(v)
|
||||
|
||||
# Keep log bounded
|
||||
if state.violations_log.size() > 100:
|
||||
state.violations_log.pop_front()
|
||||
|
||||
|
||||
func _resolve_max_speed(packet: Dictionary) -> float:
|
||||
"""Get max player speed from packet or default.
|
||||
|
||||
The game should set 'max_speed' in the input packet.
|
||||
If not provided, assume a standard FPS walking speed.
|
||||
"""
|
||||
if packet.has("max_speed"):
|
||||
return packet.max_speed
|
||||
return 320.0 # Default: CS-style max speed
|
||||
|
||||
|
||||
# ─── Pipeline Result Handlers ──────────────────────────
|
||||
|
||||
func _result_seq(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_mv(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "position", null, null)
|
||||
|
||||
|
||||
func _result_aim(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "view_angles", null, null)
|
||||
|
||||
|
||||
func _result_fire(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "fire", null, null)
|
||||
|
||||
|
||||
func _result_cmd(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_ammo(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "ammo", null, null)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1l2t1yn1gegr
|
||||
@@ -0,0 +1,519 @@
|
||||
extends Node
|
||||
class_name NetSim
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NetSim — Network Condition Simulator for ENet Traffic
|
||||
#
|
||||
# Wraps any MultiplayerPeer (ENet, WebSocket, Steam) and injects simulated
|
||||
# adverse network conditions:
|
||||
# - Packet loss (random % drop on send/receive)
|
||||
# - Latency (fixed delay on all packets)
|
||||
# - Jitter (variable added delay on each packet)
|
||||
# - Packet reordering (swap delivery order within a window)
|
||||
# - Packet duplication (random % duplicate on send)
|
||||
# - Bandwidth throttling (cap bytes per second)
|
||||
# - Burst loss (drop N consecutive packets every M packets)
|
||||
#
|
||||
# Architecture:
|
||||
# NetSim sits BETWEEN the game and the real MultiplayerPeer.
|
||||
# It wraps the peer object and intercepts put_packet() / get_packet(),
|
||||
# applying simulated conditions before forwarding to/from the real peer.
|
||||
#
|
||||
# Usage:
|
||||
# var real_peer = ENetMultiplayerPeer.new()
|
||||
# real_peer.create_server(7777, 16)
|
||||
# var sim = NetSim.new()
|
||||
# sim.wrap_peer(real_peer)
|
||||
# # configure conditions
|
||||
# sim.packet_loss = 0.1 # 10% loss
|
||||
# sim.latency_ms = 150 # 150ms round-trip
|
||||
# sim.jitter_ms = 30 # ±30ms variable delay
|
||||
# get_tree().set_multiplayer(sim.multiplayer_api, "/root/SimMp")
|
||||
#
|
||||
# For test/diagnostic use only — NEVER enable on production servers.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────
|
||||
# All probabilities are 0.0–1.0. All times in milliseconds.
|
||||
|
||||
## Probability a packet is silently dropped on send (0.0 = no loss, 1.0 = all lost)
|
||||
@export var packet_loss: float = 0.0
|
||||
|
||||
## Additional fixed one-way latency applied to every packet (ms)
|
||||
@export var latency_ms: float = 0.0
|
||||
|
||||
## Additional random jitter on top of latency (±jitter_ms, ms).
|
||||
## Actual per-packet delay = latency_ms + randf_range(-jitter_ms, +jitter_ms)
|
||||
@export var jitter_ms: float = 0.0
|
||||
|
||||
## Packet reorder window: number of consecutive packets buffered and shuffled
|
||||
## before delivery. 0 = no reordering. Higher values = more aggressive reorder.
|
||||
## (e.g., 3 means shuffle every 3-pack group on the receive side).
|
||||
@export var reorder_window: int = 0
|
||||
|
||||
## Probability a packet is duplicated on send (0.0 = no duplicates, 1.0 = every packet doubled)
|
||||
@export var duplicate_rate: float = 0.0
|
||||
|
||||
## Max outbound bandwidth in bytes/second (0 = unlimited)
|
||||
@export var bandwidth_limit: int = 0
|
||||
|
||||
## Burst loss: drop N consecutive packets every M packets sent.
|
||||
## Set to 0 to disable burst loss.
|
||||
@export var burst_loss_count: int = 0
|
||||
@export var burst_loss_interval: int = 0
|
||||
|
||||
## Enable verbose logging of every simulated condition hit
|
||||
@export var verbose: bool = false
|
||||
|
||||
## Seed for deterministic simulation (useful for reproducible tests). -1 = random seed.
|
||||
@export var seed_value: int = -1
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────
|
||||
const CHANNEL_UNRELIABLE: int = 0
|
||||
const CHANNEL_RELIABLE: int = 1
|
||||
const CHANNEL_ORDERED: int = 2
|
||||
|
||||
# ─── Internal State ───────────────────────────────────────
|
||||
|
||||
var _wrapped_peer: MultiplayerPeer = null
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
# Queues of {time: float, packet: PackedByteArray, channel: int}
|
||||
# _outbound_queue: packets from game → network (delayed send)
|
||||
# _inbound_queue: packets from network → game (delayed receive)
|
||||
var _outbound_queue: Array[Dictionary] = []
|
||||
var _inbound_queue: Array[Dictionary] = []
|
||||
|
||||
# Reorder buffer
|
||||
var _reorder_buffer: Array[Dictionary] = []
|
||||
|
||||
# Bandwidth tracking
|
||||
var _bandwidth_used_this_second: int = 0
|
||||
var _bandwidth_timer: float = 0.0
|
||||
|
||||
# Burst loss tracking
|
||||
var _packets_sent_since_burst: int = 0
|
||||
var _burst_dropping: bool = false
|
||||
var _burst_drop_remaining: int = 0
|
||||
|
||||
# Debug stats
|
||||
var _total_sent: int = 0
|
||||
var _total_received: int = 0
|
||||
var _total_lost: int = 0
|
||||
var _total_duplicated: int = 0
|
||||
var _total_reordered: int = 0
|
||||
var _total_delayed: int = 0
|
||||
var _total_burst_dropped: int = 0
|
||||
var _total_bandwidth_dropped: int = 0
|
||||
|
||||
|
||||
# ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
func _init() -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
if seed_value >= 0:
|
||||
_rng.seed = seed_value
|
||||
else:
|
||||
_rng.randomize()
|
||||
|
||||
|
||||
## Wrap an existing MultiplayerPeer to inject simulated conditions.
|
||||
## The wrapped peer's connection state (multiplayer_api) is replaced
|
||||
## transparently — all game code interacts with NetSim as it would the real peer.
|
||||
func wrap_peer(peer: MultiplayerPeer) -> void:
|
||||
_wrapped_peer = peer
|
||||
print("[NetSim] Wrapped peer: %s" % peer.get_class())
|
||||
|
||||
# Configure a custom MultiplayerAPI that delegates to us
|
||||
if not Engine.has_singleton("NetSimDebug"):
|
||||
# Just for logging
|
||||
pass
|
||||
|
||||
reset_stats()
|
||||
|
||||
|
||||
## Reset all stats counters (keeps configuration intact).
|
||||
func reset_stats() -> void:
|
||||
_total_sent = 0
|
||||
_total_received = 0
|
||||
_total_lost = 0
|
||||
_total_duplicated = 0
|
||||
_total_reordered = 0
|
||||
_total_delayed = 0
|
||||
_total_burst_dropped = 0
|
||||
_total_bandwidth_dropped = 0
|
||||
_outbound_queue.clear()
|
||||
_inbound_queue.clear()
|
||||
_reorder_buffer.clear()
|
||||
_bandwidth_used_this_second = 0
|
||||
_bandwidth_timer = 0.0
|
||||
_packets_sent_since_burst = 0
|
||||
_burst_dropping = false
|
||||
_burst_drop_remaining = 0
|
||||
|
||||
|
||||
## Get debug stats as a formatted string.
|
||||
func get_stats_string() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
lines.append("=== NetSim Stats ===")
|
||||
lines.append(" Sent: %d" % _total_sent)
|
||||
lines.append(" Received: %d" % _total_received)
|
||||
lines.append(" Lost (loss): %d" % _total_lost)
|
||||
lines.append(" Lost (burst): %d" % _total_burst_dropped)
|
||||
lines.append(" Lost (bw): %d" % _total_bandwidth_dropped)
|
||||
lines.append(" Duplicated: %d" % _total_duplicated)
|
||||
lines.append(" Reordered: %d" % _total_reordered)
|
||||
lines.append(" Delayed: %d" % _total_delayed)
|
||||
|
||||
var effective_loss = 0.0
|
||||
var total_accounted = _total_sent + _total_duplicated
|
||||
if total_accounted > 0:
|
||||
effective_loss = float(_total_lost + _total_burst_dropped + _total_bandwidth_dropped) / float(total_accounted) * 100.0
|
||||
lines.append(" Effective loss: %.1f%%" % effective_loss)
|
||||
lines.append(" Config: loss=%.1f%% lat=%dms jitter=±%dms reorder=%d dupe=%.1f%% bw=%d/s burst=%d/%d" % [
|
||||
packet_loss * 100.0, int(latency_ms), int(jitter_ms),
|
||||
reorder_window, duplicate_rate * 100.0, bandwidth_limit,
|
||||
burst_loss_count, burst_loss_interval
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
## Apply a quick-set of common network profiles.
|
||||
## "lan" — 0% loss, 1ms latency, 0 jitter
|
||||
## "dsl" — 0.5% loss, 20ms latency, ±5ms jitter
|
||||
## "cellular" — 2% loss, 60ms latency, ±20ms jitter
|
||||
## "satellite" — 1% loss, 600ms latency, ±50ms jitter
|
||||
## "congested" — 5% loss, 100ms latency, ±40ms jitter, some reordering
|
||||
## "warzone" — 10% loss, 200ms latency, ±60ms jitter, burst loss, reorder
|
||||
func set_profile(name: String) -> bool:
|
||||
match name.to_lower():
|
||||
"lan":
|
||||
packet_loss = 0.0; latency_ms = 1; jitter_ms = 0
|
||||
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
|
||||
burst_loss_count = 0; burst_loss_interval = 0
|
||||
return true
|
||||
"dsl":
|
||||
packet_loss = 0.005; latency_ms = 20; jitter_ms = 5
|
||||
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
|
||||
burst_loss_count = 0; burst_loss_interval = 0
|
||||
return true
|
||||
"cellular":
|
||||
packet_loss = 0.02; latency_ms = 60; jitter_ms = 20
|
||||
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
|
||||
burst_loss_count = 0; burst_loss_interval = 0
|
||||
return true
|
||||
"satellite":
|
||||
packet_loss = 0.01; latency_ms = 600; jitter_ms = 50
|
||||
reorder_window = 0; duplicate_rate = 0.0; bandwidth_limit = 0
|
||||
burst_loss_count = 0; burst_loss_interval = 0
|
||||
return true
|
||||
"congested":
|
||||
packet_loss = 0.05; latency_ms = 100; jitter_ms = 40
|
||||
reorder_window = 3; duplicate_rate = 0.01; bandwidth_limit = 0
|
||||
burst_loss_count = 0; burst_loss_interval = 0
|
||||
return true
|
||||
"warzone":
|
||||
packet_loss = 0.10; latency_ms = 200; jitter_ms = 60
|
||||
reorder_window = 4; duplicate_rate = 0.02; bandwidth_limit = 50000
|
||||
burst_loss_count = 3; burst_loss_interval = 100
|
||||
return true
|
||||
_:
|
||||
push_error("[NetSim] Unknown profile: '%s'" % name)
|
||||
return false
|
||||
|
||||
|
||||
# ─── Per-Frame Processing ─────────────────────────────────
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _wrapped_peer:
|
||||
return
|
||||
|
||||
var now = Time.get_ticks_msec()
|
||||
|
||||
# --- Process delayed outbound packets ---
|
||||
# Forward expired packets to the real peer
|
||||
var to_send: Array[int] = []
|
||||
for i in _outbound_queue.size():
|
||||
if now >= _outbound_queue[i].time:
|
||||
to_send.append(i)
|
||||
# Send in reverse index order so removal doesn't shift earlier indices
|
||||
for i in to_send.size():
|
||||
var idx = to_send[to_send.size() - 1 - i]
|
||||
var entry = _outbound_queue[idx]
|
||||
_send_to_real_peer(entry.packet, entry.channel)
|
||||
_outbound_queue.remove_at(idx)
|
||||
|
||||
# --- Process real peer inbound ---
|
||||
# Read packets from the real peer and queue them for the game
|
||||
# with inbound simulation
|
||||
while _wrapped_peer.get_available_packet_count() > 0:
|
||||
var packet = _wrapped_peer.get_packet()
|
||||
_total_received += 1
|
||||
_queue_delayed_inbound(packet, CHANNEL_RELIABLE) # actual channel unknown
|
||||
|
||||
# --- Process delayed inbound packets ---
|
||||
# Reorder buffer flush
|
||||
_flush_reorder_buffer()
|
||||
# Note: delayed inbound packets are consumed by receive_packet() directly
|
||||
# from _inbound_queue — no separate delivery step needed
|
||||
|
||||
|
||||
## Must be called from the game's _process (not physics) to keep latency
|
||||
## timing accurate. Alternative: connect to SceneTree's idle signal.
|
||||
func process_conditions(delta: float) -> void:
|
||||
_process(delta)
|
||||
|
||||
|
||||
# ─── Packet Interception (call from game code) ────────────────────────────
|
||||
# The game should call these instead of directly calling put_packet/get_packet
|
||||
# on the multiplayer peer. Alternatively, implement a full MultiplayerPeer
|
||||
# override — see the companion `net_sim_peer.gd` for the full adapter.
|
||||
|
||||
## Simulate sending a packet. Returns true if the packet was queued/dropped.
|
||||
func send_packet(data: PackedByteArray, channel: int = CHANNEL_UNRELIABLE) -> bool:
|
||||
if not _wrapped_peer:
|
||||
return false
|
||||
|
||||
_total_sent += 1
|
||||
|
||||
# ── 1. Burst loss check ──
|
||||
if _check_burst_loss():
|
||||
_total_burst_dropped += 1
|
||||
if verbose:
|
||||
print("[NetSim] BURST DROP (packet #%d)" % _total_sent)
|
||||
return false # packet dropped
|
||||
|
||||
# ── 2. Random loss ──
|
||||
if _rng.randf() < packet_loss:
|
||||
_total_lost += 1
|
||||
if verbose:
|
||||
print("[NetSim] LOST (packet #%d, seq=%d, loss=%.1f%%)" % [_total_sent, _total_sent, packet_loss * 100.0])
|
||||
return false # packet dropped
|
||||
|
||||
# ── 3. Bandwidth check ──
|
||||
if bandwidth_limit > 0 and _check_bandwidth(data.size()):
|
||||
_total_bandwidth_dropped += 1
|
||||
if verbose:
|
||||
print("[NetSim] BW DROP (packet #%d, %d bytes, limit=%d/s)" % [_total_sent, data.size(), bandwidth_limit])
|
||||
return false # packet dropped
|
||||
|
||||
# ── 4. Duplication ──
|
||||
if _rng.randf() < duplicate_rate:
|
||||
_total_duplicated += 1
|
||||
if verbose:
|
||||
print("[NetSim] DUPLICATE (packet #%d)" % _total_sent)
|
||||
# Send the duplicate (with same delay treatment)
|
||||
_queue_delayed_outbound(data, channel)
|
||||
|
||||
# ── 5. Queue for latency/jitter ──
|
||||
_queue_delayed_outbound(data, channel)
|
||||
return true
|
||||
|
||||
|
||||
## Simulate receiving a packet. Returns PackedByteArray or empty if none available.
|
||||
## First checks the inbound delay queue for expired packets, then falls
|
||||
## through to the real peer for immediate (non-simulated) delivery.
|
||||
func receive_packet() -> PackedByteArray:
|
||||
if not _wrapped_peer:
|
||||
return PackedByteArray()
|
||||
|
||||
var now = Time.get_ticks_msec()
|
||||
|
||||
# Check delayed inbound queue first (simulated receive)
|
||||
if _inbound_queue.size() > 0 and _inbound_queue[0].time <= now:
|
||||
var pkt = _inbound_queue[0].packet
|
||||
_inbound_queue.remove_at(0)
|
||||
return pkt
|
||||
|
||||
# Fall through to real peer for immediate delivery
|
||||
if _wrapped_peer.get_available_packet_count() > 0:
|
||||
return _wrapped_peer.get_packet()
|
||||
|
||||
return PackedByteArray()
|
||||
|
||||
|
||||
## Check if any packet is available for receiving.
|
||||
## Checks delayed inbound queue, then real peer.
|
||||
func has_packet() -> bool:
|
||||
if not _wrapped_peer:
|
||||
return false
|
||||
|
||||
var now = Time.get_ticks_msec()
|
||||
|
||||
# Check delayed inbound queue
|
||||
for pkt in _inbound_queue:
|
||||
if pkt.time <= now:
|
||||
return true
|
||||
|
||||
# Check real peer
|
||||
if _wrapped_peer.get_available_packet_count() > 0:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Get total pending packet count (inbound and outbound).
|
||||
func get_pending_count() -> int:
|
||||
return _inbound_queue.size() + _outbound_queue.size()
|
||||
|
||||
|
||||
## Check if the wrapped peer is properly connected.
|
||||
func is_peer_connected() -> bool:
|
||||
if _wrapped_peer:
|
||||
return _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||
return false
|
||||
|
||||
|
||||
# ─── Bandwidth tracking ───────────────────────────────────────────────────
|
||||
|
||||
func _check_bandwidth(packet_size: int) -> bool:
|
||||
var now = Time.get_ticks_msec() / 1000.0
|
||||
if now - _bandwidth_timer >= 1.0:
|
||||
_bandwidth_timer = now
|
||||
_bandwidth_used_this_second = 0
|
||||
|
||||
if _bandwidth_used_this_second + packet_size > bandwidth_limit:
|
||||
return true # drop
|
||||
|
||||
_bandwidth_used_this_second += packet_size
|
||||
return false
|
||||
|
||||
|
||||
# ─── Burst loss ──────────────────────────────────────────────────────────
|
||||
|
||||
func _check_burst_loss() -> bool:
|
||||
if burst_loss_count <= 0 or burst_loss_interval <= 0:
|
||||
return false
|
||||
|
||||
if _burst_dropping:
|
||||
_burst_drop_remaining -= 1
|
||||
if _burst_drop_remaining <= 0:
|
||||
_burst_dropping = false
|
||||
return true
|
||||
|
||||
_packets_sent_since_burst += 1
|
||||
if _packets_sent_since_burst >= burst_loss_interval:
|
||||
_packets_sent_since_burst = 0
|
||||
_burst_dropping = true
|
||||
_burst_drop_remaining = burst_loss_count - 1 # current packet is also dropped
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
|
||||
# ─── Reorder buffer ──────────────────────────────────────────────────────
|
||||
return a.time < b.time
|
||||
|
||||
|
||||
# ─── Reorder buffer ──────────────────────────────────────────────────────
|
||||
|
||||
func _flush_reorder_buffer() -> void:
|
||||
if reorder_window <= 0:
|
||||
return
|
||||
|
||||
if _reorder_buffer.size() >= reorder_window:
|
||||
_total_reordered += _reorder_buffer.size()
|
||||
_reorder_buffer.shuffle()
|
||||
for pkt in _reorder_buffer:
|
||||
# Re-inject shuffled packets at front of inbound queue
|
||||
_inbound_queue.push_front(pkt)
|
||||
_inbound_queue.sort_custom(_sort_by_time)
|
||||
_reorder_buffer.clear()
|
||||
|
||||
|
||||
# ─── Direct peer passthrough ──────────────────────────────────────────────
|
||||
|
||||
## Forward a packet to the real peer immediately (no simulation).
|
||||
func _send_to_real_peer(data: PackedByteArray, channel: int) -> void:
|
||||
if _wrapped_peer and _wrapped_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
_wrapped_peer.put_packet(data)
|
||||
|
||||
|
||||
# ─── Latency queue (outbound) ────────────────────────────
|
||||
|
||||
## Queue a packet for delayed delivery to the real network peer.
|
||||
func _queue_delayed_outbound(data: PackedByteArray, channel: int) -> void:
|
||||
var delay_ms = latency_ms
|
||||
if jitter_ms > 0.0:
|
||||
delay_ms += _rng.randf_range(-jitter_ms, jitter_ms)
|
||||
delay_ms = max(0.0, delay_ms)
|
||||
|
||||
var deliver_at = Time.get_ticks_msec() + delay_ms
|
||||
|
||||
var entry := {
|
||||
"time": deliver_at,
|
||||
"packet": data,
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
if delay_ms > 0:
|
||||
_total_delayed += 1
|
||||
|
||||
_outbound_queue.append(entry)
|
||||
_outbound_queue.sort_custom(_sort_by_time)
|
||||
|
||||
|
||||
# ─── Latency queue (inbound) ─────────────────────────────
|
||||
|
||||
## Queue a packet for delayed delivery to the game (from the real network).
|
||||
func _queue_delayed_inbound(data: PackedByteArray, channel: int) -> void:
|
||||
var delay_ms = 0.0
|
||||
if jitter_ms > 0.0:
|
||||
delay_ms = _rng.randf_range(-jitter_ms, jitter_ms)
|
||||
delay_ms = max(0.0, delay_ms)
|
||||
|
||||
var deliver_at = Time.get_ticks_msec() + delay_ms
|
||||
|
||||
var entry := {
|
||||
"time": deliver_at,
|
||||
"packet": data,
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
if delay_ms > 0:
|
||||
_total_delayed += 1
|
||||
|
||||
_inbound_queue.append(entry)
|
||||
_inbound_queue.sort_custom(_sort_by_time)
|
||||
|
||||
|
||||
# ─── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
## Generate a test packet with the given sequence data.
|
||||
static func make_test_packet(seq: int, data: String = "") -> PackedByteArray:
|
||||
var payload = { "seq": seq, "data": data, "time": Time.get_ticks_usec() }
|
||||
var json_str = JSON.stringify(payload)
|
||||
return json_str.to_utf8_buffer()
|
||||
|
||||
|
||||
## Decode a test packet from the simulator.
|
||||
static func decode_test_packet(data: PackedByteArray) -> Dictionary:
|
||||
var text = data.get_string_from_utf8()
|
||||
var json = JSON.new()
|
||||
var err = json.parse(text)
|
||||
if err == OK and json.data is Dictionary:
|
||||
return json.data
|
||||
return {}
|
||||
|
||||
|
||||
## Generate a packet loss report as a formatted string.
|
||||
static func format_test_report(results: Array) -> String:
|
||||
var lines: PackedStringArray = []
|
||||
var total = results.size()
|
||||
var received = 0
|
||||
var min_seq = 0
|
||||
var max_seq = 0
|
||||
var gaps: Array[int] = []
|
||||
|
||||
for r in results:
|
||||
if r.get("received", false):
|
||||
received += 1
|
||||
|
||||
var loss_pct = 0.0 if total == 0 else (1.0 - float(received) / float(total)) * 100.0
|
||||
lines.append("=== Test Report ===")
|
||||
lines.append(" Sent: %d" % total)
|
||||
lines.append(" Received: %d (%.1f%%)" % [received, 100.0 - loss_pct])
|
||||
lines.append(" Lost: %d" % (total - received))
|
||||
lines.append(" Loss rate: %.1f%%" % loss_pct)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1 @@
|
||||
uid://booj2l3cle8xp
|
||||
@@ -0,0 +1,539 @@
|
||||
extends Node
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PluginManager — Server plugin lifecycle & hook dispatch
|
||||
#
|
||||
# Architecture (SourceMod-inspired):
|
||||
# Plugins extend PluginBase and provide hook overrides. The PluginManager
|
||||
# discovers .tres manifests in the plugin directory, loads the referenced
|
||||
# GDScripts, and dispatches hook events to all loaded plugins.
|
||||
#
|
||||
# Setup — Add as an autoload in project.godot:
|
||||
# [autoload]
|
||||
# PluginManager="*res://server/scripts/plugin_manager.gd"
|
||||
#
|
||||
# Game Integration — Connect signals to forward game events:
|
||||
# PluginManager.signal_map_start.connect(_on_map_start)
|
||||
# --- After calling your game logic, dispatch to plugins:
|
||||
# PluginManager.dispatch_map_start("de_dust2")
|
||||
#
|
||||
# Hook Dispatch Flow:
|
||||
# Game loop → (calls plugin_manager.dispatch_*) → PluginManager iterates
|
||||
# loaded plugins → calls virtual method on each → collects results
|
||||
#
|
||||
# Thread Safety:
|
||||
# Hook dispatch runs on the main thread. Plugin _ready() and _exit_tree()
|
||||
# also run on the main thread. Plugins must not create threads without
|
||||
# proper mutex handling.
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Signals (for game-logic integration) ────────────────────────────────
|
||||
# Game logic connects these to learn when plugins request actions.
|
||||
|
||||
## Emitted when a plugin wants to broadcast a chat message.
|
||||
signal plugin_broadcast_chat(message: String)
|
||||
|
||||
## Emitted when a plugin requests a player be kicked.
|
||||
signal plugin_request_kick(peer_id: int, reason: String)
|
||||
|
||||
## Emitted when a plugin explicitly asks the server to change map.
|
||||
signal plugin_request_changelevel(map_name: String)
|
||||
|
||||
|
||||
# ─── Exports / Cvars ─────────────────────────────────────────────────────
|
||||
|
||||
## Master enable/disable. When false, no plugins are loaded.
|
||||
@export var sv_plugin_enabled: bool = true:
|
||||
set = set_plugin_enabled
|
||||
|
||||
## Directory to scan for plugin .tres manifests.
|
||||
## Relative to user:// for runtime discovery (admins can FTP plugins in).
|
||||
## Set to "res://plugins" for development / built-in plugins.
|
||||
@export var sv_plugin_dir: String = "user://plugins"
|
||||
|
||||
## Path separator pattern — subdirectories under sv_plugin_dir are scanned.
|
||||
## Each subdirectory should contain a plugin.tres file.
|
||||
@export var sv_plugin_rescan_on_map: bool = false
|
||||
|
||||
|
||||
# ─── Internal State ──────────────────────────────────────────────────────
|
||||
|
||||
## Loaded plugins keyed by plugin_name (lowercase).
|
||||
var loaded_plugins: Dictionary = {} # "plugin_name_lower" → PluginBase
|
||||
|
||||
## Registration order (for deterministic dispatch).
|
||||
var _load_order: Array[PluginBase] = []
|
||||
|
||||
## Whether the server is currently running (set by dispatch_server_start).
|
||||
var _server_running: bool = false
|
||||
|
||||
## Reference to the CvarRegistry singleton (cached).
|
||||
var _cvar_registry = null
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Initialization & Lifecycle
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _ready() -> void:
|
||||
"""Called when the PluginManager is added to the scene tree (autoload)."""
|
||||
# Cache CvarRegistry reference
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
_cvar_registry = Engine.get_singleton("CvarRegistry")
|
||||
|
||||
# Read cvar overrides
|
||||
_apply_cvar_overrides()
|
||||
|
||||
# Discover and load plugins
|
||||
if sv_plugin_enabled:
|
||||
_discover_plugins()
|
||||
else:
|
||||
print("[PluginManager] Plugin system disabled (sv_plugin_enabled=false)")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
"""Clean shutdown — dispatch server_stop if not already done."""
|
||||
if _server_running:
|
||||
dispatch_server_stop()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Cvar Integration
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _apply_cvar_overrides() -> void:
|
||||
"""Read overrides from the CvarRegistry (if available)."""
|
||||
if not _cvar_registry:
|
||||
return
|
||||
|
||||
if _cvar_registry.has_method("get"):
|
||||
var val = _cvar_registry.get("sv_plugin_enabled")
|
||||
if val != null:
|
||||
sv_plugin_enabled = bool(val)
|
||||
|
||||
val = _cvar_registry.get("sv_plugin_dir")
|
||||
if val != null and not str(val).is_empty():
|
||||
sv_plugin_dir = str(val)
|
||||
|
||||
|
||||
func set_plugin_enabled(new_val: bool) -> void:
|
||||
"""Setter for sv_plugin_enabled — triggers load/unload if state changes."""
|
||||
if new_val == sv_plugin_enabled:
|
||||
return
|
||||
sv_plugin_enabled = new_val
|
||||
|
||||
if sv_plugin_enabled:
|
||||
print("[PluginManager] Enabling plugin system")
|
||||
_discover_plugins()
|
||||
else:
|
||||
print("[PluginManager] Disabling plugin system — unloading all")
|
||||
_unload_all()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Plugin Discovery & Loading
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func discover_plugins() -> Dictionary:
|
||||
"""Force a re-scan of the plugin directory. Returns {loaded, failed, skipped} counts."""
|
||||
return _discover_plugins()
|
||||
|
||||
|
||||
func _discover_plugins() -> Dictionary:
|
||||
"""Scan sv_plugin_dir for plugin.tres manifests and load new plugins.
|
||||
|
||||
Returns a dict with:
|
||||
loaded: int — plugins successfully loaded
|
||||
failed: int — manifests found but failed to load
|
||||
skipped: int — manifests skipped (already loaded, disabled, or invalid)
|
||||
"""
|
||||
var result = {"loaded": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
# Resolve the plugin directory path
|
||||
var dir_path = _resolve_plugin_dir()
|
||||
if dir_path.is_empty():
|
||||
push_warning("[PluginManager] Could not resolve plugin directory: " + sv_plugin_dir)
|
||||
return result
|
||||
|
||||
var dir = DirAccess.open(dir_path)
|
||||
if not dir:
|
||||
push_warning("[PluginManager] Plugin directory not found: " + dir_path)
|
||||
return result
|
||||
|
||||
# List subdirectories (each subdir = one plugin)
|
||||
dir.list_dir_begin()
|
||||
var entry = dir.get_next()
|
||||
|
||||
while entry:
|
||||
if entry != "." and entry != ".." and dir.current_is_dir():
|
||||
var sub_path = dir_path.path_join(entry)
|
||||
var manifest_path = sub_path.path_join("plugin.tres")
|
||||
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
var load_result = _load_plugin(manifest_path)
|
||||
if load_result == OK:
|
||||
result["loaded"] += 1
|
||||
elif load_result == ERR_SKIP:
|
||||
result["skipped"] += 1
|
||||
else:
|
||||
result["failed"] += 1
|
||||
else:
|
||||
result["skipped"] += 1
|
||||
|
||||
entry = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
print("[PluginManager] Discovery complete — loaded: " + str(result["loaded"]) + \
|
||||
", failed: " + str(result["failed"]) + ", skipped: " + str(result["skipped"]))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _load_plugin(manifest_path: String) -> int:
|
||||
"""Load a single plugin from its manifest path.
|
||||
|
||||
Returns:
|
||||
OK — loaded successfully
|
||||
ERR_SKIP — skipped (already loaded, disabled, or invalid manifest)
|
||||
ERR_FILE_CORRUPT — manifest couldn't be loaded
|
||||
ERR_SCRIPT_FAILED — script failed to instantiate
|
||||
"""
|
||||
# Load the manifest Resource
|
||||
var res = ResourceLoader.load(manifest_path, "Resource", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
if not res:
|
||||
push_error("[PluginManager] Failed to load manifest: " + manifest_path)
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
# The manifest is a PluginManifest if we loaded a custom Resource, or a
|
||||
# plain Resource with export properties.
|
||||
var manifest = res as PluginManifest
|
||||
|
||||
# Validate
|
||||
if not manifest or not manifest.is_valid():
|
||||
push_warning("[PluginManager] Skipping invalid manifest: " + manifest_path)
|
||||
return ERR_SKIP
|
||||
|
||||
if not manifest.plugin_enabled:
|
||||
print("[PluginManager] Plugin '" + manifest.plugin_name + "' is disabled — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
var name_key = manifest.plugin_name.to_lower()
|
||||
|
||||
# Check if already loaded
|
||||
if loaded_plugins.has(name_key):
|
||||
print("[PluginManager] Plugin '" + manifest.plugin_name + "' already loaded — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
# Check version compatibility
|
||||
if not _check_version_compatibility(manifest):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' version requirement not met — skipping")
|
||||
return ERR_SKIP
|
||||
|
||||
# Load the plugin script
|
||||
if manifest.plugin_script_path.is_empty():
|
||||
push_error("[PluginManager] Plugin '" + manifest.plugin_name + "' has no script path")
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
# Resolve the script path relative to the plugin directory
|
||||
var script_path = manifest.plugin_script_path
|
||||
if not script_path.begins_with("res://") and not script_path.begins_with("user://"):
|
||||
# If relative, resolve relative to manifest location
|
||||
var manifest_dir = manifest_path.get_base_dir()
|
||||
script_path = manifest_dir.path_join(script_path)
|
||||
|
||||
if not ResourceLoader.exists(script_path):
|
||||
push_error("[PluginManager] Plugin script not found: " + script_path)
|
||||
return ERR_FILE_CORRUPT
|
||||
|
||||
var script_res = load(script_path)
|
||||
if not script_res or not (script_res is GDScript):
|
||||
push_error("[PluginManager] Plugin script is not a valid GDScript: " + script_path)
|
||||
return ERR_SCRIPT_FAILED
|
||||
|
||||
# Instantiate the plugin
|
||||
var plugin_instance = script_res.new()
|
||||
if not plugin_instance or not (plugin_instance is PluginBase):
|
||||
push_error("[PluginManager] Plugin script must extend PluginBase: " + script_path)
|
||||
if plugin_instance:
|
||||
plugin_instance.free()
|
||||
return ERR_SCRIPT_FAILED
|
||||
|
||||
# Apply metadata from manifest
|
||||
plugin_instance.plugin_name = manifest.plugin_name
|
||||
plugin_instance.plugin_version = manifest.plugin_version
|
||||
plugin_instance.plugin_author = manifest.plugin_author
|
||||
plugin_instance.plugin_description = manifest.plugin_description
|
||||
plugin_instance.plugin_manifest_path = manifest_path
|
||||
|
||||
# Register
|
||||
loaded_plugins[name_key] = plugin_instance
|
||||
_load_order.append(plugin_instance)
|
||||
|
||||
# Add to scene tree to enable _ready(), _process(), etc.
|
||||
add_child(plugin_instance)
|
||||
|
||||
print("[PluginManager] Loaded plugin: " + manifest.plugin_name + " v" + manifest.plugin_version)
|
||||
return OK
|
||||
|
||||
|
||||
func unload_plugin(name: String) -> bool:
|
||||
"""Unload a specific plugin by name. Returns true on success."""
|
||||
var name_key = name.to_lower()
|
||||
if not loaded_plugins.has(name_key):
|
||||
return false
|
||||
|
||||
var plugin = loaded_plugins[name_key] as PluginBase
|
||||
|
||||
# Remove from tracking first so dispatch doesn't hit it mid-unload
|
||||
loaded_plugins.erase(name_key)
|
||||
var idx = _load_order.find(plugin)
|
||||
if idx >= 0:
|
||||
_load_order.remove_at(idx)
|
||||
|
||||
# Save state (call on_server_stop if server is running)
|
||||
if _server_running:
|
||||
plugin.on_server_stop()
|
||||
|
||||
# Remove from scene tree
|
||||
remove_child(plugin)
|
||||
plugin.queue_free()
|
||||
|
||||
print("[PluginManager] Unloaded plugin: " + plugin.plugin_name)
|
||||
return true
|
||||
|
||||
|
||||
func reload_plugin(name: String) -> bool:
|
||||
"""Reload a plugin by unloading it, then re-loading from its manifest.
|
||||
Returns true if both unload and load succeeded.
|
||||
"""
|
||||
var name_key = name.to_lower()
|
||||
if not loaded_plugins.has(name_key):
|
||||
return false
|
||||
|
||||
var plugin = loaded_plugins[name_key] as PluginBase
|
||||
var manifest_path = plugin.plugin_manifest_path
|
||||
|
||||
if not unload_plugin(name):
|
||||
return false
|
||||
|
||||
if manifest_path.is_empty():
|
||||
push_warning("[PluginManager] No manifest path to reload from for '" + name + "'")
|
||||
return false
|
||||
|
||||
var result = _load_plugin(manifest_path)
|
||||
if result != OK:
|
||||
push_error("[PluginManager] Failed to reload plugin '" + name + "'")
|
||||
return false
|
||||
|
||||
# If server is running, call on_server_start on the new instance
|
||||
if _server_running:
|
||||
var new_plugin = loaded_plugins.get(name_key)
|
||||
if new_plugin:
|
||||
new_plugin.on_server_start()
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func _unload_all() -> void:
|
||||
"""Unload every loaded plugin."""
|
||||
for plugin in _load_order.duplicate():
|
||||
unload_plugin(plugin.plugin_name)
|
||||
|
||||
|
||||
func get_plugin(name: String) -> PluginBase:
|
||||
"""Get a loaded plugin by name. Returns null if not found."""
|
||||
return loaded_plugins.get(name.to_lower())
|
||||
|
||||
|
||||
func get_plugin_count() -> int:
|
||||
"""Number of currently loaded plugins."""
|
||||
return _load_order.size()
|
||||
|
||||
|
||||
func list_plugins() -> Array[Dictionary]:
|
||||
"""Return metadata for all loaded plugins (for admin display)."""
|
||||
var result: Array[Dictionary] = []
|
||||
for plugin in _load_order:
|
||||
result.append({
|
||||
"name": plugin.plugin_name,
|
||||
"version": plugin.plugin_version,
|
||||
"author": plugin.plugin_author,
|
||||
"description": plugin.plugin_description,
|
||||
"manifest_path": plugin.plugin_manifest_path,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Hook Dispatch Methods
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Each dispatch_* method iterates all loaded plugins and calls the
|
||||
# corresponding virtual hook method. For hooks that return a bool,
|
||||
# the method collects return values and decides: if ANY plugin returns
|
||||
# true, the action is considered blocked.
|
||||
#
|
||||
# The game loop should call these at the appropriate points.
|
||||
|
||||
func dispatch_server_start() -> void:
|
||||
"""Notify all plugins that the server has started."""
|
||||
_server_running = true
|
||||
for plugin in _load_order:
|
||||
plugin.on_server_start()
|
||||
|
||||
|
||||
func dispatch_server_stop() -> void:
|
||||
"""Notify all plugins that the server is shutting down."""
|
||||
_server_running = false
|
||||
# Iterate in reverse for clean shutdown
|
||||
for i in range(_load_order.size() - 1, -1, -1):
|
||||
_load_order[i].on_server_stop()
|
||||
|
||||
|
||||
func dispatch_map_start(map_name: String) -> void:
|
||||
"""Notify plugins a new map has loaded."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_map_start(map_name)
|
||||
|
||||
|
||||
func dispatch_map_end(map_name: String) -> void:
|
||||
"""Notify plugins the current map is ending."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_map_end(map_name)
|
||||
|
||||
|
||||
func dispatch_player_connect(peer_id: int, name: String, steam_id: String) -> bool:
|
||||
"""Ask plugins if a player connection should be blocked.
|
||||
|
||||
Returns true if ANY plugin blocked the connection.
|
||||
"""
|
||||
var blocked: bool = false
|
||||
for plugin in _load_order:
|
||||
if plugin.on_player_connect(peer_id, name, steam_id):
|
||||
blocked = true
|
||||
return blocked
|
||||
|
||||
|
||||
func dispatch_player_disconnect(peer_id: int, reason: String) -> void:
|
||||
"""Notify plugins a player has disconnected."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_disconnect(peer_id, reason)
|
||||
|
||||
|
||||
func dispatch_player_say(peer_id: int, message: String) -> bool:
|
||||
"""Ask plugins if a chat message should be blocked.
|
||||
|
||||
Returns true if ANY plugin blocked the message.
|
||||
"""
|
||||
var blocked: bool = false
|
||||
for plugin in _load_order:
|
||||
if plugin.on_player_say(peer_id, message):
|
||||
blocked = true
|
||||
return blocked
|
||||
|
||||
|
||||
func dispatch_player_spawn(peer_id: int) -> void:
|
||||
"""Notify plugins a player has spawned."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_spawn(peer_id)
|
||||
|
||||
|
||||
func dispatch_player_killed(victim_id: int, killer_id: int, weapon: String) -> void:
|
||||
"""Notify plugins a player has been killed."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_player_killed(victim_id, killer_id, weapon)
|
||||
|
||||
|
||||
func dispatch_round_start(round_number: int) -> void:
|
||||
"""Notify plugins a new round has started."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_round_start(round_number)
|
||||
|
||||
|
||||
func dispatch_round_end(winner_team: int, reason: String) -> void:
|
||||
"""Notify plugins the current round has ended."""
|
||||
for plugin in _load_order:
|
||||
plugin.on_round_end(winner_team, reason)
|
||||
|
||||
|
||||
func dispatch_tick(delta: float) -> void:
|
||||
"""Dispatch per-tick hook. Call from _physics_process.
|
||||
|
||||
This is potentially expensive with many plugins. Consider using
|
||||
a pooling/tick budget approach if you have >20 plugins.
|
||||
"""
|
||||
for plugin in _load_order:
|
||||
plugin.on_tick(delta)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func _resolve_plugin_dir() -> String:
|
||||
"""Resolve the plugin directory path, handling res:// and user:// prefixes."""
|
||||
var path = sv_plugin_dir
|
||||
|
||||
if path.begins_with("res://"):
|
||||
# Convert to absolute filesystem path (DirAccess needs absolute paths)
|
||||
return ProjectSettings.globalize_path(path)
|
||||
elif path.begins_with("user://"):
|
||||
return ProjectSettings.globalize_path(path)
|
||||
else:
|
||||
# Assume relative — prepend user://
|
||||
path = "user://" + path.trim_prefix("/")
|
||||
return ProjectSettings.globalize_path(path)
|
||||
|
||||
|
||||
func _check_version_compatibility(manifest: PluginManifest) -> bool:
|
||||
"""Check if this server version satisfies the plugin's version requirements."""
|
||||
# Get server version
|
||||
var server_version = ProjectSettings.get_setting("application/config/version", "0.0.0")
|
||||
|
||||
if not manifest.min_server_version.is_empty():
|
||||
if not _version_gte(server_version, manifest.min_server_version):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' requires server >= " + manifest.min_server_version + \
|
||||
" (server: " + server_version + ")")
|
||||
return false
|
||||
|
||||
if not manifest.max_server_version.is_empty():
|
||||
if not _version_lte(server_version, manifest.max_server_version):
|
||||
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
||||
"' requires server <= " + manifest.max_server_version + \
|
||||
" (server: " + server_version + ")")
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
static func _version_gte(version: String, min_version: String) -> bool:
|
||||
"""Simple semver comparison: version >= min_version."""
|
||||
var v_parts = version.split(".")
|
||||
var m_parts = min_version.split(".")
|
||||
|
||||
for i in range(max(v_parts.size(), m_parts.size())):
|
||||
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
||||
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
||||
if v > m:
|
||||
return true
|
||||
if v < m:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _version_lte(version: String, max_version: String) -> bool:
|
||||
"""Simple semver comparison: version <= max_version."""
|
||||
var v_parts = version.split(".")
|
||||
var m_parts = max_version.split(".")
|
||||
|
||||
for i in range(max(v_parts.size(), m_parts.size())):
|
||||
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
||||
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
||||
if v < m:
|
||||
return true
|
||||
if v > m:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt7le1vq5wvl4
|
||||
@@ -0,0 +1,694 @@
|
||||
## RoundManager — Server-authoritative round state machine.
|
||||
##
|
||||
## Controls round lifecycle: INACTIVE → WAITING_FOR_PLAYERS → WARMUP → LIVE
|
||||
## → POST_ROUND → (next round: WARMUP) or MATCH_END → INACTIVE.
|
||||
##
|
||||
## Win condition (elimination):
|
||||
## The round ends when only one team has alive players.
|
||||
## If the round timer expires, the team with more alive players wins.
|
||||
## A draw occurs if both teams are eliminated simultaneously or have equal
|
||||
## alive count at time limit.
|
||||
##
|
||||
## Match scoring:
|
||||
## Best-of-N configured by ServerConfig.win_limit.
|
||||
## First team to reach win_limit rounds wins the match.
|
||||
##
|
||||
## Respawn:
|
||||
## Elimination mode: no mid-round respawns. All players respawn at round start.
|
||||
## (Future: TDM/deathmatch modes may use respawn_time_seconds config.)
|
||||
##
|
||||
## Spectate:
|
||||
## Dead players are moved to spectator mode. They can watch alive teammates
|
||||
## via the spectate system. Spectating state is tracked per-player.
|
||||
##
|
||||
## Integration with PluginManager:
|
||||
## RoundManager emits signals that PluginManager dispatches to all loaded
|
||||
## plugins via their round hooks (on_round_start, on_round_end, etc.).
|
||||
##
|
||||
## =============================================================================
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when the round state machine transitions to a new state.
|
||||
## state: the new RoundState enum value.
|
||||
signal round_state_changed(state: int)
|
||||
|
||||
## Emitted at the start of each round.
|
||||
## round_number: 1-based round number.
|
||||
## warmup_time: warmup duration in seconds.
|
||||
signal round_started(round_number: int)
|
||||
|
||||
## Emitted when the round ends.
|
||||
## winner_team: 0=draw, 1=TeamA, 2=TeamB.
|
||||
## reason: string describing the end condition.
|
||||
signal round_ended(winner_team: int, reason: String)
|
||||
|
||||
## Emitted when a player dies.
|
||||
## victim_id: peer id of the killed player.
|
||||
## killer_id: peer id of the killer (may equal victim_id for suicide).
|
||||
## weapon: weapon name string.
|
||||
signal player_died(victim_id: int, killer_id: int, weapon: String)
|
||||
|
||||
## Emitted when a player respawns at round start.
|
||||
signal player_respawned(peer_id: int)
|
||||
|
||||
## Emitted when a player enters spectator mode.
|
||||
signal player_spectating(peer_id: int)
|
||||
|
||||
## Emitted when a player exits spectator mode (respawned).
|
||||
signal player_unspectated(peer_id: int)
|
||||
|
||||
## Emitted when the match ends.
|
||||
## winner_team: 0=draw, 1=TeamA, 2=TeamB.
|
||||
signal match_ended(winner_team: int)
|
||||
|
||||
## Emitted when team scores change.
|
||||
signal score_changed(team_a_score: int, team_b_score: int)
|
||||
|
||||
## Emitted when the spectate target for a player changes.
|
||||
signal spectate_target_changed(viewer_id: int, target_id: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# netfox Self-RPC methods — called via rpc() from server, execute on all peers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Replicate round state transition to all peers.
|
||||
## Self-RPC pattern: server calls .rpc(), executes locally (call_local) and on clients.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_state(state: int) -> void:
|
||||
round_state = state
|
||||
round_state_changed.emit(state)
|
||||
|
||||
## Replicate round start to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_started(round_num: int, time_seconds: int) -> void:
|
||||
round_number = round_num
|
||||
round_time_seconds = time_seconds
|
||||
round_started.emit(round_num)
|
||||
|
||||
## Replicate round end to all peers. Includes score snapshot for client UI.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_round_ended(winner_team: int, reason: String, a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
round_ended.emit(winner_team, reason)
|
||||
|
||||
## Replicate match end to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_match_ended(winner_team: int, a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
match_ended.emit(winner_team)
|
||||
|
||||
## Replicate score change to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_score_changed(a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
score_changed.emit(team_a_score, team_b_score)
|
||||
|
||||
## Replicate player death to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_player_died(victim_id: int, killer_id: int, weapon: String) -> void:
|
||||
player_died.emit(victim_id, killer_id, weapon)
|
||||
|
||||
## Replicate player respawn to all peers.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _rpc_player_respawned(peer_id: int) -> void:
|
||||
player_respawned.emit(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round state enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum RoundState {
|
||||
INACTIVE = 0, # Server starting up, no game running
|
||||
WAITING_FOR_PLAYERS = 1, # Not enough players to start
|
||||
WARMUP = 2, # Pre-round warmup period
|
||||
LIVE = 3, # Round is active, players fighting
|
||||
POST_ROUND = 4, # Brief result display period
|
||||
MATCH_END = 5, # A team reached win_limit
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Default post-round delay before next round starts (seconds).
|
||||
const DEFAULT_POST_ROUND_DELAY: float = 5.0
|
||||
|
||||
## Minimum players per team required to start a round.
|
||||
const MIN_PLAYERS_PER_TEAM: int = 1
|
||||
|
||||
## Minimum round number before match-end check activates.
|
||||
const MIN_ROUNDS_FOR_MATCH_END: int = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var round_state: int = RoundState.INACTIVE
|
||||
|
||||
## Current round number (1-based, increments each round).
|
||||
var round_number: int = 0
|
||||
|
||||
## Team A score (rounds won).
|
||||
var team_a_score: int = 0
|
||||
## Team B score (rounds won).
|
||||
var team_b_score: int = 0
|
||||
|
||||
## Dictionary of peer_id → player state dictionary.
|
||||
## Each entry: { "team": int (0=A, 1=B), "alive": bool, "spectating": bool,
|
||||
## "spectate_target": int (peer_id or 0), "spawn_pos": Vector3 }
|
||||
var players: Dictionary = {}
|
||||
|
||||
## Sequence of spawn positions for team A (cycled round-robin).
|
||||
var spawn_points_a: Array[Vector3] = []
|
||||
## Sequence of spawn positions for team B (cycled round-robin).
|
||||
var spawn_points_b: Array[Vector3] = []
|
||||
|
||||
## Internal timers
|
||||
var _warmup_timer: float = 0.0
|
||||
var _round_timer: float = 0.0
|
||||
var _post_round_timer: float = 0.0
|
||||
var _elapsed_seconds: float = 0.0
|
||||
|
||||
## Config values cached from ServerConfig at init.
|
||||
var round_time_seconds: int = 600
|
||||
var _warmup_time_seconds: int = 60
|
||||
var _win_limit: int = 3
|
||||
var _respawn_time_seconds: float = 5.0
|
||||
var _spectate_enabled: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_cache_config()
|
||||
print("[RoundManager] Initialised. Win limit: %d, Round time: %ds, Warmup: %ds" % [
|
||||
_win_limit, round_time_seconds, _warmup_time_seconds
|
||||
])
|
||||
|
||||
func _exit_tree() -> void:
|
||||
stop()
|
||||
|
||||
## Cache config values from ServerConfig (if available).
|
||||
func _cache_config() -> void:
|
||||
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
|
||||
round_time_seconds = ServerConfig.round_time_seconds
|
||||
_warmup_time_seconds = ServerConfig.warmup_time_seconds
|
||||
_win_limit = ServerConfig.win_limit
|
||||
_respawn_time_seconds = ServerConfig.respawn_time_seconds
|
||||
_spectate_enabled = ServerConfig.spectate_enabled
|
||||
else:
|
||||
push_warning("[RoundManager] ServerConfig not available — using defaults")
|
||||
|
||||
## Start the round manager. Must be called after spawn points are set.
|
||||
func start() -> void:
|
||||
_reset_all_state()
|
||||
_set_state(RoundState.WAITING_FOR_PLAYERS)
|
||||
print("[RoundManager] Started — waiting for players")
|
||||
|
||||
## Stop the round manager and reset all state.
|
||||
func stop() -> void:
|
||||
_set_state(RoundState.INACTIVE)
|
||||
_reset_all_state()
|
||||
print("[RoundManager] Stopped")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Register a player with the round manager.
|
||||
## team: 0 = Team A, 1 = Team B.
|
||||
func register_player(peer_id: int, team: int) -> void:
|
||||
if peer_id in players:
|
||||
push_warning("[RoundManager] Player %d already registered, updating team" % peer_id)
|
||||
|
||||
var spawn_pos: Vector3 = _get_spawn_position(peer_id, team)
|
||||
players[peer_id] = {
|
||||
"team": team,
|
||||
"alive": true,
|
||||
"spectating": false,
|
||||
"spectate_target": 0,
|
||||
"spawn_pos": spawn_pos,
|
||||
}
|
||||
print("[RoundManager] Registered player %d on team %s at (%.1f, %.1f)" % [
|
||||
peer_id, "A" if team == 0 else "B", spawn_pos.x, spawn_pos.z
|
||||
])
|
||||
|
||||
_check_start_condition()
|
||||
|
||||
## Unregister a player (on disconnect).
|
||||
func unregister_player(peer_id: int) -> void:
|
||||
if peer_id in players:
|
||||
var was_alive: bool = players[peer_id]["alive"]
|
||||
players.erase(peer_id)
|
||||
print("[RoundManager] Unregistered player %d" % peer_id)
|
||||
|
||||
# If the round is LIVE and a team just lost all members, check win
|
||||
if round_state == RoundState.LIVE and was_alive:
|
||||
if _check_elimination():
|
||||
return # round ended, no further action
|
||||
else:
|
||||
push_warning("[RoundManager] Cannot unregister unknown player %d" % peer_id)
|
||||
|
||||
## Set spawn points from the map.
|
||||
func set_spawn_points(spawns_a: Array[Vector3], spawns_b: Array[Vector3]) -> void:
|
||||
spawn_points_a = spawns_a.duplicate()
|
||||
spawn_points_b = spawns_b.duplicate()
|
||||
print("[RoundManager] Spawn points set: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
||||
|
||||
## Get the spawn position for a player on a given team.
|
||||
func _get_spawn_position(peer_id: int, team: int) -> Vector3:
|
||||
var pool: Array[Vector3] = spawn_points_a if team == 0 else spawn_points_b
|
||||
if pool.size() == 0:
|
||||
return Vector3.ZERO
|
||||
# Round-robin assignment based on peer_id for distribution
|
||||
var idx: int = peer_id % pool.size()
|
||||
var pos: Vector3 = pool[idx]
|
||||
pos.y = 0.0
|
||||
return pos
|
||||
|
||||
## Return the number of alive players on a given team.
|
||||
func get_alive_count(team: int) -> int:
|
||||
var count: int = 0
|
||||
for p in players.values():
|
||||
if p["team"] == team and p["alive"]:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
## Return the total number of players registered.
|
||||
func get_player_count() -> int:
|
||||
return players.size()
|
||||
|
||||
## Return the number of players on a given team.
|
||||
func get_team_player_count(team: int) -> int:
|
||||
var count: int = 0
|
||||
for p in players.values():
|
||||
if p["team"] == team:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
## Return the team of a player (0=A, 1=B), or -1 if not registered.
|
||||
func get_player_team(peer_id: int) -> int:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["team"]
|
||||
return -1
|
||||
|
||||
## Return whether a player is alive.
|
||||
func is_player_alive(peer_id: int) -> bool:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["alive"]
|
||||
return false
|
||||
|
||||
## Return whether a player is spectating.
|
||||
func is_player_spectating(peer_id: int) -> bool:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spectating"]
|
||||
return false
|
||||
|
||||
## Return a list of alive peer IDs on a given team.
|
||||
func get_alive_players_on_team(team: int) -> Array[int]:
|
||||
var result: Array[int] = []
|
||||
for pid in players:
|
||||
if players[pid]["team"] == team and players[pid]["alive"]:
|
||||
result.append(pid)
|
||||
return result
|
||||
|
||||
## Return the spectate target for a player.
|
||||
func get_spectate_target(peer_id: int) -> int:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spectate_target"]
|
||||
return 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Death / Elimination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when a player dies.
|
||||
## This is the main elimination detection entry point.
|
||||
## Returns true if the round ended as a result.
|
||||
func on_player_death(victim_id: int, killer_id: int, weapon: String) -> bool:
|
||||
if not victim_id in players:
|
||||
push_warning("[RoundManager] Death event for unknown player %d" % victim_id)
|
||||
return false
|
||||
|
||||
var victim_team: int = players[victim_id]["team"]
|
||||
players[victim_id]["alive"] = false
|
||||
|
||||
print("[RoundManager] Player %d (team %s) killed by %s with '%s'" % [
|
||||
victim_id, "A" if victim_team == 0 else "B",
|
||||
"world" if killer_id == 0 else str(killer_id),
|
||||
weapon
|
||||
])
|
||||
|
||||
# Self-RPC death notification (call_local ensures server signal too)
|
||||
_rpc_player_died.rpc(victim_id, killer_id, weapon)
|
||||
|
||||
# If spectate enabled, put the player in spectator mode
|
||||
if _spectate_enabled:
|
||||
_start_spectating(victim_id)
|
||||
|
||||
# Check elimination — only when round is LIVE
|
||||
if round_state == RoundState.LIVE:
|
||||
return _check_elimination()
|
||||
|
||||
return false
|
||||
|
||||
## Check if a team has been eliminated. Ends the round if so.
|
||||
## Returns true if the round ended.
|
||||
func _check_elimination() -> bool:
|
||||
var alive_a: int = get_alive_count(0)
|
||||
var alive_b: int = get_alive_count(1)
|
||||
|
||||
# Both teams alive — no win yet
|
||||
if alive_a > 0 and alive_b > 0:
|
||||
return false
|
||||
|
||||
# Eliminate the possibility of "nobody alive" edge case
|
||||
if alive_a == 0 and alive_b == 0:
|
||||
_end_round(0, "all_dead_draw")
|
||||
return true
|
||||
|
||||
# One team is eliminated
|
||||
if alive_a == 0 and alive_b > 0:
|
||||
_end_round(2, "elimination") # Team B wins
|
||||
return true
|
||||
|
||||
if alive_b == 0 and alive_a > 0:
|
||||
_end_round(1, "elimination") # Team A wins
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spectate system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Start spectating for a dead player. Finds the best alive teammate to watch.
|
||||
func _start_spectating(viewer_id: int) -> void:
|
||||
if not _spectate_enabled:
|
||||
return
|
||||
if not viewer_id in players:
|
||||
return
|
||||
|
||||
var viewer_team: int = players[viewer_id]["team"]
|
||||
var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team)
|
||||
|
||||
players[viewer_id]["spectating"] = true
|
||||
|
||||
if alive_teammates.size() > 0:
|
||||
# Target the first alive teammate (simple cycling later via RCON/UI)
|
||||
var target_id: int = alive_teammates[0]
|
||||
players[viewer_id]["spectate_target"] = target_id
|
||||
spectate_target_changed.emit(viewer_id, target_id)
|
||||
print("[RoundManager] Player %d now spectating %d" % [viewer_id, target_id])
|
||||
else:
|
||||
# No alive teammates — spectate the map / overview
|
||||
players[viewer_id]["spectate_target"] = 0
|
||||
spectate_target_changed.emit(viewer_id, 0)
|
||||
print("[RoundManager] Player %d spectating (no alive teammates)" % viewer_id)
|
||||
|
||||
player_spectating.emit(viewer_id)
|
||||
|
||||
## Cycle to the next spectate target for a player.
|
||||
func cycle_spectate_target(viewer_id: int) -> void:
|
||||
if not viewer_id in players or not players[viewer_id]["spectating"]:
|
||||
return
|
||||
|
||||
var viewer_team: int = players[viewer_id]["team"]
|
||||
var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team)
|
||||
|
||||
if alive_teammates.size() == 0:
|
||||
players[viewer_id]["spectate_target"] = 0
|
||||
spectate_target_changed.emit(viewer_id, 0)
|
||||
return
|
||||
|
||||
# Find current target index and advance
|
||||
var current_target: int = players[viewer_id]["spectate_target"]
|
||||
var idx: int = alive_teammates.find(current_target)
|
||||
var next_idx: int = (idx + 1) % alive_teammates.size() if idx >= 0 else 0
|
||||
var new_target: int = alive_teammates[next_idx]
|
||||
|
||||
players[viewer_id]["spectate_target"] = new_target
|
||||
spectate_target_changed.emit(viewer_id, new_target)
|
||||
print("[RoundManager] Player %d now spectating %d (cycled)" % [viewer_id, new_target])
|
||||
|
||||
## Stop spectating for a player (on respawn).
|
||||
func _stop_spectating(peer_id: int) -> void:
|
||||
if peer_id in players:
|
||||
players[peer_id]["spectating"] = false
|
||||
players[peer_id]["spectate_target"] = 0
|
||||
player_unspectated.emit(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Respawn system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Respawn all dead players on both teams at round start.
|
||||
## Sets their position to their assigned spawn point and marks them alive.
|
||||
func respawn_all_players() -> void:
|
||||
for pid in players:
|
||||
var state = players[pid]
|
||||
state["alive"] = true
|
||||
state["spawn_pos"] = _get_spawn_position(pid, state["team"])
|
||||
|
||||
if state["spectating"]:
|
||||
_stop_spectating(pid)
|
||||
|
||||
_rpc_player_respawned.rpc(pid)
|
||||
|
||||
print("[RoundManager] Respawned all %d players for round %d" % [players.size(), round_number])
|
||||
|
||||
## Get the spawn position for a player (for the current round).
|
||||
func get_player_spawn(peer_id: int) -> Vector3:
|
||||
if peer_id in players:
|
||||
return players[peer_id]["spawn_pos"]
|
||||
return Vector3.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Check if we have enough players to start. Transitions from WAITING to WARMUP.
|
||||
func _check_start_condition() -> void:
|
||||
if round_state != RoundState.WAITING_FOR_PLAYERS:
|
||||
return
|
||||
|
||||
var team_a_count: int = get_team_player_count(0)
|
||||
var team_b_count: int = get_team_player_count(1)
|
||||
|
||||
if team_a_count >= MIN_PLAYERS_PER_TEAM and team_b_count >= MIN_PLAYERS_PER_TEAM:
|
||||
_begin_warmup()
|
||||
else:
|
||||
print("[RoundManager] Waiting for players: A=%d, B=%d (need %d each)" % [
|
||||
team_a_count, team_b_count, MIN_PLAYERS_PER_TEAM
|
||||
])
|
||||
|
||||
## Begin the warmup phase.
|
||||
func _begin_warmup() -> void:
|
||||
round_number += 1
|
||||
_warmup_timer = float(_warmup_time_seconds)
|
||||
_set_state(RoundState.WARMUP)
|
||||
print("[RoundManager] Warmup started — round %d will begin in %ds" % [round_number, _warmup_time_seconds])
|
||||
|
||||
## Begin the live round.
|
||||
func _begin_round() -> void:
|
||||
# Respawn all players
|
||||
respawn_all_players()
|
||||
|
||||
_round_timer = float(round_time_seconds)
|
||||
_elapsed_seconds = 0.0
|
||||
_set_state(RoundState.LIVE)
|
||||
|
||||
_rpc_round_started.rpc(round_number, round_time_seconds)
|
||||
print("[RoundManager] Round %d started! %ds time limit" % [round_number, round_time_seconds])
|
||||
|
||||
## End the current round with the given winner and reason.
|
||||
func _end_round(winner_team: int, reason: String) -> void:
|
||||
_set_state(RoundState.POST_ROUND)
|
||||
|
||||
# Update score
|
||||
if winner_team == 1:
|
||||
team_a_score += 1
|
||||
elif winner_team == 2:
|
||||
team_b_score += 1
|
||||
|
||||
_post_round_timer = DEFAULT_POST_ROUND_DELAY
|
||||
|
||||
_rpc_round_ended.rpc(winner_team, reason, team_a_score, team_b_score)
|
||||
_rpc_score_changed.rpc(team_a_score, team_b_score)
|
||||
|
||||
var winner_name: String = "Draw"
|
||||
if winner_team == 1: winner_name = "Team A"
|
||||
elif winner_team == 2: winner_name = "Team B"
|
||||
|
||||
print("[RoundManager] Round %d ended: %s wins (%s). Score: A=%d, B=%d" % [
|
||||
round_number, winner_name, reason, team_a_score, team_b_score
|
||||
])
|
||||
|
||||
# Check match end
|
||||
if _check_match_end():
|
||||
return
|
||||
|
||||
# Schedule next round via post-round timer (handled in _process)
|
||||
# Actually we handle this in the state machine below — no need for separate logic
|
||||
|
||||
## Check if a team has reached the win limit. Ends the match if so.
|
||||
## Returns true if match ended.
|
||||
func _check_match_end() -> bool:
|
||||
if _win_limit <= 0:
|
||||
return false # No win limit = infinite play
|
||||
|
||||
if team_a_score >= _win_limit:
|
||||
_end_match(1)
|
||||
return true
|
||||
elif team_b_score >= _win_limit:
|
||||
_end_match(2)
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
## End the match with the given winning team.
|
||||
func _end_match(winner_team: int) -> void:
|
||||
_set_state(RoundState.MATCH_END)
|
||||
_rpc_match_ended.rpc(winner_team, team_a_score, team_b_score)
|
||||
|
||||
var winner: String = "Team A" if winner_team == 1 else "Team B"
|
||||
print("[RoundManager] MATCH OVER! %s wins %d-%d" % [winner, team_a_score, team_b_score])
|
||||
|
||||
## Reset all per-match state (keeps the round manager alive for a new match).
|
||||
func reset_for_new_match() -> void:
|
||||
_reset_all_state()
|
||||
_set_state(RoundState.WAITING_FOR_PLAYERS)
|
||||
print("[RoundManager] Reset for new match")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-frame tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
match round_state:
|
||||
RoundState.WARMUP:
|
||||
_warmup_timer -= delta
|
||||
if _warmup_timer <= 0.0:
|
||||
_warmup_timer = 0.0
|
||||
_begin_round()
|
||||
|
||||
RoundState.LIVE:
|
||||
_elapsed_seconds += delta
|
||||
_round_timer -= delta
|
||||
|
||||
# Time limit reached — end round
|
||||
if _round_timer <= 0.0 and round_time_seconds > 0:
|
||||
_round_timer = 0.0
|
||||
_handle_round_time_expiry()
|
||||
|
||||
RoundState.POST_ROUND:
|
||||
_post_round_timer -= delta
|
||||
if _post_round_timer <= 0.0:
|
||||
_post_round_timer = 0.0
|
||||
# Check if match ended (already checked in _end_round, but safeguard)
|
||||
if _check_match_end():
|
||||
return
|
||||
# Start next round
|
||||
_begin_warmup()
|
||||
|
||||
## Handle round time expiry — determine winner based on alive count.
|
||||
func _handle_round_time_expiry() -> void:
|
||||
var alive_a: int = get_alive_count(0)
|
||||
var alive_b: int = get_alive_count(1)
|
||||
|
||||
if alive_a > alive_b:
|
||||
_end_round(1, "time_limit")
|
||||
elif alive_b > alive_a:
|
||||
_end_round(2, "time_limit")
|
||||
else:
|
||||
# Equal alive count or both zero — stalemate
|
||||
_end_round(0, "time_limit_draw")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the round state and emit the change signal.
|
||||
func _set_state(new_state: int) -> void:
|
||||
if round_state != new_state:
|
||||
_rpc_round_state.rpc(new_state)
|
||||
|
||||
## Reset all per-round state (scores, timers, player states).
|
||||
func _reset_all_state() -> void:
|
||||
round_number = 0
|
||||
team_a_score = 0
|
||||
team_b_score = 0
|
||||
_warmup_timer = 0.0
|
||||
_round_timer = 0.0
|
||||
_post_round_timer = 0.0
|
||||
_elapsed_seconds = 0.0
|
||||
|
||||
# Reset player states (but keep registrations)
|
||||
for pid in players:
|
||||
var state = players[pid]
|
||||
state["alive"] = false
|
||||
state["spectating"] = false
|
||||
state["spectate_target"] = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public query API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Get the current round time remaining in seconds (0 if not LIVE).
|
||||
func get_time_remaining() -> float:
|
||||
if round_state == RoundState.LIVE:
|
||||
return max(_round_timer, 0.0)
|
||||
return 0.0
|
||||
|
||||
## Get the elapsed round time in seconds.
|
||||
func get_elapsed_time() -> float:
|
||||
return _elapsed_seconds
|
||||
|
||||
## Get the formatted score string.
|
||||
func get_score_string() -> String:
|
||||
return "%d - %d" % [team_a_score, team_b_score]
|
||||
|
||||
## Return a dictionary of current round state for network broadcast / RCON status.
|
||||
func get_round_state_snapshot() -> Dictionary:
|
||||
var state_name: String
|
||||
match round_state:
|
||||
RoundState.INACTIVE: state_name = "inactive"
|
||||
RoundState.WAITING_FOR_PLAYERS: state_name = "waiting"
|
||||
RoundState.WARMUP: state_name = "warmup"
|
||||
RoundState.LIVE: state_name = "live"
|
||||
RoundState.POST_ROUND: state_name = "post_round"
|
||||
RoundState.MATCH_END: state_name = "match_end"
|
||||
_: state_name = "unknown"
|
||||
|
||||
return {
|
||||
"state": state_name,
|
||||
"round_number": round_number,
|
||||
"team_a_score": team_a_score,
|
||||
"team_b_score": team_b_score,
|
||||
"win_limit": _win_limit,
|
||||
"time_remaining": get_time_remaining(),
|
||||
"elapsed": _elapsed_seconds,
|
||||
"alive_a": get_alive_count(0),
|
||||
"alive_b": get_alive_count(1),
|
||||
"total_players": get_player_count(),
|
||||
}
|
||||
|
||||
## Return the current state as a human-readable string.
|
||||
func get_state_name() -> String:
|
||||
match round_state:
|
||||
RoundState.INACTIVE: return "inactive"
|
||||
RoundState.WAITING_FOR_PLAYERS: return "waiting_for_players"
|
||||
RoundState.WARMUP: return "warmup"
|
||||
RoundState.LIVE: return "live"
|
||||
RoundState.POST_ROUND: return "post_round"
|
||||
RoundState.MATCH_END: return "match_end"
|
||||
_: return "unknown"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjs167lb51k1q
|
||||
@@ -0,0 +1,213 @@
|
||||
extends Node
|
||||
|
||||
# =============================================================================
|
||||
# Server Browser — Heartbeat Sender
|
||||
# =============================================================================
|
||||
# Periodically POSTs server state to the configured master server so the
|
||||
# server appears in the public server browser.
|
||||
#
|
||||
# Relies on cvars defined by the config system:
|
||||
# sv_master_server — Master server URL (e.g. "http://master.example.com:28961")
|
||||
# sv_server_id — Unique server identifier (auto-generated if empty)
|
||||
# sv_heartbeat_interval — Seconds between heartbeats (default 60, range [10,300])
|
||||
# sv_server_tags — Comma-separated tags for filtering
|
||||
# sv_server_password — Whether the server requires a password
|
||||
#
|
||||
# Signals:
|
||||
# heartbeat_sent(success: bool, server_id: String, http_code: int)
|
||||
# master_connection_failed(error_message: String)
|
||||
# =============================================================================
|
||||
|
||||
signal heartbeat_sent(success: bool, server_id: String, http_code: int)
|
||||
signal master_connection_failed(error_message: String)
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL: float = 60.0
|
||||
const MIN_INTERVAL: float = 10.0
|
||||
const MAX_INTERVAL: float = 300.0
|
||||
const DEFAULT_MASTER_SERVER: String = "http://127.0.0.1:28961"
|
||||
|
||||
# HTTP request node for async requests
|
||||
var _http: HTTPRequest
|
||||
var _timer: Timer
|
||||
var _server_id: String = ""
|
||||
var _enabled: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Create HTTP request node
|
||||
_http = HTTPRequest.new()
|
||||
_http.name = "MasterServerHTTP"
|
||||
_http.timeout = 10
|
||||
_http.use_threads = true
|
||||
add_child(_http)
|
||||
_http.request_completed.connect(_on_heartbeat_completed)
|
||||
|
||||
# Create timer for periodic heartbeats
|
||||
_timer = Timer.new()
|
||||
_timer.name = "HeartbeatTimer"
|
||||
_timer.one_shot = false
|
||||
add_child(_timer)
|
||||
_timer.timeout.connect(_send_heartbeat)
|
||||
|
||||
|
||||
func start() -> void:
|
||||
"""Begin the heartbeat loop. Call after cvars are loaded and server is ready."""
|
||||
var master_url = _get_cvar_string("sv_master_server", "")
|
||||
if master_url.is_empty():
|
||||
push_warning("[ServerBrowser] sv_master_server is empty — heartbeat disabled")
|
||||
return
|
||||
|
||||
# Strip trailing slash
|
||||
master_url = master_url.trim_suffix("/")
|
||||
if not master_url.begins_with("http"):
|
||||
master_url = "http://" + master_url
|
||||
|
||||
if not master_url.begins_with("http://") and not master_url.begins_with("https://"):
|
||||
push_warning("[ServerBrowser] Invalid master server URL: ", master_url)
|
||||
return
|
||||
|
||||
_server_id = _get_cvar_string("sv_server_id", "")
|
||||
_enabled = true
|
||||
|
||||
var interval = _get_cvar_float("sv_heartbeat_interval", DEFAULT_HEARTBEAT_INTERVAL)
|
||||
interval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL)
|
||||
|
||||
_timer.wait_time = interval
|
||||
_timer.start()
|
||||
|
||||
print("[ServerBrowser] Heartbeat enabled — master: ", master_url, " interval: ", interval, "s")
|
||||
|
||||
# Send initial heartbeat immediately
|
||||
_send_heartbeat()
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
"""Stop sending heartbeats."""
|
||||
_enabled = false
|
||||
_timer.stop()
|
||||
print("[ServerBrowser] Heartbeat disabled")
|
||||
|
||||
|
||||
func set_server_id(new_id: String) -> void:
|
||||
"""Update the server id (e.g., after first heartbeat response)."""
|
||||
_server_id = new_id
|
||||
|
||||
|
||||
func _get_master_url() -> String:
|
||||
var raw = _get_cvar_string("sv_master_server", DEFAULT_MASTER_SERVER).trim_suffix("/")
|
||||
if raw.is_empty():
|
||||
return ""
|
||||
if not raw.begins_with("http"):
|
||||
raw = "http://" + raw
|
||||
return raw
|
||||
|
||||
|
||||
func _send_heartbeat() -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
|
||||
var master_url = _get_master_url()
|
||||
if master_url.is_empty():
|
||||
return
|
||||
|
||||
# Build heartbeat payload
|
||||
var payload = {
|
||||
"server_id": _server_id,
|
||||
"name": _get_cvar_string("sv_server_name", "Tactical Shooter Server"),
|
||||
"map": _get_cvar_string("sv_map", "unknown"),
|
||||
"players": _get_player_count(),
|
||||
"max_players": _get_cvar_int("sv_max_players", 16),
|
||||
"game_mode": _get_cvar_string("sv_game_mode", "deathmatch"),
|
||||
"version": _get_cvar_string("sv_version", "1.0.0"),
|
||||
"tags": _get_cvar_string("sv_server_tags", ""),
|
||||
"password": _get_cvar_int("sv_server_password", 0) != 0,
|
||||
"host": _get_external_ip(),
|
||||
"port": _get_cvar_int("sv_port", 0),
|
||||
"steam_id": _get_cvar_string("sv_steam_id", ""),
|
||||
}
|
||||
|
||||
var json_string = JSON.stringify(payload)
|
||||
var headers = ["Content-Type: application/json"]
|
||||
|
||||
var endpoint = master_url + "/api/v1/heartbeat"
|
||||
var err = _http.request(endpoint, headers, HTTPClient.METHOD_POST, json_string)
|
||||
if err != OK:
|
||||
push_error("[ServerBrowser] Failed to send heartbeat: HTTP request error ", err)
|
||||
master_connection_failed.emit("HTTP request failed (code " + str(err) + ")")
|
||||
|
||||
|
||||
func _on_heartbeat_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var msg = "Heartbeat failed — network error " + str(result)
|
||||
push_warning("[ServerBrowser] ", msg)
|
||||
master_connection_failed.emit(msg)
|
||||
heartbeat_sent.emit(false, _server_id, response_code)
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
var msg = "Heartbeat failed — HTTP " + str(response_code)
|
||||
push_warning("[ServerBrowser] ", msg)
|
||||
master_connection_failed.emit(msg)
|
||||
heartbeat_sent.emit(false, _server_id, response_code)
|
||||
return
|
||||
|
||||
# Parse response for server_id (first heartbeat may get one assigned)
|
||||
var body_text = body.get_string_from_utf8()
|
||||
var json = JSON.new()
|
||||
var parse_err = json.parse(body_text)
|
||||
if parse_err == OK and json.data is Dictionary:
|
||||
if json.data.has("server_id") and not json.data["server_id"].is_empty():
|
||||
_server_id = json.data["server_id"]
|
||||
|
||||
heartbeat_sent.emit(true, _server_id, response_code)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helper: read cvars from the config system (if available)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func _get_cvar_string(name: String, default_value: String) -> String:
|
||||
"""Try the CvarRegistry autoload first, fall back to ProjectSettings."""
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_string"):
|
||||
return reg.get_string(name, default_value)
|
||||
# Fallback to ProjectSettings if no cvar registry exists
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_cvar_int(name: String, default_value: int) -> int:
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_int"):
|
||||
return reg.get_int(name, default_value)
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_cvar_float(name: String, default_value: float) -> float:
|
||||
if Engine.has_singleton("CvarRegistry"):
|
||||
var reg = Engine.get_singleton("CvarRegistry")
|
||||
if reg.has_method("get_float"):
|
||||
return reg.get_float(name, default_value)
|
||||
if ProjectSettings.has_setting(name):
|
||||
return ProjectSettings.get_setting(name)
|
||||
return default_value
|
||||
|
||||
|
||||
func _get_player_count() -> int:
|
||||
"""Get current connected player count."""
|
||||
# Try to read from game's player manager singleton
|
||||
if Engine.has_singleton("PlayerManager"):
|
||||
var pm = Engine.get_singleton("PlayerManager")
|
||||
if pm.has_method("get_player_count"):
|
||||
return pm.get_player_count()
|
||||
return 0
|
||||
|
||||
|
||||
func _get_external_ip() -> String:
|
||||
"""Return our public/external IP if known, or empty for auto-detect."""
|
||||
return _get_cvar_string("sv_external_ip", "")
|
||||
@@ -0,0 +1 @@
|
||||
uid://pisglwxvlp6c
|
||||
Binary file not shown.
@@ -0,0 +1,598 @@
|
||||
"""
|
||||
tactical-shooter — Master Server API
|
||||
======================================
|
||||
Standalone REST service that receives heartbeat pings from game servers
|
||||
and serves the server list to client browsers.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/heartbeat — Register/refresh a game server
|
||||
GET /api/v1/servers — List all active servers
|
||||
GET /api/v1/servers/:id — Get a single server's details
|
||||
GET /api/v1/status — Health check + stats
|
||||
|
||||
Data: SQLite with TTL-based expiry (servers older than heartbeat_ttl are pruned).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parent / "master_config.json"
|
||||
|
||||
with open(CONFIG_PATH) as f:
|
||||
_cfg = json.load(f)
|
||||
|
||||
HOST: str = _cfg.get("host", "0.0.0.0")
|
||||
PORT: int = _cfg.get("port", 28961)
|
||||
DB_PATH: str = _cfg.get("database", "master_server.db")
|
||||
HEARTBEAT_TTL: int = _cfg.get("heartbeat_ttl_seconds", 180)
|
||||
MAX_SERVERS: int = _cfg.get("max_servers", 1000)
|
||||
RATE_LIMIT: int = _cfg.get("rate_limit_per_ip", 30)
|
||||
RATE_LIMIT_WINDOW: int = _cfg.get("rate_limit_window_seconds", 60)
|
||||
|
||||
DB_PATH = os.environ.get("MASTER_DB_PATH", DB_PATH)
|
||||
|
||||
log = logging.getLogger("master_server")
|
||||
log.setLevel(_cfg.get("log_level", "info").upper())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HeartbeatPayload(BaseModel):
|
||||
"""Payload sent by game servers via POST /heartbeat."""
|
||||
|
||||
server_id: str | None = None # auto-generated if missing
|
||||
name: str = "Unnamed Server"
|
||||
map: str = "unknown"
|
||||
players: int = 0
|
||||
max_players: int = 16
|
||||
game_mode: str = "deathmatch"
|
||||
version: str = "1.0.0"
|
||||
tags: str = ""
|
||||
password: bool = False
|
||||
host: str = ""
|
||||
port: int = 0
|
||||
steam_id: str = ""
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Public server info returned to clients."""
|
||||
|
||||
server_id: str
|
||||
name: str
|
||||
map: str
|
||||
players: int
|
||||
max_players: int
|
||||
game_mode: str
|
||||
version: str
|
||||
tags: list[str]
|
||||
password: bool
|
||||
host: str
|
||||
port: int
|
||||
steam_id: str
|
||||
last_seen: str # ISO-8601
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
class ServerListResponse(BaseModel):
|
||||
count: int
|
||||
servers: list[ServerInfo]
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
uptime_seconds: int
|
||||
active_servers: int
|
||||
total_heartbeats: int
|
||||
version: str = "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map Registry Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory where uploaded .pck files are stored
|
||||
MAPS_DIR: str = _cfg.get("maps_dir", "workshop_maps")
|
||||
os.makedirs(MAPS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class MapInfo(BaseModel):
|
||||
"""Public map metadata returned to clients."""
|
||||
|
||||
name: str
|
||||
size: int = 0
|
||||
version: int = 1
|
||||
description: str = ""
|
||||
scene: str = ""
|
||||
author: str = ""
|
||||
checksum_sha256: str = ""
|
||||
uploaded_at: str = ""
|
||||
download_url: str = ""
|
||||
|
||||
|
||||
class MapListResponse(BaseModel):
|
||||
count: int
|
||||
maps: list[MapInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
map TEXT NOT NULL DEFAULT 'unknown',
|
||||
players INTEGER NOT NULL DEFAULT 0,
|
||||
max_players INTEGER NOT NULL DEFAULT 16,
|
||||
game_mode TEXT NOT NULL DEFAULT 'deathmatch',
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
password INTEGER NOT NULL DEFAULT 0,
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
steam_id TEXT NOT NULL DEFAULT '',
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
heartbeat_count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
"""
|
||||
CREATE_INDEX_SQL = "CREATE INDEX IF NOT EXISTS idx_servers_last_seen ON servers(last_seen);"
|
||||
|
||||
CREATE_MAPS_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS workshop_maps (
|
||||
name TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scene TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
checksum_sha256 TEXT NOT NULL DEFAULT '',
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_at REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[aiosqlite.Connection]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA synchronous=NORMAL")
|
||||
await db.execute(CREATE_TABLE_SQL)
|
||||
await db.execute(CREATE_INDEX_SQL)
|
||||
await db.commit()
|
||||
yield db
|
||||
|
||||
|
||||
async def _prune_expired(db: aiosqlite.Connection) -> int:
|
||||
"""Remove servers whose heartbeat has expired. Returns count pruned."""
|
||||
cutoff = time.time() - HEARTBEAT_TTL
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("DELETE FROM servers WHERE last_seen < ?", (cutoff,))
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def _row_to_server(row: aiosqlite.Row) -> ServerInfo:
|
||||
now = time.time()
|
||||
tags_list = [t.strip() for t in row["tags"].split(",") if t.strip()] if row["tags"] else []
|
||||
return ServerInfo(
|
||||
server_id=row["server_id"],
|
||||
name=row["name"],
|
||||
map=row["map"],
|
||||
players=row["players"],
|
||||
max_players=row["max_players"],
|
||||
game_mode=row["game_mode"],
|
||||
version=row["version"],
|
||||
tags=tags_list,
|
||||
password=bool(row["password"]),
|
||||
host=row["host"],
|
||||
port=row["port"],
|
||||
steam_id=row["steam_id"],
|
||||
last_seen=datetime.fromtimestamp(row["last_seen"], tz=timezone.utc).isoformat(),
|
||||
uptime_seconds=int(max(0, now - row["first_seen"])),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Startup / shutdown — ensure DB schema exists."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA synchronous=NORMAL")
|
||||
await db.execute(CREATE_TABLE_SQL)
|
||||
await db.execute(CREATE_INDEX_SQL)
|
||||
await db.execute(CREATE_MAPS_SQL)
|
||||
await db.commit()
|
||||
# Ensure maps directory exists
|
||||
os.makedirs(MAPS_DIR, exist_ok=True)
|
||||
log.info("Master server ready on %s:%s", HOST, PORT)
|
||||
log.info("Maps directory: %s (absolute: %s)", MAPS_DIR, os.path.abspath(MAPS_DIR))
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Tactical Shooter Master Server",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Allow cross-origin from any game client / browser
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# In-memory rate limiter: {ip: [timestamps]}
|
||||
_rate_buckets: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(ip: str) -> None:
|
||||
now = time.time()
|
||||
window_start = now - RATE_LIMIT_WINDOW
|
||||
timestamps = _rate_buckets.get(ip, [])
|
||||
# Drop old entries
|
||||
timestamps = [t for t in timestamps if t > window_start]
|
||||
if len(timestamps) >= RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Rate limit exceeded: max {RATE_LIMIT} requests per {RATE_LIMIT_WINDOW}s",
|
||||
)
|
||||
timestamps.append(now)
|
||||
_rate_buckets[ip] = timestamps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/v1/status", response_model=StatusResponse)
|
||||
async def get_status():
|
||||
"""Health check and server stats."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
|
||||
row = await cursor.fetchone()
|
||||
active = row["cnt"] if row else 0
|
||||
cursor = await db.execute("SELECT COALESCE(SUM(heartbeat_count), 0) AS total FROM servers")
|
||||
row = await cursor.fetchone()
|
||||
total_heartbeats = row["total"] if row else 0
|
||||
|
||||
return StatusResponse(
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
active_servers=active,
|
||||
total_heartbeats=total_heartbeats,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/heartbeat")
|
||||
async def heartbeat(payload: HeartbeatPayload, request: Request):
|
||||
"""Register or refresh a game server.
|
||||
|
||||
If server_id is not provided (or empty), one is auto-generated in the
|
||||
response. The server should store the returned id and send it on
|
||||
subsequent heartbeats.
|
||||
"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(ip)
|
||||
|
||||
# Generate id for new servers
|
||||
server_id = payload.server_id or str(uuid.uuid4())
|
||||
now = time.time()
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
# Check if we're at capacity
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
|
||||
row = await cursor.fetchone()
|
||||
count = row["cnt"] if row else 0
|
||||
|
||||
if count >= MAX_SERVERS:
|
||||
# Try to prune expired first
|
||||
await _prune_expired(db)
|
||||
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
|
||||
row = await cursor.fetchone()
|
||||
count = row["cnt"] if row else 0
|
||||
if count >= MAX_SERVERS:
|
||||
raise HTTPException(status_code=503, detail="Server registry full")
|
||||
|
||||
# Upsert
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO servers (server_id, name, map, players, max_players,
|
||||
game_mode, version, tags, password, host, port,
|
||||
steam_id, first_seen, last_seen, heartbeat_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(server_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
map = excluded.map,
|
||||
players = excluded.players,
|
||||
max_players = excluded.max_players,
|
||||
game_mode = excluded.game_mode,
|
||||
version = excluded.version,
|
||||
tags = excluded.tags,
|
||||
password = excluded.password,
|
||||
host = excluded.host,
|
||||
port = excluded.port,
|
||||
steam_id = excluded.steam_id,
|
||||
last_seen = excluded.last_seen,
|
||||
heartbeat_count = servers.heartbeat_count + 1
|
||||
""",
|
||||
(
|
||||
server_id,
|
||||
payload.name,
|
||||
payload.map,
|
||||
payload.players,
|
||||
payload.max_players,
|
||||
payload.game_mode,
|
||||
payload.version,
|
||||
payload.tags,
|
||||
1 if payload.password else 0,
|
||||
payload.host or ip,
|
||||
payload.port,
|
||||
payload.steam_id,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
log.info("Heartbeat from %s (%s) — %s/%s players on %s",
|
||||
payload.name, server_id[:8], payload.players, payload.max_players, payload.map)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"server_id": server_id,
|
||||
"registered": True,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/servers", response_model=ServerListResponse)
|
||||
async def list_servers(request: Request, tags: str | None = None):
|
||||
"""List all active servers, optionally filtered by comma-separated tags."""
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await _prune_expired(db)
|
||||
|
||||
if tags:
|
||||
# Filter servers whose tags field contains any of the requested tags
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
placeholders = ",".join("?" for _ in tag_list)
|
||||
query = f"""
|
||||
SELECT * FROM servers
|
||||
WHERE last_seen > ?
|
||||
AND ({' OR '.join(f"tags LIKE ?" for _ in tag_list)})
|
||||
ORDER BY players DESC
|
||||
"""
|
||||
params = [time.time() - HEARTBEAT_TTL]
|
||||
for tg in tag_list:
|
||||
params.append(f"%{tg}%")
|
||||
cursor = await db.execute(query, params)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM servers WHERE last_seen > ? ORDER BY players DESC",
|
||||
(time.time() - HEARTBEAT_TTL,),
|
||||
)
|
||||
|
||||
rows = await cursor.fetchall()
|
||||
servers = [_row_to_server(r) for r in rows]
|
||||
|
||||
return ServerListResponse(count=len(servers), servers=servers)
|
||||
|
||||
|
||||
@app.get("/api/v1/servers/{server_id}", response_model=ServerInfo)
|
||||
async def get_server(server_id: str):
|
||||
"""Get details for a single server."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("SELECT * FROM servers WHERE server_id = ?", (server_id,))
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
now = time.time()
|
||||
if row["last_seen"] < now - HEARTBEAT_TTL:
|
||||
raise HTTPException(status_code=410, detail="Server has expired (no recent heartbeat)")
|
||||
|
||||
return _row_to_server(row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map Registry — Workshop API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _compute_sha256(filepath: str) -> str:
|
||||
"""Compute SHA-256 checksum of a file."""
|
||||
try:
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _row_to_map_info(row: aiosqlite.Row) -> MapInfo:
|
||||
"""Convert a DB row to a MapInfo response model."""
|
||||
return MapInfo(
|
||||
name=row["name"],
|
||||
size=row["size"],
|
||||
version=row["version"],
|
||||
description=row["description"],
|
||||
scene=row["scene"],
|
||||
author=row["author"],
|
||||
checksum_sha256=row["checksum_sha256"],
|
||||
uploaded_at=datetime.fromtimestamp(row["uploaded_at"], tz=timezone.utc).isoformat(),
|
||||
download_url=f"/api/v1/maps/{row['name']}.pck",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/maps", response_model=MapListResponse)
|
||||
async def list_workshop_maps():
|
||||
"""List all community maps available for download."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM workshop_maps ORDER BY name ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
maps = [_row_to_map_info(r) for r in rows]
|
||||
|
||||
return MapListResponse(count=len(maps), maps=maps)
|
||||
|
||||
|
||||
@app.get("/api/v1/maps/{map_name}")
|
||||
async def get_workshop_map(map_name: str):
|
||||
"""Get metadata for a specific workshop map."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM workshop_maps WHERE name = ?", (map_name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Map '{map_name}' not found")
|
||||
|
||||
return _row_to_map_info(row)
|
||||
|
||||
|
||||
@app.get("/api/v1/maps/{map_name}.pck")
|
||||
async def download_workshop_map(map_name: str):
|
||||
"""Download a workshop map .pck file."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM workshop_maps WHERE name = ?", (map_name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Map '{map_name}' not found")
|
||||
|
||||
filepath = os.path.join(MAPS_DIR, row["filename"])
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=410, detail="Map file has been removed from storage")
|
||||
|
||||
return FileResponse(
|
||||
path=filepath,
|
||||
filename=row["filename"],
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-Checksum-SHA256": row["checksum_sha256"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/maps/upload")
|
||||
async def upload_workshop_map(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
scene: str = Form(""),
|
||||
author: str = Form(""),
|
||||
version: int = Form(1),
|
||||
):
|
||||
"""Upload a new workshop map .pck file."""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(ip)
|
||||
|
||||
# Validate file extension
|
||||
if not file.filename or not file.filename.endswith(".pck"):
|
||||
raise HTTPException(status_code=400, detail="Only .pck files are accepted")
|
||||
|
||||
# Save file to disk
|
||||
filename = f"{name}.pck"
|
||||
filepath = os.path.join(MAPS_DIR, filename)
|
||||
|
||||
content = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
checksum = _compute_sha256(filepath)
|
||||
now = time.time()
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA synchronous=NORMAL")
|
||||
|
||||
await db.execute(
|
||||
"""INSERT OR REPLACE INTO workshop_maps
|
||||
(name, size, version, description, scene, author, checksum_sha256, filename, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, file_size, version, description, scene, author, checksum, filename, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
log.info("Map uploaded: %s (%s, %d bytes, v%d)", name, filename, file_size, version)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"map": name,
|
||||
"size": file_size,
|
||||
"version": version,
|
||||
"checksum_sha256": checksum,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=_cfg.get("log_level", "info").upper(),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
log_level=_cfg.get("log_level", "info"),
|
||||
reload=False,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 28961,
|
||||
"database": "master_server.db",
|
||||
"heartbeat_ttl_seconds": 180,
|
||||
"max_servers": 1000,
|
||||
"log_level": "info",
|
||||
"rate_limit_per_ip": 30,
|
||||
"rate_limit_window_seconds": 60,
|
||||
"maps_dir": "workshop_maps"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
aiosqlite>=0.19.0
|
||||
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
@@ -0,0 +1,63 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# you cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
VIRTUAL_ENV=/home/oplabs/tactical-shooter/server/server-browser-api/venv
|
||||
export VIRTUAL_ENV
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(venv) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(venv) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
@@ -0,0 +1,26 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /home/oplabs/tactical-shooter/server/server-browser-api/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(venv) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/); you cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /home/oplabs/tactical-shooter/server/server-browser-api/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
||||
end
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from dotenv.__main__ import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from fastapi.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from idna.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
python3.11
|
||||
@@ -0,0 +1 @@
|
||||
python3.11
|
||||
@@ -0,0 +1 @@
|
||||
/home/oplabs/.local/share/uv/python/cpython-3.11-linux-x86_64-gnu/bin/python3.11
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from uvicorn.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from watchfiles.cli import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/home/oplabs/tactical-shooter/server/server-browser-api/venv/bin/python3.11
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from websockets.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
BIN
Binary file not shown.
+239
@@ -0,0 +1,239 @@
|
||||
# don't import any costly modules
|
||||
import os
|
||||
import sys
|
||||
|
||||
report_url = (
|
||||
"https://github.com/pypa/setuptools/issues/new?template=distutils-deprecation.yml"
|
||||
)
|
||||
|
||||
|
||||
def warn_distutils_present():
|
||||
if 'distutils' not in sys.modules:
|
||||
return
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Distutils was imported before Setuptools, but importing Setuptools "
|
||||
"also replaces the `distutils` module in `sys.modules`. This may lead "
|
||||
"to undesirable behaviors or errors. To avoid these issues, avoid "
|
||||
"using distutils directly, ensure that setuptools is installed in the "
|
||||
"traditional way (e.g. not an editable install), and/or make sure "
|
||||
"that setuptools is always imported before distutils."
|
||||
)
|
||||
|
||||
|
||||
def clear_distutils():
|
||||
if 'distutils' not in sys.modules:
|
||||
return
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Setuptools is replacing distutils. Support for replacing "
|
||||
"an already imported distutils is deprecated. In the future, "
|
||||
"this condition will fail. "
|
||||
f"Register concerns at {report_url}"
|
||||
)
|
||||
mods = [
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == "distutils" or name.startswith("distutils.")
|
||||
]
|
||||
for name in mods:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def enabled():
|
||||
"""
|
||||
Allow selection of distutils by environment variable.
|
||||
"""
|
||||
which = os.environ.get('SETUPTOOLS_USE_DISTUTILS', 'local')
|
||||
if which == 'stdlib':
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Reliance on distutils from stdlib is deprecated. Users "
|
||||
"must rely on setuptools to provide the distutils module. "
|
||||
"Avoid importing distutils or import setuptools first, "
|
||||
"and avoid setting SETUPTOOLS_USE_DISTUTILS=stdlib. "
|
||||
f"Register concerns at {report_url}"
|
||||
)
|
||||
return which == 'local'
|
||||
|
||||
|
||||
def ensure_local_distutils():
|
||||
import importlib
|
||||
|
||||
clear_distutils()
|
||||
|
||||
# With the DistutilsMetaFinder in place,
|
||||
# perform an import to cause distutils to be
|
||||
# loaded from setuptools._distutils. Ref #2906.
|
||||
with shim():
|
||||
importlib.import_module('distutils')
|
||||
|
||||
# check that submodules load as expected
|
||||
core = importlib.import_module('distutils.core')
|
||||
assert '_distutils' in core.__file__, core.__file__
|
||||
assert 'setuptools._distutils.log' not in sys.modules
|
||||
|
||||
|
||||
def do_override():
|
||||
"""
|
||||
Ensure that the local copy of distutils is preferred over stdlib.
|
||||
|
||||
See https://github.com/pypa/setuptools/issues/417#issuecomment-392298401
|
||||
for more motivation.
|
||||
"""
|
||||
if enabled():
|
||||
warn_distutils_present()
|
||||
ensure_local_distutils()
|
||||
|
||||
|
||||
class _TrivialRe:
|
||||
def __init__(self, *patterns) -> None:
|
||||
self._patterns = patterns
|
||||
|
||||
def match(self, string):
|
||||
return all(pat in string for pat in self._patterns)
|
||||
|
||||
|
||||
class DistutilsMetaFinder:
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
# optimization: only consider top level modules and those
|
||||
# found in the CPython test suite.
|
||||
if path is not None and not fullname.startswith('test.'):
|
||||
return None
|
||||
|
||||
method_name = 'spec_for_{fullname}'.format(**locals())
|
||||
method = getattr(self, method_name, lambda: None)
|
||||
return method()
|
||||
|
||||
def spec_for_distutils(self):
|
||||
if self.is_cpython():
|
||||
return None
|
||||
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
mod = importlib.import_module('setuptools._distutils')
|
||||
except Exception:
|
||||
# There are a couple of cases where setuptools._distutils
|
||||
# may not be present:
|
||||
# - An older Setuptools without a local distutils is
|
||||
# taking precedence. Ref #2957.
|
||||
# - Path manipulation during sitecustomize removes
|
||||
# setuptools from the path but only after the hook
|
||||
# has been loaded. Ref #2980.
|
||||
# In either case, fall back to stdlib behavior.
|
||||
return None
|
||||
|
||||
class DistutilsLoader(importlib.abc.Loader):
|
||||
def create_module(self, spec):
|
||||
mod.__name__ = 'distutils'
|
||||
return mod
|
||||
|
||||
def exec_module(self, module):
|
||||
pass
|
||||
|
||||
return importlib.util.spec_from_loader(
|
||||
'distutils', DistutilsLoader(), origin=mod.__file__
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_cpython():
|
||||
"""
|
||||
Suppress supplying distutils for CPython (build and tests).
|
||||
Ref #2965 and #3007.
|
||||
"""
|
||||
return os.path.isfile('pybuilddir.txt')
|
||||
|
||||
def spec_for_pip(self):
|
||||
"""
|
||||
Ensure stdlib distutils when running under pip.
|
||||
See pypa/pip#8761 for rationale.
|
||||
"""
|
||||
if sys.version_info >= (3, 12) or self.pip_imported_during_build():
|
||||
return
|
||||
clear_distutils()
|
||||
self.spec_for_distutils = lambda: None
|
||||
|
||||
@classmethod
|
||||
def pip_imported_during_build(cls):
|
||||
"""
|
||||
Detect if pip is being imported in a build script. Ref #2355.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
return any(
|
||||
cls.frame_file_is_setup(frame) for frame, line in traceback.walk_stack(None)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def frame_file_is_setup(frame):
|
||||
"""
|
||||
Return True if the indicated frame suggests a setup.py file.
|
||||
"""
|
||||
# some frames may not have __file__ (#2940)
|
||||
return frame.f_globals.get('__file__', '').endswith('setup.py')
|
||||
|
||||
def spec_for_sensitive_tests(self):
|
||||
"""
|
||||
Ensure stdlib distutils when running select tests under CPython.
|
||||
|
||||
python/cpython#91169
|
||||
"""
|
||||
clear_distutils()
|
||||
self.spec_for_distutils = lambda: None
|
||||
|
||||
sensitive_tests = (
|
||||
[
|
||||
'test.test_distutils',
|
||||
'test.test_peg_generator',
|
||||
'test.test_importlib',
|
||||
]
|
||||
if sys.version_info < (3, 10)
|
||||
else [
|
||||
'test.test_distutils',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
for name in DistutilsMetaFinder.sensitive_tests:
|
||||
setattr(
|
||||
DistutilsMetaFinder,
|
||||
f'spec_for_{name}',
|
||||
DistutilsMetaFinder.spec_for_sensitive_tests,
|
||||
)
|
||||
|
||||
|
||||
DISTUTILS_FINDER = DistutilsMetaFinder()
|
||||
|
||||
|
||||
def add_shim():
|
||||
DISTUTILS_FINDER in sys.meta_path or insert_shim()
|
||||
|
||||
|
||||
class shim:
|
||||
def __enter__(self) -> None:
|
||||
insert_shim()
|
||||
|
||||
def __exit__(self, exc: object, value: object, tb: object) -> None:
|
||||
_remove_shim()
|
||||
|
||||
|
||||
def insert_shim():
|
||||
sys.meta_path.insert(0, DISTUTILS_FINDER)
|
||||
|
||||
|
||||
def _remove_shim():
|
||||
try:
|
||||
sys.meta_path.remove(DISTUTILS_FINDER)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
# DistutilsMetaFinder can only be disabled in Python < 3.12 (PEP 632)
|
||||
remove_shim = _remove_shim
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
__import__('_distutils_hack').do_override()
|
||||
@@ -0,0 +1,33 @@
|
||||
# This is a stub package designed to roughly emulate the _yaml
|
||||
# extension module, which previously existed as a standalone module
|
||||
# and has been moved into the `yaml` package namespace.
|
||||
# It does not perfectly mimic its old counterpart, but should get
|
||||
# close enough for anyone who's relying on it even when they shouldn't.
|
||||
import yaml
|
||||
|
||||
# in some circumstances, the yaml module we imoprted may be from a different version, so we need
|
||||
# to tread carefully when poking at it here (it may not have the attributes we expect)
|
||||
if not getattr(yaml, '__with_libyaml__', False):
|
||||
from sys import version_info
|
||||
|
||||
exc = ModuleNotFoundError if version_info >= (3, 6) else ImportError
|
||||
raise exc("No module named '_yaml'")
|
||||
else:
|
||||
from yaml._yaml import *
|
||||
import warnings
|
||||
warnings.warn(
|
||||
'The _yaml extension module is now located at yaml._yaml'
|
||||
' and its location is subject to change. To use the'
|
||||
' LibYAML-based parser and emitter, import from `yaml`:'
|
||||
' `from yaml import CLoader as Loader, CDumper as Dumper`.',
|
||||
DeprecationWarning
|
||||
)
|
||||
del warnings
|
||||
# Don't `del yaml` here because yaml is actually an existing
|
||||
# namespace member of _yaml.
|
||||
|
||||
__name__ = '_yaml'
|
||||
# If the module is top-level (i.e. not a part of any specific package)
|
||||
# then the attribute should be set to ''.
|
||||
# https://docs.python.org/3.8/library/types.html
|
||||
__package__ = ''
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
pip
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: aiosqlite
|
||||
Version: 0.22.1
|
||||
Summary: asyncio bridge to the standard sqlite3 module
|
||||
Author-email: Amethyst Reese <amethyst@n7.gg>
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Framework :: AsyncIO
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Topic :: Software Development :: Libraries
|
||||
License-File: LICENSE
|
||||
Requires-Dist: attribution==1.8.0 ; extra == "dev"
|
||||
Requires-Dist: black==25.11.0 ; extra == "dev"
|
||||
Requires-Dist: build>=1.2 ; extra == "dev"
|
||||
Requires-Dist: coverage[toml]==7.10.7 ; extra == "dev"
|
||||
Requires-Dist: flake8==7.3.0 ; extra == "dev"
|
||||
Requires-Dist: flake8-bugbear==24.12.12 ; extra == "dev"
|
||||
Requires-Dist: flit==3.12.0 ; extra == "dev"
|
||||
Requires-Dist: mypy==1.19.0 ; extra == "dev"
|
||||
Requires-Dist: ufmt==2.8.0 ; extra == "dev"
|
||||
Requires-Dist: usort==1.0.8.post1 ; extra == "dev"
|
||||
Requires-Dist: sphinx==8.1.3 ; extra == "docs"
|
||||
Requires-Dist: sphinx-mdinclude==0.6.2 ; extra == "docs"
|
||||
Project-URL: Documentation, https://aiosqlite.omnilib.dev
|
||||
Project-URL: Github, https://github.com/omnilib/aiosqlite
|
||||
Provides-Extra: dev
|
||||
Provides-Extra: docs
|
||||
|
||||
aiosqlite\: Sqlite for AsyncIO
|
||||
==============================
|
||||
|
||||
.. image:: https://readthedocs.org/projects/aiosqlite/badge/?version=latest
|
||||
:target: https://aiosqlite.omnilib.dev/en/latest/?badge=latest
|
||||
:alt: Documentation Status
|
||||
.. image:: https://img.shields.io/pypi/v/aiosqlite.svg
|
||||
:target: https://pypi.org/project/aiosqlite
|
||||
:alt: PyPI Release
|
||||
.. image:: https://img.shields.io/badge/change-log-blue
|
||||
:target: https://github.com/omnilib/aiosqlite/blob/master/CHANGELOG.md
|
||||
:alt: Changelog
|
||||
.. image:: https://img.shields.io/pypi/l/aiosqlite.svg
|
||||
:target: https://github.com/omnilib/aiosqlite/blob/master/LICENSE
|
||||
:alt: MIT Licensed
|
||||
|
||||
aiosqlite provides a friendly, async interface to sqlite databases.
|
||||
|
||||
It replicates the standard ``sqlite3`` module, but with async versions
|
||||
of all the standard connection and cursor methods, plus context managers for
|
||||
automatically closing connections and cursors:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with aiosqlite.connect(...) as db:
|
||||
await db.execute("INSERT INTO some_table ...")
|
||||
await db.commit()
|
||||
|
||||
async with db.execute("SELECT * FROM some_table") as cursor:
|
||||
async for row in cursor:
|
||||
...
|
||||
|
||||
It can also be used in the traditional, procedural manner:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db = await aiosqlite.connect(...)
|
||||
cursor = await db.execute('SELECT * FROM some_table')
|
||||
row = await cursor.fetchone()
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
await db.close()
|
||||
|
||||
aiosqlite also replicates most of the advanced features of ``sqlite3``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async with aiosqlite.connect(...) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute('SELECT * FROM some_table') as cursor:
|
||||
async for row in cursor:
|
||||
value = row['column']
|
||||
|
||||
await db.execute('INSERT INTO foo some_table')
|
||||
assert db.total_changes > 0
|
||||
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
aiosqlite is compatible with Python 3.8 and newer.
|
||||
You can install it from PyPI:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pip install aiosqlite
|
||||
|
||||
|
||||
Details
|
||||
-------
|
||||
|
||||
aiosqlite allows interaction with SQLite databases on the main AsyncIO event
|
||||
loop without blocking execution of other coroutines while waiting for queries
|
||||
or data fetches. It does this by using a single, shared thread per connection.
|
||||
This thread executes all actions within a shared request queue to prevent
|
||||
overlapping actions.
|
||||
|
||||
Connection objects are proxies to the real connections, contain the shared
|
||||
execution thread, and provide context managers to handle automatically closing
|
||||
connections. Cursors are similarly proxies to the real cursors, and provide
|
||||
async iterators to query results.
|
||||
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
aiosqlite is copyright `Amethyst Reese <https://noswap.com>`_, and licensed under the
|
||||
MIT license. I am providing code in this repository to you under an open source
|
||||
license. This is my personal repository; the license you receive to my code
|
||||
is from me and not from my employer. See the `LICENSE`_ file for details.
|
||||
|
||||
.. _LICENSE: https://github.com/omnilib/aiosqlite/blob/master/LICENSE
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
aiosqlite-0.22.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
aiosqlite-0.22.1.dist-info/METADATA,sha256=zzyMxzl2h_dGAlV6Pk9c4YBlkaYsgv6UybOW_YDRs5o,4311
|
||||
aiosqlite-0.22.1.dist-info/RECORD,,
|
||||
aiosqlite-0.22.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
aiosqlite-0.22.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
||||
aiosqlite-0.22.1.dist-info/licenses/LICENSE,sha256=qwwXHcPvi_MlqEu3fYVUIfJhEzXd9uCIFrKSLE7cD3Y,1071
|
||||
aiosqlite/__init__.py,sha256=kjZKcYP2eZ3IbBEHQ0D_Owsk_-FlRGEjQWlbybOs8jk,888
|
||||
aiosqlite/__pycache__/__init__.cpython-311.pyc,,
|
||||
aiosqlite/__pycache__/__version__.cpython-311.pyc,,
|
||||
aiosqlite/__pycache__/context.cpython-311.pyc,,
|
||||
aiosqlite/__pycache__/core.cpython-311.pyc,,
|
||||
aiosqlite/__pycache__/cursor.cpython-311.pyc,,
|
||||
aiosqlite/__version__.py,sha256=sEM7xBU6e8WQYHe3ESoQmkEbXGKrqXjrQ5ujl5zpyV0,157
|
||||
aiosqlite/context.py,sha256=9jJcPG_SGSshzNUwXy87C1__mrKGFbToX0UuOQ1uItQ,1448
|
||||
aiosqlite/core.py,sha256=eXar7Bxr1pQz3VShCD0t6zGpp_bwwvFtVzVdzS2opII,15095
|
||||
aiosqlite/cursor.py,sha256=X3k2gYJeo3yB84scDEAPFZpsC_rzjT8dT4p6W3MeezM,3476
|
||||
aiosqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
aiosqlite/tests/__init__.py,sha256=sp0-HYboM6gOYrUxWy8xna-hdJyMUtKBvAKrpRBcDCE,90
|
||||
aiosqlite/tests/__main__.py,sha256=eZRuAxr1bwF9xAAqVjCi4vd1WFsFO35uyhtuVO0GjmY,162
|
||||
aiosqlite/tests/__pycache__/__init__.cpython-311.pyc,,
|
||||
aiosqlite/tests/__pycache__/__main__.cpython-311.pyc,,
|
||||
aiosqlite/tests/__pycache__/helpers.cpython-311.pyc,,
|
||||
aiosqlite/tests/__pycache__/perf.cpython-311.pyc,,
|
||||
aiosqlite/tests/__pycache__/smoke.cpython-311.pyc,,
|
||||
aiosqlite/tests/helpers.py,sha256=MWC839FiX63TBmFiIjabXNx-4G5eWYnE5MiInKIAdJw,722
|
||||
aiosqlite/tests/perf.py,sha256=-ipnXSHidO6VBKEdLAOcGa3cKHU5ul1w8-ifDNtGbfA,7249
|
||||
aiosqlite/tests/smoke.py,sha256=k5mp4AOHheOO6wKL_bgdH1fenY6-ve6aew19ifmcIWA,19851
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: flit 3.12.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Amethyst Reese
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
"""asyncio bridge to the standard sqlite3 module"""
|
||||
|
||||
from sqlite3 import ( # pylint: disable=redefined-builtin
|
||||
DatabaseError,
|
||||
Error,
|
||||
IntegrityError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
paramstyle,
|
||||
ProgrammingError,
|
||||
register_adapter,
|
||||
register_converter,
|
||||
Row,
|
||||
sqlite_version,
|
||||
sqlite_version_info,
|
||||
Warning,
|
||||
)
|
||||
|
||||
__author__ = "Amethyst Reese"
|
||||
from .__version__ import __version__
|
||||
from .core import connect, Connection, Cursor
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"paramstyle",
|
||||
"register_adapter",
|
||||
"register_converter",
|
||||
"sqlite_version",
|
||||
"sqlite_version_info",
|
||||
"connect",
|
||||
"Connection",
|
||||
"Cursor",
|
||||
"Row",
|
||||
"Warning",
|
||||
"Error",
|
||||
"DatabaseError",
|
||||
"IntegrityError",
|
||||
"ProgrammingError",
|
||||
"OperationalError",
|
||||
"NotSupportedError",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
This file is automatically generated by attribution.
|
||||
|
||||
Do not edit manually. Get more info at https://attribution.omnilib.dev
|
||||
"""
|
||||
|
||||
__version__ = "0.22.1"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
|
||||
from collections.abc import Coroutine, Generator
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from .cursor import Cursor
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class Result(AbstractAsyncContextManager[_T], Coroutine[Any, Any, _T]):
|
||||
__slots__ = ("_coro", "_obj")
|
||||
|
||||
def __init__(self, coro: Coroutine[Any, Any, _T]):
|
||||
self._coro = coro
|
||||
self._obj: _T
|
||||
|
||||
def send(self, value) -> None:
|
||||
return self._coro.send(value)
|
||||
|
||||
def throw(self, typ, val=None, tb=None) -> None:
|
||||
if val is None:
|
||||
return self._coro.throw(typ)
|
||||
|
||||
if tb is None:
|
||||
return self._coro.throw(typ, val)
|
||||
|
||||
return self._coro.throw(typ, val, tb)
|
||||
|
||||
def close(self) -> None:
|
||||
return self._coro.close()
|
||||
|
||||
def __await__(self) -> Generator[Any, None, _T]:
|
||||
return self._coro.__await__()
|
||||
|
||||
async def __aenter__(self) -> _T:
|
||||
self._obj = await self._coro
|
||||
return self._obj
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if isinstance(self._obj, Cursor):
|
||||
await self._obj.close()
|
||||
|
||||
|
||||
def contextmanager(
|
||||
method: Callable[..., Coroutine[Any, Any, _T]],
|
||||
) -> Callable[..., Result[_T]]:
|
||||
@wraps(method)
|
||||
def wrapper(self, *args, **kwargs) -> Result[_T]:
|
||||
return Result(method(self, *args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,468 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
"""
|
||||
Core implementation of aiosqlite proxies
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Generator, Iterable
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue, SimpleQueue
|
||||
from threading import Thread
|
||||
from typing import Any, Callable, Literal, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
from .context import contextmanager
|
||||
from .cursor import Cursor
|
||||
|
||||
__all__ = ["connect", "Connection", "Cursor"]
|
||||
|
||||
AuthorizerCallback = Callable[[int, str, str, str, str], int]
|
||||
|
||||
LOG = logging.getLogger("aiosqlite")
|
||||
|
||||
|
||||
IsolationLevel = Optional[Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"]]
|
||||
|
||||
|
||||
def set_result(fut: asyncio.Future, result: Any) -> None:
|
||||
"""Set the result of a future if it hasn't been set already."""
|
||||
if not fut.done():
|
||||
fut.set_result(result)
|
||||
|
||||
|
||||
def set_exception(fut: asyncio.Future, e: BaseException) -> None:
|
||||
"""Set the exception of a future if it hasn't been set already."""
|
||||
if not fut.done():
|
||||
fut.set_exception(e)
|
||||
|
||||
|
||||
_STOP_RUNNING_SENTINEL = object()
|
||||
_TxQueue = SimpleQueue[tuple[Optional[asyncio.Future], Callable[[], Any]]]
|
||||
|
||||
|
||||
def _connection_worker_thread(tx: _TxQueue):
|
||||
"""
|
||||
Execute function calls on a separate thread.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
while True:
|
||||
# Continues running until all queue items are processed,
|
||||
# even after connection is closed (so we can finalize all
|
||||
# futures)
|
||||
|
||||
future, function = tx.get()
|
||||
|
||||
try:
|
||||
LOG.debug("executing %s", function)
|
||||
result = function()
|
||||
|
||||
if future:
|
||||
future.get_loop().call_soon_threadsafe(set_result, future, result)
|
||||
LOG.debug("operation %s completed", function)
|
||||
|
||||
if result is _STOP_RUNNING_SENTINEL:
|
||||
break
|
||||
|
||||
except BaseException as e: # noqa B036
|
||||
LOG.debug("returning exception %s", e)
|
||||
if future:
|
||||
future.get_loop().call_soon_threadsafe(set_exception, future, e)
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(
|
||||
self,
|
||||
connector: Callable[[], sqlite3.Connection],
|
||||
iter_chunk_size: int,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
) -> None:
|
||||
self._running = True
|
||||
self._connection: Optional[sqlite3.Connection] = None
|
||||
self._connector = connector
|
||||
self._tx: _TxQueue = SimpleQueue()
|
||||
self._iter_chunk_size = iter_chunk_size
|
||||
self._thread = Thread(target=_connection_worker_thread, args=(self._tx,))
|
||||
|
||||
if loop is not None:
|
||||
warn(
|
||||
"aiosqlite.Connection no longer uses the `loop` parameter",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
if self._connection is None:
|
||||
return
|
||||
|
||||
warn(
|
||||
(
|
||||
f"{self!r} was deleted before being closed. "
|
||||
"Please use 'async with' or '.close()' to close the connection properly."
|
||||
),
|
||||
ResourceWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
# Don't try to be creative here, the event loop may have already been closed.
|
||||
# Simply stop the worker thread, and let the underlying sqlite3 connection
|
||||
# be finalized by its own __del__.
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> Optional[asyncio.Future]:
|
||||
"""Stop the background thread. Prefer `async with` or `await close()`"""
|
||||
self._running = False
|
||||
|
||||
def close_and_stop():
|
||||
if self._connection is not None:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
return _STOP_RUNNING_SENTINEL
|
||||
|
||||
try:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
except Exception:
|
||||
future = None
|
||||
|
||||
self._tx.put_nowait((future, close_and_stop))
|
||||
return future
|
||||
|
||||
@property
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
if self._connection is None:
|
||||
raise ValueError("no active connection")
|
||||
|
||||
return self._connection
|
||||
|
||||
def _execute_insert(self, sql: str, parameters: Any) -> Optional[sqlite3.Row]:
|
||||
cursor = self._conn.execute(sql, parameters)
|
||||
cursor.execute("SELECT last_insert_rowid()")
|
||||
return cursor.fetchone()
|
||||
|
||||
def _execute_fetchall(self, sql: str, parameters: Any) -> Iterable[sqlite3.Row]:
|
||||
cursor = self._conn.execute(sql, parameters)
|
||||
return cursor.fetchall()
|
||||
|
||||
async def _execute(self, fn, *args, **kwargs):
|
||||
"""Queue a function with the given arguments for execution."""
|
||||
if not self._running or not self._connection:
|
||||
raise ValueError("Connection closed")
|
||||
|
||||
function = partial(fn, *args, **kwargs)
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
|
||||
self._tx.put_nowait((future, function))
|
||||
|
||||
return await future
|
||||
|
||||
async def _connect(self) -> "Connection":
|
||||
"""Connect to the actual sqlite database."""
|
||||
if self._connection is None:
|
||||
try:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self._tx.put_nowait((future, self._connector))
|
||||
self._connection = await future
|
||||
except BaseException:
|
||||
self.stop()
|
||||
self._connection = None
|
||||
raise
|
||||
|
||||
return self
|
||||
|
||||
def __await__(self) -> Generator[Any, None, "Connection"]:
|
||||
self._thread.start()
|
||||
return self._connect().__await__()
|
||||
|
||||
async def __aenter__(self) -> "Connection":
|
||||
return await self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
await self.close()
|
||||
|
||||
@contextmanager
|
||||
async def cursor(self) -> Cursor:
|
||||
"""Create an aiosqlite cursor wrapping a sqlite3 cursor object."""
|
||||
return Cursor(self, await self._execute(self._conn.cursor))
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""Commit the current transaction."""
|
||||
await self._execute(self._conn.commit)
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""Roll back the current transaction."""
|
||||
await self._execute(self._conn.rollback)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Complete queued queries/cursors and close the connection."""
|
||||
|
||||
if self._connection is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._execute(self._conn.close)
|
||||
except Exception:
|
||||
LOG.info("exception occurred while closing connection")
|
||||
raise
|
||||
finally:
|
||||
self._connection = None
|
||||
future = self.stop()
|
||||
if future:
|
||||
await future
|
||||
|
||||
@contextmanager
|
||||
async def execute(
|
||||
self, sql: str, parameters: Optional[Iterable[Any]] = None
|
||||
) -> Cursor:
|
||||
"""Helper to create a cursor and execute the given query."""
|
||||
if parameters is None:
|
||||
parameters = []
|
||||
cursor = await self._execute(self._conn.execute, sql, parameters)
|
||||
return Cursor(self, cursor)
|
||||
|
||||
@contextmanager
|
||||
async def execute_insert(
|
||||
self, sql: str, parameters: Optional[Iterable[Any]] = None
|
||||
) -> Optional[sqlite3.Row]:
|
||||
"""Helper to insert and get the last_insert_rowid."""
|
||||
if parameters is None:
|
||||
parameters = []
|
||||
return await self._execute(self._execute_insert, sql, parameters)
|
||||
|
||||
@contextmanager
|
||||
async def execute_fetchall(
|
||||
self, sql: str, parameters: Optional[Iterable[Any]] = None
|
||||
) -> Iterable[sqlite3.Row]:
|
||||
"""Helper to execute a query and return all the data."""
|
||||
if parameters is None:
|
||||
parameters = []
|
||||
return await self._execute(self._execute_fetchall, sql, parameters)
|
||||
|
||||
@contextmanager
|
||||
async def executemany(
|
||||
self, sql: str, parameters: Iterable[Iterable[Any]]
|
||||
) -> Cursor:
|
||||
"""Helper to create a cursor and execute the given multiquery."""
|
||||
cursor = await self._execute(self._conn.executemany, sql, parameters)
|
||||
return Cursor(self, cursor)
|
||||
|
||||
@contextmanager
|
||||
async def executescript(self, sql_script: str) -> Cursor:
|
||||
"""Helper to create a cursor and execute a user script."""
|
||||
cursor = await self._execute(self._conn.executescript, sql_script)
|
||||
return Cursor(self, cursor)
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
"""Interrupt pending queries."""
|
||||
return self._conn.interrupt()
|
||||
|
||||
async def create_function(
|
||||
self, name: str, num_params: int, func: Callable, deterministic: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Create user-defined function that can be later used
|
||||
within SQL statements. Must be run within the same thread
|
||||
that query executions take place so instead of executing directly
|
||||
against the connection, we defer this to `run` function.
|
||||
|
||||
If ``deterministic`` is true, the created function is marked as deterministic,
|
||||
which allows SQLite to perform additional optimizations. This flag is supported
|
||||
by SQLite 3.8.3 or higher, ``NotSupportedError`` will be raised if used with
|
||||
older versions.
|
||||
"""
|
||||
await self._execute(
|
||||
self._conn.create_function,
|
||||
name,
|
||||
num_params,
|
||||
func,
|
||||
deterministic=deterministic,
|
||||
)
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return self._conn.in_transaction
|
||||
|
||||
@property
|
||||
def isolation_level(self) -> Optional[str]:
|
||||
return self._conn.isolation_level
|
||||
|
||||
@isolation_level.setter
|
||||
def isolation_level(self, value: IsolationLevel) -> None:
|
||||
self._conn.isolation_level = value
|
||||
|
||||
@property
|
||||
def row_factory(self) -> Optional[type]:
|
||||
return self._conn.row_factory
|
||||
|
||||
@row_factory.setter
|
||||
def row_factory(self, factory: Optional[type]) -> None:
|
||||
self._conn.row_factory = factory
|
||||
|
||||
@property
|
||||
def text_factory(self) -> Callable[[bytes], Any]:
|
||||
return self._conn.text_factory
|
||||
|
||||
@text_factory.setter
|
||||
def text_factory(self, factory: Callable[[bytes], Any]) -> None:
|
||||
self._conn.text_factory = factory
|
||||
|
||||
@property
|
||||
def total_changes(self) -> int:
|
||||
return self._conn.total_changes
|
||||
|
||||
async def enable_load_extension(self, value: bool) -> None:
|
||||
await self._execute(self._conn.enable_load_extension, value) # type: ignore
|
||||
|
||||
async def load_extension(self, path: str):
|
||||
await self._execute(self._conn.load_extension, path) # type: ignore
|
||||
|
||||
async def set_progress_handler(
|
||||
self, handler: Callable[[], Optional[int]], n: int
|
||||
) -> None:
|
||||
await self._execute(self._conn.set_progress_handler, handler, n)
|
||||
|
||||
async def set_trace_callback(self, handler: Callable) -> None:
|
||||
await self._execute(self._conn.set_trace_callback, handler)
|
||||
|
||||
async def set_authorizer(
|
||||
self, authorizer_callback: Optional[AuthorizerCallback]
|
||||
) -> None:
|
||||
"""
|
||||
Set an authorizer callback to control database access.
|
||||
|
||||
The authorizer callback is invoked for each SQL statement that is prepared,
|
||||
and controls whether specific operations are permitted.
|
||||
|
||||
Example::
|
||||
|
||||
import sqlite3
|
||||
|
||||
def restrict_drops(action_code, arg1, arg2, db_name, trigger_name):
|
||||
# Deny all DROP operations
|
||||
if action_code == sqlite3.SQLITE_DROP_TABLE:
|
||||
return sqlite3.SQLITE_DENY
|
||||
# Allow everything else
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
await conn.set_authorizer(restrict_drops)
|
||||
|
||||
See ``sqlite3`` documentation for details:
|
||||
https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.set_authorizer
|
||||
|
||||
:param authorizer_callback: An optional callable that receives five arguments:
|
||||
|
||||
- ``action_code`` (int): The action to be authorized (e.g., ``SQLITE_READ``)
|
||||
- ``arg1`` (str): First argument, meaning depends on ``action_code``
|
||||
- ``arg2`` (str): Second argument, meaning depends on ``action_code``
|
||||
- ``db_name`` (str): Database name (e.g., ``"main"``, ``"temp"``)
|
||||
- ``trigger_name`` (str): Name of trigger or view that is doing the access,
|
||||
or ``None``
|
||||
|
||||
The callback should return:
|
||||
|
||||
- ``SQLITE_OK`` (0): Allow the operation
|
||||
- ``SQLITE_DENY`` (1): Deny the operation, raise ``sqlite3.DatabaseError``
|
||||
- ``SQLITE_IGNORE`` (2): Treat operation as no-op
|
||||
|
||||
Pass ``None`` to remove the authorizer.
|
||||
"""
|
||||
await self._execute(self._conn.set_authorizer, authorizer_callback)
|
||||
|
||||
async def iterdump(self) -> AsyncIterator[str]:
|
||||
"""
|
||||
Return an async iterator to dump the database in SQL text format.
|
||||
|
||||
Example::
|
||||
|
||||
async for line in db.iterdump():
|
||||
...
|
||||
|
||||
"""
|
||||
dump_queue: Queue = Queue()
|
||||
|
||||
def dumper():
|
||||
try:
|
||||
for line in self._conn.iterdump():
|
||||
dump_queue.put_nowait(line)
|
||||
dump_queue.put_nowait(None)
|
||||
|
||||
except Exception:
|
||||
LOG.exception("exception while dumping db")
|
||||
dump_queue.put_nowait(None)
|
||||
raise
|
||||
|
||||
fut = self._execute(dumper)
|
||||
task = asyncio.ensure_future(fut)
|
||||
|
||||
while True:
|
||||
try:
|
||||
line: Optional[str] = dump_queue.get_nowait()
|
||||
if line is None:
|
||||
break
|
||||
yield line
|
||||
|
||||
except Empty:
|
||||
if task.done():
|
||||
LOG.warning("iterdump completed unexpectedly")
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await task
|
||||
|
||||
async def backup(
|
||||
self,
|
||||
target: Union["Connection", sqlite3.Connection],
|
||||
*,
|
||||
pages: int = 0,
|
||||
progress: Optional[Callable[[int, int, int], None]] = None,
|
||||
name: str = "main",
|
||||
sleep: float = 0.250,
|
||||
) -> None:
|
||||
"""
|
||||
Make a backup of the current database to the target database.
|
||||
|
||||
Takes either a standard sqlite3 or aiosqlite Connection object as the target.
|
||||
"""
|
||||
if isinstance(target, Connection):
|
||||
target = target._conn
|
||||
|
||||
await self._execute(
|
||||
self._conn.backup,
|
||||
target,
|
||||
pages=pages,
|
||||
progress=progress,
|
||||
name=name,
|
||||
sleep=sleep,
|
||||
)
|
||||
|
||||
|
||||
def connect(
|
||||
database: Union[str, Path],
|
||||
*,
|
||||
iter_chunk_size=64,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
**kwargs: Any,
|
||||
) -> Connection:
|
||||
"""Create and return a connection proxy to the sqlite database."""
|
||||
|
||||
if loop is not None:
|
||||
warn(
|
||||
"aiosqlite.connect() no longer uses the `loop` parameter",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
def connector() -> sqlite3.Connection:
|
||||
if isinstance(database, str):
|
||||
loc = database
|
||||
elif isinstance(database, bytes):
|
||||
loc = database.decode("utf-8")
|
||||
else:
|
||||
loc = str(database)
|
||||
|
||||
return sqlite3.connect(loc, **kwargs)
|
||||
|
||||
return Connection(connector, iter_chunk_size)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core import Connection
|
||||
|
||||
|
||||
class Cursor:
|
||||
def __init__(self, conn: "Connection", cursor: sqlite3.Cursor) -> None:
|
||||
self.iter_chunk_size = conn._iter_chunk_size
|
||||
self._conn = conn
|
||||
self._cursor = cursor
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[sqlite3.Row]:
|
||||
"""The cursor proxy is also an async iterator."""
|
||||
return self._fetch_chunked()
|
||||
|
||||
async def _fetch_chunked(self):
|
||||
while True:
|
||||
rows = await self.fetchmany(self.iter_chunk_size)
|
||||
if not rows:
|
||||
return
|
||||
for row in rows:
|
||||
yield row
|
||||
|
||||
async def _execute(self, fn, *args, **kwargs):
|
||||
"""Execute the given function on the shared connection's thread."""
|
||||
return await self._conn._execute(fn, *args, **kwargs)
|
||||
|
||||
async def execute(
|
||||
self, sql: str, parameters: Optional[Iterable[Any]] = None
|
||||
) -> "Cursor":
|
||||
"""Execute the given query."""
|
||||
if parameters is None:
|
||||
parameters = []
|
||||
await self._execute(self._cursor.execute, sql, parameters)
|
||||
return self
|
||||
|
||||
async def executemany(
|
||||
self, sql: str, parameters: Iterable[Iterable[Any]]
|
||||
) -> "Cursor":
|
||||
"""Execute the given multiquery."""
|
||||
await self._execute(self._cursor.executemany, sql, parameters)
|
||||
return self
|
||||
|
||||
async def executescript(self, sql_script: str) -> "Cursor":
|
||||
"""Execute a user script."""
|
||||
await self._execute(self._cursor.executescript, sql_script)
|
||||
return self
|
||||
|
||||
async def fetchone(self) -> Optional[sqlite3.Row]:
|
||||
"""Fetch a single row."""
|
||||
return await self._execute(self._cursor.fetchone)
|
||||
|
||||
async def fetchmany(self, size: Optional[int] = None) -> Iterable[sqlite3.Row]:
|
||||
"""Fetch up to `cursor.arraysize` number of rows."""
|
||||
args: tuple[int, ...] = ()
|
||||
if size is not None:
|
||||
args = (size,)
|
||||
return await self._execute(self._cursor.fetchmany, *args)
|
||||
|
||||
async def fetchall(self) -> Iterable[sqlite3.Row]:
|
||||
"""Fetch all remaining rows."""
|
||||
return await self._execute(self._cursor.fetchall)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the cursor."""
|
||||
await self._execute(self._cursor.close)
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
return self._cursor.rowcount
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> Optional[int]:
|
||||
return self._cursor.lastrowid
|
||||
|
||||
@property
|
||||
def arraysize(self) -> int:
|
||||
return self._cursor.arraysize
|
||||
|
||||
@arraysize.setter
|
||||
def arraysize(self, value: int) -> None:
|
||||
self._cursor.arraysize = value
|
||||
|
||||
@property
|
||||
def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...]:
|
||||
return self._cursor.description
|
||||
|
||||
@property
|
||||
def row_factory(self) -> Optional[Callable[[sqlite3.Cursor, sqlite3.Row], object]]:
|
||||
return self._cursor.row_factory
|
||||
|
||||
@row_factory.setter
|
||||
def row_factory(self, factory: Optional[type]) -> None:
|
||||
self._cursor.row_factory = factory
|
||||
|
||||
@property
|
||||
def connection(self) -> sqlite3.Connection:
|
||||
return self._cursor.connection
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
from .smoke import SmokeTest
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
import unittest
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(module="aiosqlite.tests", verbosity=2)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+29
@@ -0,0 +1,29 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def setup_logger():
|
||||
log = logging.getLogger("")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
logging.addLevelName(logging.ERROR, "E")
|
||||
logging.addLevelName(logging.WARNING, "W")
|
||||
logging.addLevelName(logging.INFO, "I")
|
||||
logging.addLevelName(logging.DEBUG, "V")
|
||||
|
||||
date_fmt = r"%H:%M:%S"
|
||||
verbose_fmt = (
|
||||
"%(asctime)s,%(msecs)d %(levelname)s "
|
||||
"%(module)s:%(funcName)s():%(lineno)d "
|
||||
"%(message)s"
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setFormatter(logging.Formatter(verbose_fmt, date_fmt))
|
||||
log.addHandler(handler)
|
||||
|
||||
return log
|
||||
@@ -0,0 +1,221 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
"""
|
||||
Simple perf tests for aiosqlite and the asyncio run loop.
|
||||
"""
|
||||
import sqlite3
|
||||
import string
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from unittest import IsolatedAsyncioTestCase as TestCase
|
||||
|
||||
import aiosqlite
|
||||
from .smoke import setup_logger
|
||||
|
||||
TEST_DB = ":memory:"
|
||||
TARGET = 2.0
|
||||
RESULTS = {}
|
||||
|
||||
|
||||
def timed(fn, name=None):
|
||||
"""
|
||||
Decorator for perf testing a block of async code.
|
||||
|
||||
Expects the wrapped function to return an async generator.
|
||||
The generator should do setup, then yield when ready to start perf testing.
|
||||
The decorator will then pump the generator repeatedly until the target
|
||||
time has been reached, then close the generator and print perf results.
|
||||
"""
|
||||
|
||||
name = name or fn.__name__
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
gen = fn(*args, **kwargs)
|
||||
|
||||
await gen.asend(None)
|
||||
count = 0
|
||||
before = time.time()
|
||||
|
||||
while True:
|
||||
count += 1
|
||||
value = time.time() - before < TARGET
|
||||
try:
|
||||
if value:
|
||||
await gen.asend(value)
|
||||
else:
|
||||
await gen.aclose()
|
||||
break
|
||||
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"exception occurred: {e}")
|
||||
return
|
||||
|
||||
duration = time.time() - before
|
||||
|
||||
RESULTS[name] = (count, duration)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class PerfTest(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
print(f"Running perf tests for at least {TARGET:.1f}s each...")
|
||||
setup_logger()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
print(f"\n{'Perf Test':<25} Iterations Duration {'Rate':>11}")
|
||||
for name in sorted(RESULTS):
|
||||
count, duration = RESULTS[name]
|
||||
rate = count / duration
|
||||
name = name.replace("test_", "")
|
||||
print(f"{name:<25} {count:>10} {duration:>7.1f}s {rate:>9.1f}/s")
|
||||
|
||||
@timed
|
||||
async def test_connection_memory(self):
|
||||
while True:
|
||||
yield
|
||||
async with aiosqlite.connect(TEST_DB):
|
||||
pass
|
||||
|
||||
@timed
|
||||
async def test_connection_file(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||
path = tf.name
|
||||
tf.close()
|
||||
|
||||
async with aiosqlite.connect(path) as db:
|
||||
await db.execute(
|
||||
"create table perf (i integer primary key asc, k integer)"
|
||||
)
|
||||
await db.execute("insert into perf (k) values (2), (3)")
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
async with aiosqlite.connect(path):
|
||||
pass
|
||||
|
||||
@timed
|
||||
async def test_atomics(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
await db.execute("insert into perf (k) values (2), (3)")
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
async with db.execute("select last_insert_rowid()") as cursor:
|
||||
await cursor.fetchone()
|
||||
|
||||
@timed
|
||||
async def test_inserts(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
await db.execute("insert into perf (k) values (1), (2), (3)")
|
||||
await db.commit()
|
||||
|
||||
@timed
|
||||
async def test_inserts_authorized(self):
|
||||
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
|
||||
if action_code == sqlite3.SQLITE_DROP_TABLE:
|
||||
return sqlite3.SQLITE_DENY
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
await db.set_authorizer(deny_drops)
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
await db.execute("insert into perf (k) values (1), (2), (3)")
|
||||
await db.commit()
|
||||
|
||||
@timed
|
||||
async def test_insert_ids(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
cursor = await db.execute("insert into perf (k) values (1)")
|
||||
await cursor.execute("select last_insert_rowid()")
|
||||
await cursor.fetchone()
|
||||
await db.commit()
|
||||
|
||||
@timed
|
||||
async def test_insert_macro_ids(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
await db.execute_insert("insert into perf (k) values (1)")
|
||||
await db.commit()
|
||||
|
||||
@timed
|
||||
async def test_select(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
for i in range(100):
|
||||
await db.execute("insert into perf (k) values (%d)" % (i,))
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
cursor = await db.execute("select i, k from perf")
|
||||
assert len(await cursor.fetchall()) == 100
|
||||
|
||||
@timed
|
||||
async def test_select_macro(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute("create table perf (i integer primary key asc, k integer)")
|
||||
for i in range(100):
|
||||
await db.execute("insert into perf (k) values (%d)" % (i,))
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
yield
|
||||
assert len(await db.execute_fetchall("select i, k from perf")) == 100
|
||||
|
||||
async def test_iterable_cursor_perf(self):
|
||||
async with aiosqlite.connect(TEST_DB) as db:
|
||||
await db.execute(
|
||||
"create table ic_perf ("
|
||||
"i integer primary key asc, k integer, a integer, b integer, c char(16))"
|
||||
)
|
||||
for batch in range(128): # add 128k rows
|
||||
r_start = batch * 1024
|
||||
await db.executemany(
|
||||
"insert into ic_perf (k, a, b, c) values(?, 1, 2, ?)",
|
||||
[
|
||||
*[
|
||||
(i, string.ascii_lowercase)
|
||||
for i in range(r_start, r_start + 1024)
|
||||
]
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def test_perf(chunk_size: int):
|
||||
while True:
|
||||
async with db.execute("SELECT * FROM ic_perf") as cursor:
|
||||
cursor.iter_chunk_size = chunk_size
|
||||
async for _ in cursor:
|
||||
yield
|
||||
|
||||
for chunk_size in [2**i for i in range(4, 11)]:
|
||||
await timed(test_perf, f"iterable_cursor @ {chunk_size}")(chunk_size)
|
||||
@@ -0,0 +1,537 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sqlite3 import OperationalError
|
||||
from tempfile import TemporaryDirectory
|
||||
from threading import Thread
|
||||
from unittest import IsolatedAsyncioTestCase, SkipTest
|
||||
from unittest.mock import patch
|
||||
|
||||
import aiosqlite
|
||||
from .helpers import setup_logger
|
||||
|
||||
|
||||
class SmokeTest(IsolatedAsyncioTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
setup_logger()
|
||||
|
||||
def setUp(self):
|
||||
td = TemporaryDirectory()
|
||||
self.addCleanup(td.cleanup)
|
||||
self.db = Path(td.name).resolve() / "test.db"
|
||||
|
||||
async def test_connection_await(self):
|
||||
db = await aiosqlite.connect(self.db)
|
||||
self.assertIsInstance(db, aiosqlite.Connection)
|
||||
|
||||
async with db.execute("select 1, 2") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(rows, [(1, 2)])
|
||||
|
||||
await db.close()
|
||||
|
||||
async def test_connection_context(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
self.assertIsInstance(db, aiosqlite.Connection)
|
||||
|
||||
async with db.execute("select 1, 2") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(rows, [(1, 2)])
|
||||
|
||||
async def test_connection_locations(self):
|
||||
TEST_DB = self.db.as_posix()
|
||||
|
||||
class Fake: # pylint: disable=too-few-public-methods
|
||||
def __str__(self):
|
||||
return TEST_DB
|
||||
|
||||
locs = (Path(TEST_DB), TEST_DB, TEST_DB.encode(), Fake())
|
||||
|
||||
async with aiosqlite.connect(locs[0]) as db:
|
||||
await db.execute("create table foo (i integer, k integer)")
|
||||
await db.execute("insert into foo (i, k) values (1, 5)")
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("select * from foo")
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
for loc in locs:
|
||||
async with aiosqlite.connect(loc) as db:
|
||||
cursor = await db.execute("select * from foo")
|
||||
self.assertEqual(await cursor.fetchall(), rows)
|
||||
|
||||
async def test_multiple_connections(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.execute(
|
||||
"create table multiple_connections "
|
||||
"(i integer primary key asc, k integer)"
|
||||
)
|
||||
|
||||
async def do_one_conn(i):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.execute("insert into multiple_connections (k) values (?)", [i])
|
||||
await db.commit()
|
||||
|
||||
await asyncio.gather(*[do_one_conn(i) for i in range(10)])
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.execute("select * from multiple_connections")
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
assert len(rows) == 10
|
||||
|
||||
async def test_multiple_queries(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.execute(
|
||||
"create table multiple_queries "
|
||||
"(i integer primary key asc, k integer)"
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
db.execute("insert into multiple_queries (k) values (?)", [i])
|
||||
for i in range(10)
|
||||
]
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.execute("select * from multiple_queries")
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
assert len(rows) == 10
|
||||
|
||||
async def test_iterable_cursor(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.cursor()
|
||||
await cursor.execute(
|
||||
"create table iterable_cursor " "(i integer primary key asc, k integer)"
|
||||
)
|
||||
await cursor.executemany(
|
||||
"insert into iterable_cursor (k) values (?)", [[i] for i in range(10)]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.execute("select * from iterable_cursor")
|
||||
rows = []
|
||||
async for row in cursor:
|
||||
rows.append(row)
|
||||
|
||||
assert len(rows) == 10
|
||||
|
||||
async def test_multi_loop_usage(self):
|
||||
results = {}
|
||||
|
||||
def runner(k, conn):
|
||||
async def query():
|
||||
async with conn.execute("select * from foo") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(len(rows), 2)
|
||||
return rows
|
||||
|
||||
with self.subTest(k):
|
||||
loop = asyncio.new_event_loop()
|
||||
rows = loop.run_until_complete(query())
|
||||
loop.close()
|
||||
results[k] = rows
|
||||
|
||||
async with aiosqlite.connect(":memory:") as db:
|
||||
await db.execute("create table foo (id int, name varchar)")
|
||||
await db.execute(
|
||||
"insert into foo values (?, ?), (?, ?)", (1, "Sally", 2, "Janet")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
threads = [Thread(target=runner, args=(k, db)) for k in range(4)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertEqual(len(results), 4)
|
||||
for rows in results.values():
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
async def test_context_cursor(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
async with db.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"create table context_cursor "
|
||||
"(i integer primary key asc, k integer)"
|
||||
)
|
||||
await cursor.executemany(
|
||||
"insert into context_cursor (k) values (?)",
|
||||
[[i] for i in range(10)],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
async with db.execute("select * from context_cursor") as cursor:
|
||||
rows = []
|
||||
async for row in cursor:
|
||||
rows.append(row)
|
||||
|
||||
assert len(rows) == 10
|
||||
|
||||
async def test_cursor_return_self(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.cursor()
|
||||
|
||||
result = await cursor.execute(
|
||||
"create table test_cursor_return_self (i integer, k integer)"
|
||||
)
|
||||
self.assertEqual(result, cursor, "cursor execute returns itself")
|
||||
|
||||
result = await cursor.executemany(
|
||||
"insert into test_cursor_return_self values (?, ?)", [(1, 1), (2, 2)]
|
||||
)
|
||||
self.assertEqual(result, cursor)
|
||||
|
||||
result = await cursor.executescript(
|
||||
"insert into test_cursor_return_self values (3, 3);"
|
||||
"insert into test_cursor_return_self values (4, 4);"
|
||||
"insert into test_cursor_return_self values (5, 5);"
|
||||
)
|
||||
self.assertEqual(result, cursor)
|
||||
|
||||
async def test_connection_properties(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
self.assertEqual(db.total_changes, 0)
|
||||
|
||||
async with db.cursor() as cursor:
|
||||
self.assertFalse(db.in_transaction)
|
||||
await cursor.execute(
|
||||
"create table test_properties "
|
||||
"(i integer primary key asc, k integer, d text)"
|
||||
)
|
||||
await cursor.execute(
|
||||
"insert into test_properties (k, d) values (1, 'hi')"
|
||||
)
|
||||
self.assertTrue(db.in_transaction)
|
||||
await db.commit()
|
||||
self.assertFalse(db.in_transaction)
|
||||
|
||||
self.assertEqual(db.total_changes, 1)
|
||||
|
||||
self.assertIsNone(db.row_factory)
|
||||
self.assertEqual(db.text_factory, str)
|
||||
|
||||
async with db.cursor() as cursor:
|
||||
await cursor.execute("select * from test_properties")
|
||||
row = await cursor.fetchone()
|
||||
self.assertIsInstance(row, tuple)
|
||||
self.assertEqual(row, (1, 1, "hi"))
|
||||
with self.assertRaises(TypeError):
|
||||
_ = row["k"]
|
||||
|
||||
async with db.cursor() as cursor:
|
||||
cursor.row_factory = aiosqlite.Row
|
||||
self.assertEqual(cursor.row_factory, aiosqlite.Row)
|
||||
await cursor.execute("select * from test_properties")
|
||||
row = await cursor.fetchone()
|
||||
self.assertIsInstance(row, aiosqlite.Row)
|
||||
self.assertEqual(row[1], 1)
|
||||
self.assertEqual(row[2], "hi")
|
||||
self.assertEqual(row["k"], 1)
|
||||
self.assertEqual(row["d"], "hi")
|
||||
|
||||
db.row_factory = aiosqlite.Row
|
||||
db.text_factory = bytes
|
||||
self.assertEqual(db.row_factory, aiosqlite.Row)
|
||||
self.assertEqual(db.text_factory, bytes)
|
||||
|
||||
async with db.cursor() as cursor:
|
||||
await cursor.execute("select * from test_properties")
|
||||
row = await cursor.fetchone()
|
||||
self.assertIsInstance(row, aiosqlite.Row)
|
||||
self.assertEqual(row[1], 1)
|
||||
self.assertEqual(row[2], b"hi")
|
||||
self.assertEqual(row["k"], 1)
|
||||
self.assertEqual(row["d"], b"hi")
|
||||
|
||||
async def test_fetch_all(self):
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.execute(
|
||||
"create table test_fetch_all (i integer primary key asc, k integer)"
|
||||
)
|
||||
await db.execute(
|
||||
"insert into test_fetch_all (k) values (10), (24), (16), (32)"
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
cursor = await db.execute("select k from test_fetch_all where k < 30")
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(rows, [(10,), (24,), (16,)])
|
||||
|
||||
async def test_enable_load_extension(self):
|
||||
"""Assert that after enabling extension loading, they can be loaded"""
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
try:
|
||||
await db.enable_load_extension(True)
|
||||
await db.load_extension("test")
|
||||
except OperationalError as e:
|
||||
assert "not authorized" not in e.args
|
||||
except AttributeError as e:
|
||||
raise SkipTest(
|
||||
"python was not compiled with sqlite3 "
|
||||
"extension support, so we can't test it"
|
||||
) from e
|
||||
|
||||
async def test_set_progress_handler(self):
|
||||
"""
|
||||
Assert that after setting a progress handler returning 1, DB operations are aborted
|
||||
"""
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.set_progress_handler(lambda: 1, 1)
|
||||
with self.assertRaises(OperationalError):
|
||||
await db.execute(
|
||||
"create table test_progress_handler (i integer primary key asc, k integer)"
|
||||
)
|
||||
|
||||
async def test_create_function(self):
|
||||
"""Assert that after creating a custom function, it can be used"""
|
||||
|
||||
def no_arg():
|
||||
return "no arg"
|
||||
|
||||
def one_arg(num):
|
||||
return num * 2
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.create_function("no_arg", 0, no_arg)
|
||||
await db.create_function("one_arg", 1, one_arg)
|
||||
|
||||
async with db.execute("SELECT no_arg();") as res:
|
||||
row = await res.fetchone()
|
||||
self.assertEqual(row[0], "no arg")
|
||||
|
||||
async with db.execute("SELECT one_arg(10);") as res:
|
||||
row = await res.fetchone()
|
||||
self.assertEqual(row[0], 20)
|
||||
|
||||
async def test_create_function_deterministic(self):
|
||||
"""Assert that after creating a deterministic custom function, it can be used.
|
||||
|
||||
https://sqlite.org/deterministic.html
|
||||
"""
|
||||
|
||||
def one_arg(num):
|
||||
return num * 2
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.create_function("one_arg", 1, one_arg, deterministic=True)
|
||||
await db.execute("create table foo (id int, bar int)")
|
||||
|
||||
# Non-deterministic functions cannot be used in indexes
|
||||
await db.execute("create index t on foo(one_arg(bar))")
|
||||
|
||||
async def test_set_trace_callback(self):
|
||||
statements = []
|
||||
|
||||
def callback(statement: str):
|
||||
statements.append(statement)
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.set_trace_callback(callback)
|
||||
|
||||
await db.execute("select 10")
|
||||
self.assertIn("select 10", statements)
|
||||
|
||||
async def test_set_authorizer_deny_drops(self):
|
||||
"""Test authorizer that denies DROP operations"""
|
||||
|
||||
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
|
||||
if action_code == sqlite3.SQLITE_DROP_TABLE:
|
||||
return sqlite3.SQLITE_DENY
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.set_authorizer(deny_drops)
|
||||
|
||||
# Other operations should succeed
|
||||
await db.execute("CREATE TABLE test_drop (id INTEGER)")
|
||||
await db.execute("INSERT INTO test_drop VALUES (1)")
|
||||
await db.execute("SELECT * FROM test_drop")
|
||||
|
||||
# DROP should fail
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
await db.execute("DROP TABLE test_drop")
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
# Disabling the authorizer re-enables DROP
|
||||
await db.set_authorizer(None)
|
||||
await db.execute("DROP TABLE test_drop")
|
||||
|
||||
async def test_set_authorizer_exception_propagation(self):
|
||||
"""Test that exceptions raised in authorizer callback are caught by SQLite"""
|
||||
|
||||
def raise_exception(action_code, arg1, arg2, db_name, trigger_name):
|
||||
raise ValueError("Test exception from authorizer")
|
||||
|
||||
async with aiosqlite.connect(self.db) as db:
|
||||
await db.set_authorizer(raise_exception)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
await db.execute("CREATE TABLE test_exception (id INTEGER)")
|
||||
|
||||
async def test_connect_error(self):
|
||||
bad_db = Path("/something/that/shouldnt/exist.db")
|
||||
with self.assertRaisesRegex(OperationalError, "unable to open database"):
|
||||
async with aiosqlite.connect(bad_db) as db:
|
||||
self.assertIsNone(db) # should never be reached
|
||||
|
||||
with self.assertRaisesRegex(OperationalError, "unable to open database"):
|
||||
await aiosqlite.connect(bad_db)
|
||||
|
||||
async def test_connect_base_exception(self):
|
||||
# Check if connect task is cancelled, thread is properly closed.
|
||||
def _raise_cancelled_error(*_, **__):
|
||||
raise asyncio.CancelledError("I changed my mind")
|
||||
|
||||
connection = aiosqlite.Connection(lambda: sqlite3.connect(":memory:"), 64)
|
||||
with (
|
||||
patch.object(sqlite3, "connect", side_effect=_raise_cancelled_error),
|
||||
self.assertRaisesRegex(asyncio.CancelledError, "I changed my mind"),
|
||||
):
|
||||
async with connection:
|
||||
...
|
||||
# Terminate the thread here if the test fails to have a clear error.
|
||||
if connection._running:
|
||||
connection.stop()
|
||||
raise AssertionError("connection thread was not stopped")
|
||||
|
||||
async def test_iterdump(self):
|
||||
async with aiosqlite.connect(":memory:") as db:
|
||||
await db.execute("create table foo (i integer, k charvar(250))")
|
||||
await db.executemany(
|
||||
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
|
||||
)
|
||||
|
||||
lines = [line async for line in db.iterdump()]
|
||||
self.assertEqual(
|
||||
lines,
|
||||
[
|
||||
"BEGIN TRANSACTION;",
|
||||
"CREATE TABLE foo (i integer, k charvar(250));",
|
||||
"INSERT INTO \"foo\" VALUES(1,'hello');",
|
||||
"INSERT INTO \"foo\" VALUES(2,'world');",
|
||||
"COMMIT;",
|
||||
],
|
||||
)
|
||||
|
||||
async def test_cursor_on_closed_connection(self):
|
||||
db = await aiosqlite.connect(self.db)
|
||||
|
||||
cursor = await db.execute("select 1, 2")
|
||||
await db.close()
|
||||
with self.assertRaisesRegex(ValueError, "Connection closed"):
|
||||
await cursor.fetchall()
|
||||
with self.assertRaisesRegex(ValueError, "Connection closed"):
|
||||
await cursor.fetchall()
|
||||
|
||||
async def test_cursor_on_closed_connection_loop(self):
|
||||
db = await aiosqlite.connect(self.db)
|
||||
|
||||
cursor = await db.execute("select 1, 2")
|
||||
tasks = []
|
||||
for i in range(100):
|
||||
if i == 50:
|
||||
tasks.append(asyncio.ensure_future(db.close()))
|
||||
tasks.append(asyncio.ensure_future(cursor.fetchall()))
|
||||
for task in tasks:
|
||||
try:
|
||||
await task
|
||||
except sqlite3.ProgrammingError:
|
||||
pass
|
||||
|
||||
async def test_close_blocking_until_transaction_queue_empty(self):
|
||||
db = await aiosqlite.connect(self.db)
|
||||
# Insert transactions into the
|
||||
# transaction queue '_tx'
|
||||
for i in range(1000):
|
||||
await db.execute(f"select 1, {i}")
|
||||
# Wait for all transactions to complete
|
||||
await db.close()
|
||||
# Check no more transaction pending
|
||||
self.assertEqual(db._tx.empty(), True)
|
||||
|
||||
async def test_close_twice(self):
|
||||
db = await aiosqlite.connect(self.db)
|
||||
|
||||
await db.close()
|
||||
|
||||
# no error
|
||||
await db.close()
|
||||
|
||||
async def test_backup_aiosqlite(self):
|
||||
def progress(a, b, c):
|
||||
print(a, b, c)
|
||||
|
||||
async with (
|
||||
aiosqlite.connect(":memory:") as db1,
|
||||
aiosqlite.connect(":memory:") as db2,
|
||||
):
|
||||
await db1.execute("create table foo (i integer, k charvar(250))")
|
||||
await db1.executemany(
|
||||
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
|
||||
)
|
||||
await db1.commit()
|
||||
|
||||
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
|
||||
await db2.execute("select * from foo")
|
||||
|
||||
await db1.backup(db2, progress=progress)
|
||||
|
||||
async with db2.execute("select * from foo") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(rows, [(1, "hello"), (2, "world")])
|
||||
|
||||
async def test_backup_sqlite(self):
|
||||
async with aiosqlite.connect(":memory:") as db1:
|
||||
with sqlite3.connect(":memory:") as db2:
|
||||
await db1.execute("create table foo (i integer, k charvar(250))")
|
||||
await db1.executemany(
|
||||
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
|
||||
)
|
||||
await db1.commit()
|
||||
|
||||
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
|
||||
db2.execute("select * from foo")
|
||||
|
||||
await db1.backup(db2)
|
||||
|
||||
cursor = db2.execute("select * from foo")
|
||||
rows = cursor.fetchall()
|
||||
self.assertEqual(rows, [(1, "hello"), (2, "world")])
|
||||
|
||||
async def test_emits_warning_when_left_open(self):
|
||||
db = await aiosqlite.connect(":memory:")
|
||||
|
||||
with self.assertWarnsRegex(
|
||||
ResourceWarning, r".*was deleted before being closed.*"
|
||||
):
|
||||
del db
|
||||
|
||||
async def test_stop_without_close(self):
|
||||
db = await aiosqlite.connect(":memory:")
|
||||
await db.stop()
|
||||
|
||||
def test_stop_after_event_loop_closed(self):
|
||||
db = None
|
||||
|
||||
async def inner():
|
||||
nonlocal db
|
||||
db = await aiosqlite.connect(":memory:")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(inner())
|
||||
loop.close()
|
||||
|
||||
db.stop()
|
||||
+1
@@ -0,0 +1 @@
|
||||
pip
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: annotated-doc
|
||||
Version: 0.0.4
|
||||
Summary: Document parameters, class attributes, return types, and variables inline, with Annotated.
|
||||
Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <tiangolo@gmail.com>
|
||||
License-Expression: MIT
|
||||
License-File: LICENSE
|
||||
Classifier: Intended Audience :: Information Technology
|
||||
Classifier: Intended Audience :: System Administrators
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Topic :: Internet
|
||||
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Classifier: Topic :: Software Development :: Libraries
|
||||
Classifier: Topic :: Software Development
|
||||
Classifier: Typing :: Typed
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: 3.13
|
||||
Classifier: Programming Language :: Python :: 3.14
|
||||
Project-URL: Homepage, https://github.com/fastapi/annotated-doc
|
||||
Project-URL: Documentation, https://github.com/fastapi/annotated-doc
|
||||
Project-URL: Repository, https://github.com/fastapi/annotated-doc
|
||||
Project-URL: Issues, https://github.com/fastapi/annotated-doc/issues
|
||||
Project-URL: Changelog, https://github.com/fastapi/annotated-doc/release-notes.md
|
||||
Requires-Python: >=3.8
|
||||
Description-Content-Type: text/markdown
|
||||
|
||||
# Annotated Doc
|
||||
|
||||
Document parameters, class attributes, return types, and variables inline, with `Annotated`.
|
||||
|
||||
<a href="https://github.com/fastapi/annotated-doc/actions?query=workflow%3ATest+event%3Apush+branch%3Amain" target="_blank">
|
||||
<img src="https://github.com/fastapi/annotated-doc/actions/workflows/test.yml/badge.svg?event=push&branch=main" alt="Test">
|
||||
</a>
|
||||
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/annotated-doc" target="_blank">
|
||||
<img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/annotated-doc.svg" alt="Coverage">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/annotated-doc" target="_blank">
|
||||
<img src="https://img.shields.io/pypi/v/annotated-doc?color=%2334D058&label=pypi%20package" alt="Package version">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/annotated-doc" target="_blank">
|
||||
<img src="https://img.shields.io/pypi/pyversions/annotated-doc.svg?color=%2334D058" alt="Supported Python versions">
|
||||
</a>
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install annotated-doc
|
||||
```
|
||||
|
||||
Or with `uv`:
|
||||
|
||||
```Python
|
||||
uv add annotated-doc
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import `Doc` and pass a single literal string with the documentation for the specific parameter, class attribute, return type, or variable.
|
||||
|
||||
For example, to document a parameter `name` in a function `hi` you could do:
|
||||
|
||||
```Python
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_doc import Doc
|
||||
|
||||
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
|
||||
print(f"Hi, {name}!")
|
||||
```
|
||||
|
||||
You can also use it to document class attributes:
|
||||
|
||||
```Python
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_doc import Doc
|
||||
|
||||
class User:
|
||||
name: Annotated[str, Doc("The user's name")]
|
||||
age: Annotated[int, Doc("The user's age")]
|
||||
```
|
||||
|
||||
The same way, you could document return types and variables, or anything that could have a type annotation with `Annotated`.
|
||||
|
||||
## Who Uses This
|
||||
|
||||
`annotated-doc` was made for:
|
||||
|
||||
* [FastAPI](https://fastapi.tiangolo.com/)
|
||||
* [Typer](https://typer.tiangolo.com/)
|
||||
* [SQLModel](https://sqlmodel.tiangolo.com/)
|
||||
* [Asyncer](https://asyncer.tiangolo.com/)
|
||||
|
||||
`annotated-doc` is supported by [griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc), which powers reference documentation like the one in the [FastAPI Reference](https://fastapi.tiangolo.com/reference/).
|
||||
|
||||
## Reasons not to use `annotated-doc`
|
||||
|
||||
You are already comfortable with one of the existing docstring formats, like:
|
||||
|
||||
* Sphinx
|
||||
* numpydoc
|
||||
* Google
|
||||
* Keras
|
||||
|
||||
Your team is already comfortable using them.
|
||||
|
||||
You prefer having the documentation about parameters all together in a docstring, separated from the code defining them.
|
||||
|
||||
You care about a specific set of users, using one specific editor, and that editor already has support for the specific docstring format you use.
|
||||
|
||||
## Reasons to use `annotated-doc`
|
||||
|
||||
* No micro-syntax to learn for newcomers, it’s **just Python** syntax.
|
||||
* **Editing** would be already fully supported by default by any editor (current or future) supporting Python syntax, including syntax errors, syntax highlighting, etc.
|
||||
* **Rendering** would be relatively straightforward to implement by static tools (tools that don't need runtime execution), as the information can be extracted from the AST they normally already create.
|
||||
* **Deduplication of information**: the name of a parameter would be defined in a single place, not duplicated inside of a docstring.
|
||||
* **Elimination** of the possibility of having **inconsistencies** when removing a parameter or class variable and **forgetting to remove** its documentation.
|
||||
* **Minimization** of the probability of adding a new parameter or class variable and **forgetting to add its documentation**.
|
||||
* **Elimination** of the possibility of having **inconsistencies** between the **name** of a parameter in the **signature** and the name in the docstring when it is renamed.
|
||||
* **Access** to the documentation string for each symbol at **runtime**, including existing (older) Python versions.
|
||||
* A more formalized way to document other symbols, like type aliases, that could use Annotated.
|
||||
* **Support** for apps using FastAPI, Typer and others.
|
||||
* **AI Accessibility**: AI tools will have an easier way understanding each parameter as the distance from documentation to parameter is much closer.
|
||||
|
||||
## History
|
||||
|
||||
I ([@tiangolo](https://github.com/tiangolo)) originally wanted for this to be part of the Python standard library (in [PEP 727](https://peps.python.org/pep-0727/)), but the proposal was withdrawn as there was a fair amount of negative feedback and opposition.
|
||||
|
||||
The conclusion was that this was better done as an external effort, in a third-party library.
|
||||
|
||||
So, here it is, with a simpler approach, as a third-party library, in a way that can be used by others, starting with FastAPI and friends.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the MIT license.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
annotated_doc-0.0.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
annotated_doc-0.0.4.dist-info/METADATA,sha256=Irm5KJua33dY2qKKAjJ-OhKaVBVIfwFGej_dSe3Z1TU,6566
|
||||
annotated_doc-0.0.4.dist-info/RECORD,,
|
||||
annotated_doc-0.0.4.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
|
||||
annotated_doc-0.0.4.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
||||
annotated_doc-0.0.4.dist-info/licenses/LICENSE,sha256=__Fwd5pqy_ZavbQFwIfxzuF4ZpHkqWpANFF-SlBKDN8,1086
|
||||
annotated_doc/__init__.py,sha256=VuyxxUe80kfEyWnOrCx_Bk8hybo3aKo6RYBlkBBYW8k,52
|
||||
annotated_doc/__pycache__/__init__.cpython-311.pyc,,
|
||||
annotated_doc/__pycache__/main.cpython-311.pyc,,
|
||||
annotated_doc/main.py,sha256=5Zfvxv80SwwLqpRW73AZyZyiM4bWma9QWRbp_cgD20s,1075
|
||||
annotated_doc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pdm-backend (2.4.5)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
[console_scripts]
|
||||
|
||||
[gui_scripts]
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025 Sebastián Ramírez
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import Doc as Doc
|
||||
|
||||
__version__ = "0.0.4"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
class Doc:
|
||||
"""Define the documentation of a type annotation using `Annotated`, to be
|
||||
used in class attributes, function and method parameters, return values,
|
||||
and variables.
|
||||
|
||||
The value should be a positional-only string literal to allow static tools
|
||||
like editors and documentation generators to use it.
|
||||
|
||||
This complements docstrings.
|
||||
|
||||
The string value passed is available in the attribute `documentation`.
|
||||
|
||||
Example:
|
||||
|
||||
```Python
|
||||
from typing import Annotated
|
||||
from annotated_doc import Doc
|
||||
|
||||
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
|
||||
print(f"Hi, {name}!")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, documentation: str, /) -> None:
|
||||
self.documentation = documentation
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Doc({self.documentation!r})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.documentation)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Doc):
|
||||
return NotImplemented
|
||||
return self.documentation == other.documentation
|
||||
+1
@@ -0,0 +1 @@
|
||||
pip
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
Metadata-Version: 2.3
|
||||
Name: annotated-types
|
||||
Version: 0.7.0
|
||||
Summary: Reusable constraint types to use with typing.Annotated
|
||||
Project-URL: Homepage, https://github.com/annotated-types/annotated-types
|
||||
Project-URL: Source, https://github.com/annotated-types/annotated-types
|
||||
Project-URL: Changelog, https://github.com/annotated-types/annotated-types/releases
|
||||
Author-email: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin <s@muelcolvin.com>, Zac Hatfield-Dodds <zac@zhd.dev>
|
||||
License-File: LICENSE
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: Environment :: Console
|
||||
Classifier: Environment :: MacOS X
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Intended Audience :: Information Technology
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Operating System :: POSIX :: Linux
|
||||
Classifier: Operating System :: Unix
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Classifier: Typing :: Typed
|
||||
Requires-Python: >=3.8
|
||||
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.9'
|
||||
Description-Content-Type: text/markdown
|
||||
|
||||
# annotated-types
|
||||
|
||||
[](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
|
||||
[](https://pypi.python.org/pypi/annotated-types)
|
||||
[](https://github.com/annotated-types/annotated-types)
|
||||
[](https://github.com/annotated-types/annotated-types/blob/main/LICENSE)
|
||||
|
||||
[PEP-593](https://peps.python.org/pep-0593/) added `typing.Annotated` as a way of
|
||||
adding context-specific metadata to existing types, and specifies that
|
||||
`Annotated[T, x]` _should_ be treated as `T` by any tool or library without special
|
||||
logic for `x`.
|
||||
|
||||
This package provides metadata objects which can be used to represent common
|
||||
constraints such as upper and lower bounds on scalar values and collection sizes,
|
||||
a `Predicate` marker for runtime checks, and
|
||||
descriptions of how we intend these metadata to be interpreted. In some cases,
|
||||
we also note alternative representations which do not require this package.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install annotated-types
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from annotated_types import Gt, Len, Predicate
|
||||
|
||||
class MyClass:
|
||||
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
|
||||
# Invalid: 17, 18, "19", 19.0, ...
|
||||
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
|
||||
# Invalid: 4, 8, -2, 5.0, "prime", ...
|
||||
|
||||
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
|
||||
# Invalid: (1, 2), ["abc"], [0] * 20
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
_While `annotated-types` avoids runtime checks for performance, users should not
|
||||
construct invalid combinations such as `MultipleOf("non-numeric")` or `Annotated[int, Len(3)]`.
|
||||
Downstream implementors may choose to raise an error, emit a warning, silently ignore
|
||||
a metadata item, etc., if the metadata objects described below are used with an
|
||||
incompatible type - or for any other reason!_
|
||||
|
||||
### Gt, Ge, Lt, Le
|
||||
|
||||
Express inclusive and/or exclusive bounds on orderable values - which may be numbers,
|
||||
dates, times, strings, sets, etc. Note that the boundary value need not be of the
|
||||
same type that was annotated, so long as they can be compared: `Annotated[int, Gt(1.5)]`
|
||||
is fine, for example, and implies that the value is an integer x such that `x > 1.5`.
|
||||
|
||||
We suggest that implementors may also interpret `functools.partial(operator.le, 1.5)`
|
||||
as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependency on
|
||||
the `annotated-types` package.
|
||||
|
||||
To be explicit, these types have the following meanings:
|
||||
|
||||
* `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum
|
||||
* `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum
|
||||
* `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum
|
||||
* `Le(x)` - value must be "Less than or Equal" to `x` - equivalent to inclusive maximum
|
||||
|
||||
### Interval
|
||||
|
||||
`Interval(gt, ge, lt, le)` allows you to specify an upper and lower bound with a single
|
||||
metadata object. `None` attributes should be ignored, and non-`None` attributes
|
||||
treated as per the single bounds above.
|
||||
|
||||
### MultipleOf
|
||||
|
||||
`MultipleOf(multiple_of=x)` might be interpreted in two ways:
|
||||
|
||||
1. Python semantics, implying `value % multiple_of == 0`, or
|
||||
2. [JSONschema semantics](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1),
|
||||
where `int(value / multiple_of) == value / multiple_of`.
|
||||
|
||||
We encourage users to be aware of these two common interpretations and their
|
||||
distinct behaviours, especially since very large or non-integer numbers make
|
||||
it easy to cause silent data corruption due to floating-point imprecision.
|
||||
|
||||
We encourage libraries to carefully document which interpretation they implement.
|
||||
|
||||
### MinLen, MaxLen, Len
|
||||
|
||||
`Len()` implies that `min_length <= len(value) <= max_length` - lower and upper bounds are inclusive.
|
||||
|
||||
As well as `Len()` which can optionally include upper and lower bounds, we also
|
||||
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_length=x)`
|
||||
and `Len(max_length=y)` respectively.
|
||||
|
||||
`Len`, `MinLen`, and `MaxLen` may be used with any type which supports `len(value)`.
|
||||
|
||||
Examples of usage:
|
||||
|
||||
* `Annotated[list, MaxLen(10)]` (or `Annotated[list, Len(max_length=10))`) - list must have a length of 10 or less
|
||||
* `Annotated[str, MaxLen(10)]` - string must have a length of 10 or less
|
||||
* `Annotated[list, MinLen(3))` (or `Annotated[list, Len(min_length=3))`) - list must have a length of 3 or more
|
||||
* `Annotated[list, Len(4, 6)]` - list must have a length of 4, 5, or 6
|
||||
* `Annotated[list, Len(8, 8)]` - list must have a length of exactly 8
|
||||
|
||||
#### Changed in v0.4.0
|
||||
|
||||
* `min_inclusive` has been renamed to `min_length`, no change in meaning
|
||||
* `max_exclusive` has been renamed to `max_length`, upper bound is now **inclusive** instead of **exclusive**
|
||||
* The recommendation that slices are interpreted as `Len` has been removed due to ambiguity and different semantic
|
||||
meaning of the upper bound in slices vs. `Len`
|
||||
|
||||
See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) for discussion.
|
||||
|
||||
### Timezone
|
||||
|
||||
`Timezone` can be used with a `datetime` or a `time` to express which timezones
|
||||
are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime.
|
||||
`Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis))
|
||||
expresses that any timezone-aware datetime is allowed. You may also pass a specific
|
||||
timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects)
|
||||
object such as `Timezone(timezone.utc)` or `Timezone("Africa/Abidjan")` to express that you only
|
||||
allow a specific timezone, though we note that this is often a symptom of fragile design.
|
||||
|
||||
#### Changed in v0.x.x
|
||||
|
||||
* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
|
||||
`timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
|
||||
|
||||
### Unit
|
||||
|
||||
`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
|
||||
a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
|
||||
would be a float representing a velocity in meters per second.
|
||||
|
||||
Please note that `annotated_types` itself makes no attempt to parse or validate
|
||||
the unit string in any way. That is left entirely to downstream libraries,
|
||||
such as [`pint`](https://pint.readthedocs.io) or
|
||||
[`astropy.units`](https://docs.astropy.org/en/stable/units/).
|
||||
|
||||
An example of how a library might use this metadata:
|
||||
|
||||
```python
|
||||
from annotated_types import Unit
|
||||
from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
|
||||
|
||||
# given a type annotated with a unit:
|
||||
Meters = Annotated[float, Unit("m")]
|
||||
|
||||
|
||||
# you can cast the annotation to a specific unit type with any
|
||||
# callable that accepts a string and returns the desired type
|
||||
T = TypeVar("T")
|
||||
def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
|
||||
if get_origin(tp) is Annotated:
|
||||
for arg in get_args(tp):
|
||||
if isinstance(arg, Unit):
|
||||
return unit_cls(arg.unit)
|
||||
return None
|
||||
|
||||
|
||||
# using `pint`
|
||||
import pint
|
||||
pint_unit = cast_unit(Meters, pint.Unit)
|
||||
|
||||
|
||||
# using `astropy.units`
|
||||
import astropy.units as u
|
||||
astropy_unit = cast_unit(Meters, u.Unit)
|
||||
```
|
||||
|
||||
### Predicate
|
||||
|
||||
`Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
|
||||
Users should prefer the statically inspectable metadata above, but if you need
|
||||
the full power and flexibility of arbitrary runtime predicates... here it is.
|
||||
|
||||
For some common constraints, we provide generic types:
|
||||
|
||||
* `IsLower = Annotated[T, Predicate(str.islower)]`
|
||||
* `IsUpper = Annotated[T, Predicate(str.isupper)]`
|
||||
* `IsDigit = Annotated[T, Predicate(str.isdigit)]`
|
||||
* `IsFinite = Annotated[T, Predicate(math.isfinite)]`
|
||||
* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]`
|
||||
* `IsNan = Annotated[T, Predicate(math.isnan)]`
|
||||
* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]`
|
||||
* `IsInfinite = Annotated[T, Predicate(math.isinf)]`
|
||||
* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]`
|
||||
|
||||
so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer
|
||||
(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`.
|
||||
|
||||
Some libraries might have special logic to handle known or understandable predicates,
|
||||
for example by checking for `str.isdigit` and using its presence to both call custom
|
||||
logic to enforce digit-only strings, and customise some generated external schema.
|
||||
Users are therefore encouraged to avoid indirection like `lambda s: s.lower()`, in
|
||||
favor of introspectable methods such as `str.lower` or `re.compile("pattern").search`.
|
||||
|
||||
To enable basic negation of commonly used predicates like `math.isnan` without introducing introspection that makes it impossible for implementers to introspect the predicate we provide a `Not` wrapper that simply negates the predicate in an introspectable manner. Several of the predicates listed above are created in this manner.
|
||||
|
||||
We do not specify what behaviour should be expected for predicates that raise
|
||||
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
|
||||
skip invalid constraints, or statically raise an error; or it might try calling it
|
||||
and then propagate or discard the resulting
|
||||
`TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object`
|
||||
exception. We encourage libraries to document the behaviour they choose.
|
||||
|
||||
### Doc
|
||||
|
||||
`doc()` can be used to add documentation information in `Annotated`, for function and method parameters, variables, class attributes, return types, and any place where `Annotated` can be used.
|
||||
|
||||
It expects a value that can be statically analyzed, as the main use case is for static analysis, editors, documentation generators, and similar tools.
|
||||
|
||||
It returns a `DocInfo` class with a single attribute `documentation` containing the value passed to `doc()`.
|
||||
|
||||
This is the early adopter's alternative form of the [`typing-doc` proposal](https://github.com/tiangolo/fastapi/blob/typing-doc/typing_doc.md).
|
||||
|
||||
### Integrating downstream types with `GroupedMetadata`
|
||||
|
||||
Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata.
|
||||
This can help reduce verbosity and cognitive overhead for users.
|
||||
For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
from annotated_types import GroupedMetadata, Ge
|
||||
|
||||
@dataclass
|
||||
class Field(GroupedMetadata):
|
||||
ge: int | None = None
|
||||
description: str | None = None
|
||||
|
||||
def __iter__(self) -> Iterator[object]:
|
||||
# Iterating over a GroupedMetadata object should yield annotated-types
|
||||
# constraint metadata objects which describe it as fully as possible,
|
||||
# and may include other unknown objects too.
|
||||
if self.ge is not None:
|
||||
yield Ge(self.ge)
|
||||
if self.description is not None:
|
||||
yield Description(self.description)
|
||||
```
|
||||
|
||||
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
|
||||
|
||||
Libraries consuming annotated-types should also ignore any metadata they do not recongize that came from unpacking a `GroupedMetadata`, just like they ignore unrecognized metadata in `Annotated` itself.
|
||||
|
||||
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
|
||||
|
||||
### Consuming metadata
|
||||
|
||||
We intend to not be prescriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103).
|
||||
|
||||
It is up to the implementer to determine how this metadata is used.
|
||||
You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases.
|
||||
|
||||
## Design & History
|
||||
|
||||
This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic
|
||||
and Hypothesis, with the goal of making it as easy as possible for end-users to
|
||||
provide more informative annotations for use by runtime libraries.
|
||||
|
||||
It is deliberately minimal, and following PEP-593 allows considerable downstream
|
||||
discretion in what (if anything!) they choose to support. Nonetheless, we expect
|
||||
that staying simple and covering _only_ the most common use-cases will give users
|
||||
and maintainers the best experience we can. If you'd like more constraints for your
|
||||
types - follow our lead, by defining them and documenting them downstream!
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
annotated_types-0.7.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
annotated_types-0.7.0.dist-info/METADATA,sha256=7ltqxksJJ0wCYFGBNIQCWTlWQGeAH0hRFdnK3CB895E,15046
|
||||
annotated_types-0.7.0.dist-info/RECORD,,
|
||||
annotated_types-0.7.0.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
|
||||
annotated_types-0.7.0.dist-info/licenses/LICENSE,sha256=_hBJiEsaDZNCkB6I4H8ykl0ksxIdmXK2poBfuYJLCV0,1083
|
||||
annotated_types/__init__.py,sha256=RynLsRKUEGI0KimXydlD1fZEfEzWwDo0Uon3zOKhG1Q,13819
|
||||
annotated_types/__pycache__/__init__.cpython-311.pyc,,
|
||||
annotated_types/__pycache__/test_cases.cpython-311.pyc,,
|
||||
annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
annotated_types/test_cases.py,sha256=zHFX6EpcMbGJ8FzBYDbO56bPwx_DYIVSKbZM-4B3_lg,6421
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: hatchling 1.24.2
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 the contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from datetime import tzinfo
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union
|
||||
|
||||
if sys.version_info < (3, 8):
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
else:
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing_extensions import Annotated, Literal
|
||||
else:
|
||||
from typing import Annotated, Literal
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
EllipsisType = type(Ellipsis)
|
||||
KW_ONLY = {}
|
||||
SLOTS = {}
|
||||
else:
|
||||
from types import EllipsisType
|
||||
|
||||
KW_ONLY = {"kw_only": True}
|
||||
SLOTS = {"slots": True}
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseMetadata',
|
||||
'GroupedMetadata',
|
||||
'Gt',
|
||||
'Ge',
|
||||
'Lt',
|
||||
'Le',
|
||||
'Interval',
|
||||
'MultipleOf',
|
||||
'MinLen',
|
||||
'MaxLen',
|
||||
'Len',
|
||||
'Timezone',
|
||||
'Predicate',
|
||||
'LowerCase',
|
||||
'UpperCase',
|
||||
'IsDigits',
|
||||
'IsFinite',
|
||||
'IsNotFinite',
|
||||
'IsNan',
|
||||
'IsNotNan',
|
||||
'IsInfinite',
|
||||
'IsNotInfinite',
|
||||
'doc',
|
||||
'DocInfo',
|
||||
'__version__',
|
||||
)
|
||||
|
||||
__version__ = '0.7.0'
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
# arguments that start with __ are considered
|
||||
# positional only
|
||||
# see https://peps.python.org/pep-0484/#positional-only-arguments
|
||||
|
||||
|
||||
class SupportsGt(Protocol):
|
||||
def __gt__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsGe(Protocol):
|
||||
def __ge__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsLt(Protocol):
|
||||
def __lt__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsLe(Protocol):
|
||||
def __le__(self: T, __other: T) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class SupportsMod(Protocol):
|
||||
def __mod__(self: T, __other: T) -> T:
|
||||
...
|
||||
|
||||
|
||||
class SupportsDiv(Protocol):
|
||||
def __div__(self: T, __other: T) -> T:
|
||||
...
|
||||
|
||||
|
||||
class BaseMetadata:
|
||||
"""Base class for all metadata.
|
||||
|
||||
This exists mainly so that implementers
|
||||
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Gt(BaseMetadata):
|
||||
"""Gt(gt=x) implies that the value must be greater than x.
|
||||
|
||||
It can be used with any type that supports the ``>`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
gt: SupportsGt
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Ge(BaseMetadata):
|
||||
"""Ge(ge=x) implies that the value must be greater than or equal to x.
|
||||
|
||||
It can be used with any type that supports the ``>=`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
ge: SupportsGe
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Lt(BaseMetadata):
|
||||
"""Lt(lt=x) implies that the value must be less than x.
|
||||
|
||||
It can be used with any type that supports the ``<`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
lt: SupportsLt
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Le(BaseMetadata):
|
||||
"""Le(le=x) implies that the value must be less than or equal to x.
|
||||
|
||||
It can be used with any type that supports the ``<=`` operator,
|
||||
including numbers, dates and times, strings, sets, and so on.
|
||||
"""
|
||||
|
||||
le: SupportsLe
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GroupedMetadata(Protocol):
|
||||
"""A grouping of multiple objects, like typing.Unpack.
|
||||
|
||||
`GroupedMetadata` on its own is not metadata and has no meaning.
|
||||
All of the constraints and metadata should be fully expressable
|
||||
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
|
||||
|
||||
Concrete implementations should override `GroupedMetadata.__iter__()`
|
||||
to add their own metadata.
|
||||
For example:
|
||||
|
||||
>>> @dataclass
|
||||
>>> class Field(GroupedMetadata):
|
||||
>>> gt: float | None = None
|
||||
>>> description: str | None = None
|
||||
...
|
||||
>>> def __iter__(self) -> Iterable[object]:
|
||||
>>> if self.gt is not None:
|
||||
>>> yield Gt(self.gt)
|
||||
>>> if self.description is not None:
|
||||
>>> yield Description(self.gt)
|
||||
|
||||
Also see the implementation of `Interval` below for an example.
|
||||
|
||||
Parsers should recognize this and unpack it so that it can be used
|
||||
both with and without unpacking:
|
||||
|
||||
- `Annotated[int, Field(...)]` (parser must unpack Field)
|
||||
- `Annotated[int, *Field(...)]` (PEP-646)
|
||||
""" # noqa: trailing-whitespace
|
||||
|
||||
@property
|
||||
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
|
||||
return True
|
||||
|
||||
def __iter__(self) -> Iterator[object]:
|
||||
...
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
__slots__ = () # allow subclasses to use slots
|
||||
|
||||
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
|
||||
# Basic ABC like functionality without the complexity of an ABC
|
||||
super().__init_subclass__(*args, **kwargs)
|
||||
if cls.__iter__ is GroupedMetadata.__iter__:
|
||||
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
|
||||
|
||||
def __iter__(self) -> Iterator[object]: # noqa: F811
|
||||
raise NotImplementedError # more helpful than "None has no attribute..." type errors
|
||||
|
||||
|
||||
@dataclass(frozen=True, **KW_ONLY, **SLOTS)
|
||||
class Interval(GroupedMetadata):
|
||||
"""Interval can express inclusive or exclusive bounds with a single object.
|
||||
|
||||
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
|
||||
are interpreted the same way as the single-bound constraints.
|
||||
"""
|
||||
|
||||
gt: Union[SupportsGt, None] = None
|
||||
ge: Union[SupportsGe, None] = None
|
||||
lt: Union[SupportsLt, None] = None
|
||||
le: Union[SupportsLe, None] = None
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]:
|
||||
"""Unpack an Interval into zero or more single-bounds."""
|
||||
if self.gt is not None:
|
||||
yield Gt(self.gt)
|
||||
if self.ge is not None:
|
||||
yield Ge(self.ge)
|
||||
if self.lt is not None:
|
||||
yield Lt(self.lt)
|
||||
if self.le is not None:
|
||||
yield Le(self.le)
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MultipleOf(BaseMetadata):
|
||||
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
|
||||
|
||||
1. Python semantics, implying ``value % multiple_of == 0``, or
|
||||
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
|
||||
|
||||
We encourage users to be aware of these two common interpretations,
|
||||
and libraries to carefully document which they implement.
|
||||
"""
|
||||
|
||||
multiple_of: Union[SupportsDiv, SupportsMod]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MinLen(BaseMetadata):
|
||||
"""
|
||||
MinLen() implies minimum inclusive length,
|
||||
e.g. ``len(value) >= min_length``.
|
||||
"""
|
||||
|
||||
min_length: Annotated[int, Ge(0)]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class MaxLen(BaseMetadata):
|
||||
"""
|
||||
MaxLen() implies maximum inclusive length,
|
||||
e.g. ``len(value) <= max_length``.
|
||||
"""
|
||||
|
||||
max_length: Annotated[int, Ge(0)]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Len(GroupedMetadata):
|
||||
"""
|
||||
Len() implies that ``min_length <= len(value) <= max_length``.
|
||||
|
||||
Upper bound may be omitted or ``None`` to indicate no upper length bound.
|
||||
"""
|
||||
|
||||
min_length: Annotated[int, Ge(0)] = 0
|
||||
max_length: Optional[Annotated[int, Ge(0)]] = None
|
||||
|
||||
def __iter__(self) -> Iterator[BaseMetadata]:
|
||||
"""Unpack a Len into zone or more single-bounds."""
|
||||
if self.min_length > 0:
|
||||
yield MinLen(self.min_length)
|
||||
if self.max_length is not None:
|
||||
yield MaxLen(self.max_length)
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Timezone(BaseMetadata):
|
||||
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
|
||||
|
||||
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
|
||||
``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be
|
||||
tz-aware but any timezone is allowed.
|
||||
|
||||
You may also pass a specific timezone string or tzinfo object such as
|
||||
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
|
||||
you only allow a specific timezone, though we note that this is often
|
||||
a symptom of poor design.
|
||||
"""
|
||||
|
||||
tz: Union[str, tzinfo, EllipsisType, None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Unit(BaseMetadata):
|
||||
"""Indicates that the value is a physical quantity with the specified unit.
|
||||
|
||||
It is intended for usage with numeric types, where the value represents the
|
||||
magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
|
||||
or ``speed: Annotated[float, Unit('m/s')]``.
|
||||
|
||||
Interpretation of the unit string is left to the discretion of the consumer.
|
||||
It is suggested to follow conventions established by python libraries that work
|
||||
with physical quantities, such as
|
||||
|
||||
- ``pint`` : <https://pint.readthedocs.io/en/stable/>
|
||||
- ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
|
||||
|
||||
For indicating a quantity with a certain dimensionality but without a specific unit
|
||||
it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
|
||||
Note, however, ``annotated_types`` itself makes no use of the unit string.
|
||||
"""
|
||||
|
||||
unit: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class Predicate(BaseMetadata):
|
||||
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
|
||||
|
||||
Users should prefer statically inspectable metadata, but if you need the full
|
||||
power and flexibility of arbitrary runtime predicates... here it is.
|
||||
|
||||
We provide a few predefined predicates for common string constraints:
|
||||
``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and
|
||||
``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which
|
||||
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
|
||||
|
||||
Some libraries might have special logic to handle certain predicates, e.g. by
|
||||
checking for `str.isdigit` and using its presence to both call custom logic to
|
||||
enforce digit-only strings, and customise some generated external schema.
|
||||
|
||||
We do not specify what behaviour should be expected for predicates that raise
|
||||
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
|
||||
skip invalid constraints, or statically raise an error; or it might try calling it
|
||||
and then propagate or discard the resulting exception.
|
||||
"""
|
||||
|
||||
func: Callable[[Any], bool]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if getattr(self.func, "__name__", "<lambda>") == "<lambda>":
|
||||
return f"{self.__class__.__name__}({self.func!r})"
|
||||
if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and (
|
||||
namespace := getattr(self.func.__self__, "__name__", None)
|
||||
):
|
||||
return f"{self.__class__.__name__}({namespace}.{self.func.__name__})"
|
||||
if isinstance(self.func, type(str.isascii)): # method descriptor
|
||||
return f"{self.__class__.__name__}({self.func.__qualname__})"
|
||||
return f"{self.__class__.__name__}({self.func.__name__})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Not:
|
||||
func: Callable[[Any], bool]
|
||||
|
||||
def __call__(self, __v: Any) -> bool:
|
||||
return not self.func(__v)
|
||||
|
||||
|
||||
_StrType = TypeVar("_StrType", bound=str)
|
||||
|
||||
LowerCase = Annotated[_StrType, Predicate(str.islower)]
|
||||
"""
|
||||
Return True if the string is a lowercase string, False otherwise.
|
||||
|
||||
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
|
||||
""" # noqa: E501
|
||||
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
|
||||
"""
|
||||
Return True if the string is an uppercase string, False otherwise.
|
||||
|
||||
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
|
||||
""" # noqa: E501
|
||||
IsDigit = Annotated[_StrType, Predicate(str.isdigit)]
|
||||
IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63
|
||||
"""
|
||||
Return True if the string is a digit string, False otherwise.
|
||||
|
||||
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
|
||||
""" # noqa: E501
|
||||
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
|
||||
"""
|
||||
Return True if all characters in the string are ASCII, False otherwise.
|
||||
|
||||
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
|
||||
"""
|
||||
|
||||
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
|
||||
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
|
||||
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
|
||||
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
|
||||
"""Return True if x is one of infinity or NaN, and False otherwise"""
|
||||
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
|
||||
"""Return True if x is a NaN (not a number), and False otherwise."""
|
||||
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
|
||||
"""Return True if x is anything but NaN (not a number), and False otherwise."""
|
||||
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
|
||||
"""Return True if x is a positive or negative infinity, and False otherwise."""
|
||||
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
|
||||
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
|
||||
|
||||
try:
|
||||
from typing_extensions import DocInfo, doc # type: ignore [attr-defined]
|
||||
except ImportError:
|
||||
|
||||
@dataclass(frozen=True, **SLOTS)
|
||||
class DocInfo: # type: ignore [no-redef]
|
||||
""" "
|
||||
The return value of doc(), mainly to be used by tools that want to extract the
|
||||
Annotated documentation at runtime.
|
||||
"""
|
||||
|
||||
documentation: str
|
||||
"""The documentation string passed to doc()."""
|
||||
|
||||
def doc(
|
||||
documentation: str,
|
||||
) -> DocInfo:
|
||||
"""
|
||||
Add documentation to a type annotation inside of Annotated.
|
||||
|
||||
For example:
|
||||
|
||||
>>> def hi(name: Annotated[int, doc("The name of the user")]) -> None: ...
|
||||
"""
|
||||
return DocInfo(documentation)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+151
@@ -0,0 +1,151 @@
|
||||
import math
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from typing_extensions import Annotated
|
||||
else:
|
||||
from typing import Annotated
|
||||
|
||||
import annotated_types as at
|
||||
|
||||
|
||||
class Case(NamedTuple):
|
||||
"""
|
||||
A test case for `annotated_types`.
|
||||
"""
|
||||
|
||||
annotation: Any
|
||||
valid_cases: Iterable[Any]
|
||||
invalid_cases: Iterable[Any]
|
||||
|
||||
|
||||
def cases() -> Iterable[Case]:
|
||||
# Gt, Ge, Lt, Le
|
||||
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
|
||||
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(date(2000, 1, 1))],
|
||||
[date(2000, 1, 2), date(2000, 1, 3)],
|
||||
[date(2000, 1, 1), date(1999, 12, 31)],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Gt(Decimal('1.123'))],
|
||||
[Decimal('1.1231'), Decimal('123')],
|
||||
[Decimal('1.123'), Decimal('0')],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
|
||||
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
|
||||
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
|
||||
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
|
||||
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
|
||||
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
)
|
||||
|
||||
# Interval
|
||||
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
|
||||
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
|
||||
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
|
||||
yield Case(
|
||||
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
|
||||
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
|
||||
)
|
||||
|
||||
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
|
||||
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
|
||||
|
||||
# lengths
|
||||
|
||||
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
|
||||
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
|
||||
yield Case(Annotated[List[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
|
||||
yield Case(Annotated[List[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
|
||||
|
||||
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
|
||||
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
|
||||
yield Case(Annotated[List[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
|
||||
yield Case(Annotated[List[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
|
||||
|
||||
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
|
||||
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
|
||||
|
||||
yield Case(Annotated[Dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
|
||||
yield Case(Annotated[Set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
|
||||
yield Case(Annotated[Tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
|
||||
|
||||
# Timezone
|
||||
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone(timezone.utc)],
|
||||
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
|
||||
)
|
||||
yield Case(
|
||||
Annotated[datetime, at.Timezone('Europe/London')],
|
||||
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
|
||||
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
|
||||
)
|
||||
|
||||
# Quantity
|
||||
|
||||
yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m'))
|
||||
|
||||
# predicate types
|
||||
|
||||
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
|
||||
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
|
||||
yield Case(at.IsDigit[str], ['123'], ['', 'ab', 'a1b2'])
|
||||
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
|
||||
|
||||
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
|
||||
|
||||
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
|
||||
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
|
||||
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
|
||||
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
|
||||
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
|
||||
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
|
||||
|
||||
# check stacked predicates
|
||||
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
|
||||
|
||||
# doc
|
||||
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
|
||||
|
||||
# custom GroupedMetadata
|
||||
class MyCustomGroupedMetadata(at.GroupedMetadata):
|
||||
def __iter__(self) -> Iterator[at.Predicate]:
|
||||
yield at.Predicate(lambda x: float(x).is_integer())
|
||||
|
||||
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])
|
||||
+1
@@ -0,0 +1 @@
|
||||
pip
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: anyio
|
||||
Version: 4.14.1
|
||||
Summary: High-level concurrency and networking framework on top of asyncio or Trio
|
||||
Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
|
||||
License-Expression: MIT
|
||||
Project-URL: Documentation, https://anyio.readthedocs.io/en/latest/
|
||||
Project-URL: Changelog, https://anyio.readthedocs.io/en/stable/versionhistory.html
|
||||
Project-URL: Source code, https://github.com/agronholm/anyio
|
||||
Project-URL: Issue tracker, https://github.com/agronholm/anyio/issues
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Framework :: AnyIO
|
||||
Classifier: Typing :: Typed
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: 3.13
|
||||
Classifier: Programming Language :: Python :: 3.14
|
||||
Classifier: Programming Language :: Python :: 3.15
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: exceptiongroup>=1.0.2; python_version < "3.11"
|
||||
Requires-Dist: idna>=2.8
|
||||
Requires-Dist: typing_extensions>=4.5; python_version < "3.13"
|
||||
Provides-Extra: trio
|
||||
Requires-Dist: trio>=0.32.0; extra == "trio"
|
||||
Dynamic: license-file
|
||||
|
||||
.. image:: https://github.com/agronholm/anyio/actions/workflows/test.yml/badge.svg
|
||||
:target: https://github.com/agronholm/anyio/actions/workflows/test.yml
|
||||
:alt: Build Status
|
||||
.. image:: https://coveralls.io/repos/github/agronholm/anyio/badge.svg?branch=master
|
||||
:target: https://coveralls.io/github/agronholm/anyio?branch=master
|
||||
:alt: Code Coverage
|
||||
.. image:: https://readthedocs.org/projects/anyio/badge/?version=latest
|
||||
:target: https://anyio.readthedocs.io/en/latest/?badge=latest
|
||||
:alt: Documentation
|
||||
.. image:: https://badges.gitter.im/gitterHQ/gitter.svg
|
||||
:target: https://gitter.im/python-trio/AnyIO
|
||||
:alt: Gitter chat
|
||||
.. image:: https://tidelift.com/badges/package/pypi/anyio
|
||||
:target: https://tidelift.com/subscription/pkg/pypi-anyio
|
||||
:alt: Tidelift
|
||||
|
||||
AnyIO is an asynchronous networking and concurrency library that works on top of either asyncio_ or
|
||||
Trio_. It implements Trio-like `structured concurrency`_ (SC) on top of asyncio and works in harmony
|
||||
with the native SC of Trio itself.
|
||||
|
||||
Applications and libraries written against AnyIO's API will run unmodified on either asyncio_ or
|
||||
Trio_. AnyIO can also be adopted into a library or application incrementally – bit by bit, no full
|
||||
refactoring necessary. It will blend in with the native libraries of your chosen backend.
|
||||
|
||||
To find out why you might want to use AnyIO's APIs instead of asyncio's, you can read about it
|
||||
`here <https://anyio.readthedocs.io/en/stable/why.html>`_.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
View full documentation at: https://anyio.readthedocs.io/
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
AnyIO offers the following functionality:
|
||||
|
||||
* Task groups (nurseries_ in trio terminology)
|
||||
* High-level networking (TCP, UDP and UNIX sockets)
|
||||
|
||||
* `Happy eyeballs`_ algorithm for TCP connections (more robust than that of asyncio on Python
|
||||
3.8)
|
||||
* async/await style UDP sockets (unlike asyncio where you still have to use Transports and
|
||||
Protocols)
|
||||
|
||||
* A versatile API for byte streams and object streams
|
||||
* Inter-task synchronization and communication (locks, conditions, events, semaphores, object
|
||||
streams)
|
||||
* Worker threads
|
||||
* Subprocesses
|
||||
* Subinterpreter support for code parallelization (on Python 3.13 and later)
|
||||
* Asynchronous file I/O (using worker threads)
|
||||
* Signal handling
|
||||
* Asynchronous versions of the functools_ and itertools_ modules
|
||||
|
||||
AnyIO also comes with its own pytest_ plugin which also supports asynchronous fixtures.
|
||||
It even works with the popular Hypothesis_ library.
|
||||
|
||||
.. _asyncio: https://docs.python.org/3/library/asyncio.html
|
||||
.. _Trio: https://github.com/python-trio/trio
|
||||
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
|
||||
.. _nurseries: https://trio.readthedocs.io/en/stable/reference-core.html#nurseries-and-spawning
|
||||
.. _Happy eyeballs: https://en.wikipedia.org/wiki/Happy_Eyeballs
|
||||
.. _pytest: https://docs.pytest.org/en/latest/
|
||||
.. _functools: https://docs.python.org/3/library/functools.html
|
||||
.. _itertools: https://docs.python.org/3/library/itertools.html
|
||||
.. _Hypothesis: https://hypothesis.works/
|
||||
|
||||
Security contact information
|
||||
----------------------------
|
||||
|
||||
To report a security vulnerability, please use the `Tidelift security contact`_.
|
||||
Tidelift will coordinate the fix and disclosure.
|
||||
|
||||
.. _Tidelift security contact: https://tidelift.com/security
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
anyio-4.14.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
anyio-4.14.1.dist-info/METADATA,sha256=bfkjYaZLYPsPI5JV_Gn7HYF65mteyE8nhjaI0ZqC4L4,4645
|
||||
anyio-4.14.1.dist-info/RECORD,,
|
||||
anyio-4.14.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
||||
anyio-4.14.1.dist-info/entry_points.txt,sha256=_d6Yu6uiaZmNe0CydowirE9Cmg7zUL2g08tQpoS3Qvc,39
|
||||
anyio-4.14.1.dist-info/licenses/LICENSE,sha256=U2GsncWPLvX9LpsJxoKXwX8ElQkJu8gCO9uC6s8iwrA,1081
|
||||
anyio-4.14.1.dist-info/scm_file_list.json,sha256=wDSXGv8Ehn5ZW5BhB-RlaAc16zY_OfO27qrlMfMMZy8,3654
|
||||
anyio-4.14.1.dist-info/scm_version.json,sha256=gw22Q2aBbdiYhyMbObTYNN7BN-wSpzOCktNiAuulRN8,161
|
||||
anyio-4.14.1.dist-info/top_level.txt,sha256=QglSMiWX8_5dpoVAEIHdEYzvqFMdSYWmCj6tYw2ITkQ,6
|
||||
anyio/__init__.py,sha256=HitUIfzvAojSeaHVmJ9rFn8k_yI63G6s_jUL2QChf4U,6405
|
||||
anyio/__pycache__/__init__.cpython-311.pyc,,
|
||||
anyio/__pycache__/from_thread.cpython-311.pyc,,
|
||||
anyio/__pycache__/functools.cpython-311.pyc,,
|
||||
anyio/__pycache__/itertools.cpython-311.pyc,,
|
||||
anyio/__pycache__/lowlevel.cpython-311.pyc,,
|
||||
anyio/__pycache__/pytest_plugin.cpython-311.pyc,,
|
||||
anyio/__pycache__/to_interpreter.cpython-311.pyc,,
|
||||
anyio/__pycache__/to_process.cpython-311.pyc,,
|
||||
anyio/__pycache__/to_thread.cpython-311.pyc,,
|
||||
anyio/_backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
anyio/_backends/__pycache__/__init__.cpython-311.pyc,,
|
||||
anyio/_backends/__pycache__/_asyncio.cpython-311.pyc,,
|
||||
anyio/_backends/__pycache__/_trio.cpython-311.pyc,,
|
||||
anyio/_backends/_asyncio.py,sha256=-q-5gUYg_r5SsN-OYbQnF_lvtW0v51-dFlsU8_gduWA,102077
|
||||
anyio/_backends/_trio.py,sha256=vR0ZgxVnOo4AHhHcHVG0worMc-3ZNpAZ6Vxh0m0ZZC0,45189
|
||||
anyio/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
anyio/_core/__pycache__/__init__.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_asyncio_selector_thread.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_contextmanagers.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_eventloop.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_exceptions.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_fileio.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_resources.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_signals.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_sockets.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_streams.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_subprocesses.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_synchronization.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_tasks.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_tempfile.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_testing.cpython-311.pyc,,
|
||||
anyio/_core/__pycache__/_typedattr.cpython-311.pyc,,
|
||||
anyio/_core/_asyncio_selector_thread.py,sha256=2PdxFM3cs02Kp6BSppbvmRT7q7asreTW5FgBxEsflBo,5626
|
||||
anyio/_core/_contextmanagers.py,sha256=YInBCabiEeS-UaP_Jdxa1CaFC71ETPW8HZTHIM8Rsc8,7215
|
||||
anyio/_core/_eventloop.py,sha256=ByZUeJD9alMfcyTseRo5IzTO0IltEul_Gyq9iqSjqDk,6658
|
||||
anyio/_core/_exceptions.py,sha256=OfzLO4Z3Hog1TnipbIn72YNtkoYxS4lHW9MqKDeGc88,4936
|
||||
anyio/_core/_fileio.py,sha256=hHfyV0bXDL-R2ZNnInwse3nmTAd36AIz1cBxgmAwzAQ,31358
|
||||
anyio/_core/_resources.py,sha256=NbmU5O5UX3xEyACnkmYX28Fmwdl-f-ny0tHym26e0w0,435
|
||||
anyio/_core/_signals.py,sha256=mjTBB2hTKNPRlU0IhnijeQedpWOGERDiMjSlJQsFrug,1016
|
||||
anyio/_core/_sockets.py,sha256=9FU423j52XBBfGVr6MdzPTdyw8bGrzApZ5m338-AtsY,35286
|
||||
anyio/_core/_streams.py,sha256=FczFwIgDpnkK0bODWJXMpsUJYdvAD04kaUaGzJU8DK0,1806
|
||||
anyio/_core/_subprocesses.py,sha256=tkmkPKEkEaiMD8C9WRZBlmgjOYRDRbZdte6e-unay2E,7916
|
||||
anyio/_core/_synchronization.py,sha256=jn2nIbTRlBAUXL-mx_a3I_VnasF8GbVFpBRp2-YwCx0,21591
|
||||
anyio/_core/_tasks.py,sha256=ELL2jscaSW0Jw_xA6MtQlm3xwvFEzjTbc1u9Tteyt0I,13244
|
||||
anyio/_core/_tempfile.py,sha256=jE2w59FRF3yRo4vjkjfZF2YcqsBZvc66VWRwrJGDYGk,19624
|
||||
anyio/_core/_testing.py,sha256=u7MPqGXwpTxqI7hclSdNA30z2GH1Nw258uwKvy_RfBg,2340
|
||||
anyio/_core/_typedattr.py,sha256=P4ozZikn3-DbpoYcvyghS_FOYAgbmUxeoU8-L_07pZM,2508
|
||||
anyio/abc/__init__.py,sha256=6mWhcl_pGXhrgZVHP_TCfMvIXIOp9mroEFM90fYCU_U,2869
|
||||
anyio/abc/__pycache__/__init__.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_eventloop.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_resources.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_sockets.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_streams.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_subprocesses.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_tasks.cpython-311.pyc,,
|
||||
anyio/abc/__pycache__/_testing.cpython-311.pyc,,
|
||||
anyio/abc/_eventloop.py,sha256=OqWYSEj0TmwL_xniCJt3_jHFWsuMk9THk8tCTGsKapI,10681
|
||||
anyio/abc/_resources.py,sha256=DrYvkNN1hH6Uvv5_5uKySvDsnknGVDe8FCKfko0VtN8,783
|
||||
anyio/abc/_sockets.py,sha256=OmVDrfemVvF9c5K1tpBgQyV6fn5v0XyCExLAqBOGz9o,13124
|
||||
anyio/abc/_streams.py,sha256=HYvna1iZbWcwLROTO6IhLX79RTRLPShZMWe0sG1q54I,7481
|
||||
anyio/abc/_subprocesses.py,sha256=cumAPJTktOQtw63IqG0lDpyZqu_l1EElvQHMiwJgL08,2067
|
||||
anyio/abc/_tasks.py,sha256=m-FtE4phxeNIELSG7A3H7VUz3jA2Ib5J2JIew8-PS6o,6642
|
||||
anyio/abc/_testing.py,sha256=9YYM2AXsYFvf4PLjUEr6yRxDiUeB5QbY_gOg0X_C6lY,2034
|
||||
anyio/from_thread.py,sha256=JYsbaCaIB_Iit6kNhtXSteJGt4PcQ7ncq0nIpcelIrg,19265
|
||||
anyio/functools.py,sha256=T4JS8IXq-x1S0Lbo2owF8l9fza2KypO147QLeyz4cjs,11797
|
||||
anyio/itertools.py,sha256=QV-9mnRCr2yBph8g01QFvN-bQ_Yle-8Sl13YSydBlMI,16168
|
||||
anyio/lowlevel.py,sha256=WPtppHfI2qs1nokzjn8elL8LvyqI05AK5Zslhlo71A4,6242
|
||||
anyio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
anyio/pytest_plugin.py,sha256=paMpI_VMNQf2bir0LfvgMpXSiYJoHDzWdKUVTyoHmvQ,13609
|
||||
anyio/streams/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
anyio/streams/__pycache__/__init__.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/buffered.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/file.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/memory.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/stapled.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/text.cpython-311.pyc,,
|
||||
anyio/streams/__pycache__/tls.cpython-311.pyc,,
|
||||
anyio/streams/buffered.py,sha256=v3xKtjFHgNV41g2SvMAkA_qd2t9WYlCI1_lNGCAatw0,6650
|
||||
anyio/streams/file.py,sha256=msnrotVKGMQomUu_Rj2qz9MvIdUp6d3JGr7MOEO8kV4,4428
|
||||
anyio/streams/memory.py,sha256=F0zwzvFJKAhX_LRZGoKzzqDC2oMM-f-yyTBrEYEGOaU,10740
|
||||
anyio/streams/stapled.py,sha256=T8Xqwf8K6EgURPxbt1N4i7A8BAk-gScv-GRhjLXIf_o,4390
|
||||
anyio/streams/text.py,sha256=BcVAGJw1VRvtIqnv-o0Rb0pwH7p8vwlvl21xHq522ag,5765
|
||||
anyio/streams/tls.py,sha256=DQVkXUvsTEYKkBO8dlVU7j_5H8QOtLy4sGi1Wrjqevo,15303
|
||||
anyio/to_interpreter.py,sha256=_mLngrMy97TMR6VbW4Y6YzDUk9ZuPcQMPlkuyRh3C9k,7100
|
||||
anyio/to_process.py,sha256=68qhLfce7MeXysid4fOpmhfWkgdo7Z7-9BC0VyUciIE,9809
|
||||
anyio/to_thread.py,sha256=f6h_k2d743GBv9FhAnhM_YpTvWgIrzBy9cOE0eJ1UJw,2693
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (82.0.1)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[pytest11]
|
||||
anyio = anyio.pytest_plugin
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Alex Grönholm
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"files": [
|
||||
".pre-commit-config.yaml",
|
||||
"LICENSE",
|
||||
"pyproject.toml",
|
||||
"AGENTS.md",
|
||||
"README.rst",
|
||||
"CLAUDE.md",
|
||||
".readthedocs.yml",
|
||||
".gitignore",
|
||||
"docs/tempfile.rst",
|
||||
"docs/signals.rst",
|
||||
"docs/synchronization.rst",
|
||||
"docs/contextmanagers.rst",
|
||||
"docs/testing.rst",
|
||||
"docs/networking.rst",
|
||||
"docs/contributing.rst",
|
||||
"docs/index.rst",
|
||||
"docs/versionhistory.rst",
|
||||
"docs/threads.rst",
|
||||
"docs/api.rst",
|
||||
"docs/typedattrs.rst",
|
||||
"docs/basics.rst",
|
||||
"docs/fileio.rst",
|
||||
"docs/cancellation.rst",
|
||||
"docs/support.rst",
|
||||
"docs/streams.rst",
|
||||
"docs/why.rst",
|
||||
"docs/tasks.rst",
|
||||
"docs/migration.rst",
|
||||
"docs/conf.py",
|
||||
"docs/subprocesses.rst",
|
||||
"docs/faq.rst",
|
||||
"docs/subinterpreters.rst",
|
||||
"src/anyio/functools.py",
|
||||
"src/anyio/py.typed",
|
||||
"src/anyio/__init__.py",
|
||||
"src/anyio/pytest_plugin.py",
|
||||
"src/anyio/itertools.py",
|
||||
"src/anyio/to_interpreter.py",
|
||||
"src/anyio/from_thread.py",
|
||||
"src/anyio/to_process.py",
|
||||
"src/anyio/to_thread.py",
|
||||
"src/anyio/lowlevel.py",
|
||||
"src/anyio/_backends/_trio.py",
|
||||
"src/anyio/_backends/__init__.py",
|
||||
"src/anyio/_backends/_asyncio.py",
|
||||
"src/anyio/streams/memory.py",
|
||||
"src/anyio/streams/__init__.py",
|
||||
"src/anyio/streams/tls.py",
|
||||
"src/anyio/streams/file.py",
|
||||
"src/anyio/streams/text.py",
|
||||
"src/anyio/streams/stapled.py",
|
||||
"src/anyio/streams/buffered.py",
|
||||
"src/anyio/abc/_eventloop.py",
|
||||
"src/anyio/abc/__init__.py",
|
||||
"src/anyio/abc/_sockets.py",
|
||||
"src/anyio/abc/_tasks.py",
|
||||
"src/anyio/abc/_subprocesses.py",
|
||||
"src/anyio/abc/_resources.py",
|
||||
"src/anyio/abc/_streams.py",
|
||||
"src/anyio/abc/_testing.py",
|
||||
"src/anyio/_core/_typedattr.py",
|
||||
"src/anyio/_core/_eventloop.py",
|
||||
"src/anyio/_core/__init__.py",
|
||||
"src/anyio/_core/_tempfile.py",
|
||||
"src/anyio/_core/_sockets.py",
|
||||
"src/anyio/_core/_tasks.py",
|
||||
"src/anyio/_core/_fileio.py",
|
||||
"src/anyio/_core/_synchronization.py",
|
||||
"src/anyio/_core/_subprocesses.py",
|
||||
"src/anyio/_core/_resources.py",
|
||||
"src/anyio/_core/_contextmanagers.py",
|
||||
"src/anyio/_core/_exceptions.py",
|
||||
"src/anyio/_core/_streams.py",
|
||||
"src/anyio/_core/_signals.py",
|
||||
"src/anyio/_core/_asyncio_selector_thread.py",
|
||||
"src/anyio/_core/_testing.py",
|
||||
"tests/test_itertools.py",
|
||||
"tests/test_functools.py",
|
||||
"tests/test_eventloop.py",
|
||||
"tests/__init__.py",
|
||||
"tests/test_to_thread.py",
|
||||
"tests/test_from_thread.py",
|
||||
"tests/test_lowlevel.py",
|
||||
"tests/test_to_interpreter.py",
|
||||
"tests/test_sockets.py",
|
||||
"tests/test_typedattr.py",
|
||||
"tests/test_to_process.py",
|
||||
"tests/test_all_attributes.py",
|
||||
"tests/test_synchronization.py",
|
||||
"tests/test_debugging.py",
|
||||
"tests/test_contextmanagers.py",
|
||||
"tests/test_fileio.py",
|
||||
"tests/conftest.py",
|
||||
"tests/test_signals.py",
|
||||
"tests/test_deprecations.py",
|
||||
"tests/test_tempfile.py",
|
||||
"tests/test_taskgroups.py",
|
||||
"tests/test_pytest_plugin.py",
|
||||
"tests/test_subprocesses.py",
|
||||
"tests/streams/test_text.py",
|
||||
"tests/streams/test_memory.py",
|
||||
"tests/streams/__init__.py",
|
||||
"tests/streams/test_file.py",
|
||||
"tests/streams/test_stapled.py",
|
||||
"tests/streams/test_tls.py",
|
||||
"tests/streams/test_buffered.py",
|
||||
".github/pull_request_template.md",
|
||||
".github/dependabot.yml",
|
||||
".github/FUNDING.yml",
|
||||
".github/ISSUE_TEMPLATE/features_request.yaml",
|
||||
".github/ISSUE_TEMPLATE/bug_report.yaml",
|
||||
".github/ISSUE_TEMPLATE/config.yml",
|
||||
".github/workflows/test.yml",
|
||||
".github/workflows/test-downstream.yml",
|
||||
".github/workflows/publish.yml"
|
||||
]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"tag": "4.14.1",
|
||||
"distance": 0,
|
||||
"node": "g149b9e907618fadf6840a4d3cebad533b0c7d033",
|
||||
"dirty": false,
|
||||
"branch": "HEAD",
|
||||
"node_date": "2026-06-24"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
anyio
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._core._contextmanagers import AsyncContextManagerMixin as AsyncContextManagerMixin
|
||||
from ._core._contextmanagers import ContextManagerMixin as ContextManagerMixin
|
||||
from ._core._eventloop import current_time as current_time
|
||||
from ._core._eventloop import get_all_backends as get_all_backends
|
||||
from ._core._eventloop import get_available_backends as get_available_backends
|
||||
from ._core._eventloop import get_cancelled_exc_class as get_cancelled_exc_class
|
||||
from ._core._eventloop import run as run
|
||||
from ._core._eventloop import sleep as sleep
|
||||
from ._core._eventloop import sleep_forever as sleep_forever
|
||||
from ._core._eventloop import sleep_until as sleep_until
|
||||
from ._core._exceptions import BrokenResourceError as BrokenResourceError
|
||||
from ._core._exceptions import BrokenWorkerInterpreter as BrokenWorkerInterpreter
|
||||
from ._core._exceptions import BrokenWorkerProcess as BrokenWorkerProcess
|
||||
from ._core._exceptions import BusyResourceError as BusyResourceError
|
||||
from ._core._exceptions import ClosedResourceError as ClosedResourceError
|
||||
from ._core._exceptions import ConnectionFailed as ConnectionFailed
|
||||
from ._core._exceptions import DelimiterNotFound as DelimiterNotFound
|
||||
from ._core._exceptions import EndOfStream as EndOfStream
|
||||
from ._core._exceptions import IncompleteRead as IncompleteRead
|
||||
from ._core._exceptions import NoEventLoopError as NoEventLoopError
|
||||
from ._core._exceptions import RunFinishedError as RunFinishedError
|
||||
from ._core._exceptions import TaskCancelled as TaskCancelled
|
||||
from ._core._exceptions import TaskFailed as TaskFailed
|
||||
from ._core._exceptions import TaskNotFinished as TaskNotFinished
|
||||
from ._core._exceptions import TypedAttributeLookupError as TypedAttributeLookupError
|
||||
from ._core._exceptions import WouldBlock as WouldBlock
|
||||
from ._core._fileio import AsyncFile as AsyncFile
|
||||
from ._core._fileio import Path as Path
|
||||
from ._core._fileio import open_file as open_file
|
||||
from ._core._fileio import wrap_file as wrap_file
|
||||
from ._core._resources import aclose_forcefully as aclose_forcefully
|
||||
from ._core._signals import open_signal_receiver as open_signal_receiver
|
||||
from ._core._sockets import TCPConnectable as TCPConnectable
|
||||
from ._core._sockets import UNIXConnectable as UNIXConnectable
|
||||
from ._core._sockets import as_connectable as as_connectable
|
||||
from ._core._sockets import connect_tcp as connect_tcp
|
||||
from ._core._sockets import connect_unix as connect_unix
|
||||
from ._core._sockets import create_connected_udp_socket as create_connected_udp_socket
|
||||
from ._core._sockets import (
|
||||
create_connected_unix_datagram_socket as create_connected_unix_datagram_socket,
|
||||
)
|
||||
from ._core._sockets import create_tcp_listener as create_tcp_listener
|
||||
from ._core._sockets import create_udp_socket as create_udp_socket
|
||||
from ._core._sockets import create_unix_datagram_socket as create_unix_datagram_socket
|
||||
from ._core._sockets import create_unix_listener as create_unix_listener
|
||||
from ._core._sockets import getaddrinfo as getaddrinfo
|
||||
from ._core._sockets import getnameinfo as getnameinfo
|
||||
from ._core._sockets import notify_closing as notify_closing
|
||||
from ._core._sockets import wait_readable as wait_readable
|
||||
from ._core._sockets import wait_socket_readable as wait_socket_readable
|
||||
from ._core._sockets import wait_socket_writable as wait_socket_writable
|
||||
from ._core._sockets import wait_writable as wait_writable
|
||||
from ._core._streams import create_memory_object_stream as create_memory_object_stream
|
||||
from ._core._subprocesses import open_process as open_process
|
||||
from ._core._subprocesses import run_process as run_process
|
||||
from ._core._synchronization import CapacityLimiter as CapacityLimiter
|
||||
from ._core._synchronization import (
|
||||
CapacityLimiterStatistics as CapacityLimiterStatistics,
|
||||
)
|
||||
from ._core._synchronization import Condition as Condition
|
||||
from ._core._synchronization import ConditionStatistics as ConditionStatistics
|
||||
from ._core._synchronization import Event as Event
|
||||
from ._core._synchronization import EventStatistics as EventStatistics
|
||||
from ._core._synchronization import Lock as Lock
|
||||
from ._core._synchronization import LockStatistics as LockStatistics
|
||||
from ._core._synchronization import ResourceGuard as ResourceGuard
|
||||
from ._core._synchronization import Semaphore as Semaphore
|
||||
from ._core._synchronization import SemaphoreStatistics as SemaphoreStatistics
|
||||
from ._core._tasks import TASK_STATUS_IGNORED as TASK_STATUS_IGNORED
|
||||
from ._core._tasks import CancelScope as CancelScope
|
||||
from ._core._tasks import TaskHandle as TaskHandle
|
||||
from ._core._tasks import create_task_group as create_task_group
|
||||
from ._core._tasks import current_effective_deadline as current_effective_deadline
|
||||
from ._core._tasks import fail_after as fail_after
|
||||
from ._core._tasks import move_on_after as move_on_after
|
||||
from ._core._tempfile import NamedTemporaryFile as NamedTemporaryFile
|
||||
from ._core._tempfile import SpooledTemporaryFile as SpooledTemporaryFile
|
||||
from ._core._tempfile import TemporaryDirectory as TemporaryDirectory
|
||||
from ._core._tempfile import TemporaryFile as TemporaryFile
|
||||
from ._core._tempfile import gettempdir as gettempdir
|
||||
from ._core._tempfile import gettempdirb as gettempdirb
|
||||
from ._core._tempfile import mkdtemp as mkdtemp
|
||||
from ._core._tempfile import mkstemp as mkstemp
|
||||
from ._core._testing import TaskInfo as TaskInfo
|
||||
from ._core._testing import get_current_task as get_current_task
|
||||
from ._core._testing import get_running_tasks as get_running_tasks
|
||||
from ._core._testing import wait_all_tasks_blocked as wait_all_tasks_blocked
|
||||
from ._core._typedattr import TypedAttributeProvider as TypedAttributeProvider
|
||||
from ._core._typedattr import TypedAttributeSet as TypedAttributeSet
|
||||
from ._core._typedattr import typed_attribute as typed_attribute
|
||||
|
||||
# Re-export imports so they look like they live directly in this package
|
||||
for __value in list(locals().values()):
|
||||
if getattr(__value, "__module__", "").startswith("anyio."):
|
||||
__value.__module__ = __name__
|
||||
|
||||
|
||||
del __value
|
||||
|
||||
|
||||
def __getattr__(attr: str) -> type[BrokenWorkerInterpreter]:
|
||||
"""Support deprecated aliases."""
|
||||
if attr == "BrokenWorkerIntepreter":
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"The 'BrokenWorkerIntepreter' alias is deprecated, use 'BrokenWorkerInterpreter' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return BrokenWorkerInterpreter
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user